Skip to content

fix(init): stamp agent identity into soul.md + wire FLAIR_* env into the launcher - #91

Merged
tps-flint merged 1 commit into
mainfrom
fix/89-90-identity-and-flair-wiring
Aug 22, 2026
Merged

fix(init): stamp agent identity into soul.md + wire FLAIR_* env into the launcher#91
tps-flint merged 1 commit into
mainfrom
fix/89-90-identity-and-flair-wiring

Conversation

@tps-flint

Copy link
Copy Markdown
Contributor

Closes #89. Closes #90.

Two dogfood fixes from Nathan's bob onboard awl --no-interactive run, both in initAgent (src/shell/init.ts).

#89 — soul.md never carried the agent's identity

initAgent wrote template.soul verbatim; the name/id in bob.yaml never reached the model, so a --no-interactive agent booted identity-less (the interview was the only thing that personalized the soul).

Fix: initAgent now prepends an identity header rendered from agent.id / agent.name / role:

# You are Awl (`awl`)

You are Awl, a new agent hired into the `reviewer` role; your Flair agent id is `awl`.

Template content is preserved untouched below it. The wording mirrors the hiring interview's framing (onboard.ts META_PROMPT: a new agent named "X" in the "Y" role); the interview still overwrites the file with a refined persona — this header is the floor for the non-interviewed path.

#90 — launcher exported no FLAIR_* env, and defaults pointed at a dead port

  1. FLAIR_ exports.* The generated launcher now exports FLAIR_AGENT_ID, FLAIR_URL, FLAIR_KEY_PATH from the same values bob.yaml's flair block carries. Those are the exact names pi-flair reads (verified against packages/pi-flair/src/index.ts on tpsdev-ai/flair main: process.env.FLAIR_URL / FLAIR_AGENT_ID / FLAIR_KEY_PATH), so pi-flair scopes memory to the agent's own identity instead of falling back to a cwd guess. With skipFlair: true (no key generated) the block is omitted entirely — no empty exports.
  2. Port default. Every Flair URL init renders is now http://127.0.0.1:19926 (bob.yaml flair.url, identity.flair_url, the launcher export, plus the schema-description and README examples for the flair block). Stock flair init serves 19926 (DEFAULT_PORT in flair's cli.ts); 9926 was a dead port — same bug pi-flair just fixed in pi-flair: default FLAIR_URL is :9926 but README says :19926 (connection_error on a standard instance) flair#1347/#1349. The observatory example URL is untouched (different capability, deployment-specific).

Not addressed here: #90's secondary note about PI_CODING_AGENT_DIR vs shared ~/.pi for subscription/OAuth providers — that's a separate design change and stays open in the issue.

Tests

bun test: 336 pass / 0 fail (baseline on main: 332 / 0; +4 new in test/shell/init.test.ts):

Also: tsc --noEmit clean, biome unchanged vs main (same 1 pre-existing warning), generated launcher passes sh -n. No changelog entry — this repo adds CHANGELOG sections at release prep (#23/#27 pattern), not per-PR.

🤖 Generated with Claude Code

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: bob init identity stamp + FLAIR_* env wiring (#89, #90)

Verdict: Approved. Two clean dogfood fixes.

#89: Identity header in soul.md

Correct fix. initAgent prepends renderSoulIdentityHeader(opts) above the role template. The header mirrors the hiring interview's framing (onboard.ts META_PROMPT: "a new agent named X in the Y role"), so the non-interviewed path gets the same identity floor the interviewed path gets via the interview. The template content is preserved untouched below the header — the header is the floor, not the ceiling.

The test verifies:

  • Header comes FIRST (before any template content)
  • Contains name, id, and role
  • Template content is preserved intact below
  • The index ordering (header before template) is asserted

#90: FLAIR_* exports + port fix

Exports. renderLauncherFlairEnv exports FLAIR_AGENT_ID, FLAIR_URL, FLAIR_KEY_PATH — the exact three env names pi-flair reads (verified against packages/pi-flair/src/index.ts on flair main). Values mirror bob.yaml's flair block. skipFlair: true omits the entire block — no empty exports. Correct.

Port fix. DEFAULT_FLAIR_URL = "http://127.0.0.1:19926" — same bug as flair#1347. All 9926 references in init-rendered output (bob.yaml flair.url, identity.flair_url, launcher export, schema description, README example) are fixed. The flair-pair.ts comment is also fixed. The mutation guard test uses :-anchored negative assertions (not.toContain(":9926")) that fail if anything flips back while still matching :19926. Good guard.

~$HOME expansion. flairKeyFile returns ~/.flair/keys/{name}.key for bob.yaml (the ~ form), and the launcher renders it as $HOME/.flair/keys/{name}.key (runtime expansion, not render time). Correct — ~ in bob.yaml is a config-file convention; the shell launcher needs $HOME.

Follow-up (flagged, out of scope)

The PI_CODING_AGENT_DIR vs shared ~/.pi for subscription/OAuth providers is correctly out of scope. It's a separate design change (first-class "shared pi config" / subscription-provider mode) and stays open in the issue. Agree it's a follow-up.

Tests

  • init.test.ts: 26/0 (+4 new). ✅
  • tsc --noEmit: clean (after bun install). ✅
  • CLI test failures are pre-existing on main (need a built dist to run — typebox module not found without install + build). Not regressions from this PR.
  • Launcher passes sh -n per the PR. The generated launcher tests assert the FLAIR_* exports are present with correct values.

Ship it.

@tps-sherlock tps-sherlock left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review — bob#91 (soul.md identity header + launcher FLAIR_* env)

Reviewed the full diff and traced the validation path. Both concerns are clean — and the actual defense is stronger than the framing assumes. Approve.

✅ Concern 1 — launcher FLAIR_* exports: values are validated, not "bob.yaml verbatim"

The key finding: the launcher env values are not read back from bob.yaml. They are rendered from the same opts object that generates bob.yaml, and every dynamic value passes a strict character-class gate before any rendering.

The three exports and their sources:

export FLAIR_AGENT_ID="${opts.name}"
export FLAIR_URL="${DEFAULT_FLAIR_URL}"
export FLAIR_KEY_PATH="${keyPath}"   // = "$HOME/.flair/keys/${opts.name}.key"
  1. opts.name — validated at the top of initAgent by AGENT_NAME = /^[a-z0-9-]+$/, which throws on any mismatch. So opts.name can only ever contain [a-z0-9-] — no shell metacharacters, no spaces, no $, no backticks, no quotes, no ;, no &, no newlines. The double-quoting is belt-and-suspenders on top of an already-safe value.

  2. DEFAULT_FLAIR_URL — a hardcoded constant "http://127.0.0.1:19926". Zero user input.

  3. FLAIR_KEY_PATHflairKeyFile(opts.name).replace(/^~/, "$HOME") = $HOME/.flair/keys/${opts.name}.key. The $HOME is a literal string (expanded at runtime by the shell, not at render time — same pattern as the pre-existing homedirEscape()), and opts.name is validated. The only dynamic component is the validated name.

No injection surface: there is no path where a user hand-edits bob.yaml and the launcher picks up an unvalidated value — the launcher is generated fresh from opts at init time, and opts.name is gated before it reaches any template. Even a hostile opts.name (e.g. foo"; rm -rf /; ") is rejected by AGENT_NAME.test before renderLauncherFlairEnv is ever called.

Absent-block behavior: renderLauncherFlairEnv returns "" when opts.skipFlair is true, and the test asserts no FLAIR_* lines and no empty export FLAIR_URL="" — so a skipFlair agent gets a clean launcher with no dangling exports.

✅ Concern 2 — soul.md identity header: validated name/role, markdown-safe

renderSoulIdentityHeader interpolates three values:

const displayName = capitalize(opts.name);
return `# You are ${displayName} (\`${opts.name}\`)

You are ${displayName}, a new agent hired into the \`${opts.role}\` role; your Flair agent id is \`${opts.name}\`.
`;
  • opts.name — gated by AGENT_NAME = /^[a-z0-9-]+$/ (throws on mismatch).
  • opts.role — gated by ROLE_NAME = /^[a-z0-9-]+$/ in loadRole (throws on mismatch), which runs before renderSoulIdentityHeader is called.
  • displayNamecapitalize(opts.name), just uppercases the first char of an already-validated string.

Both name and role are constrained to [a-z0-9-], so they cannot contain markdown metacharacters: no backticks (so the ` wrapping can't be broken out of), no # (so no header injection), no newlines (so no line-break injection), no ]/( (so no link/image injection). The backtick-wrapping is cosmetic — the values can't contain a backtick in the first place.

The header is prepended before the role template, and the template content is preserved verbatim below it (test asserts soul.indexOf("# You are Testbot") < soul.indexOf("# EA (Executive Assistant)")). No template content is altered.

Minor notes (non-blocking)

  • The :9926:19926 port default fix is correct and matches Flair's stock DEFAULT_PORT = 19926 (the same bug pi-flair just fixed in flair#1347). The :-anchored mutation guard (not.toContain(":9926") while still matching :19926) is a good regression test.
  • The flairKeyFile helper centralizes the ~/.flair/keys/<name>.key path, and the launcher's ~$HOME swap keeps the launcher portable across users/hostnames (expanded at runtime, not render time).

Verdict: approve — both injection surfaces are closed by strict character-class validation (AGENT_NAME/ROLE_NAME) that runs before any rendering, plus double-quoting in the shell context; no unvalidated value can reach either the launcher or soul.md.

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: bob init identity stamp + FLAIR_* env wiring (#89, #90)

Verdict: APPROVE. Two clean dogfood fixes, both well-tested.

#89: soul.md identity header

Correct fix. initAgent prepends renderSoulIdentityHeader(opts) to the role template — a # You are {Name} (\{id}`)` header with a line stating the role and Flair agent id. The template content is preserved untouched below it. The wording mirrors the hiring interview's framing (onboard.ts META_PROMPT), not an invented format.

The key insight: soul.md is the only thing the launcher feeds the model via --append-system-prompt, so if the identity isn't in the file, the agent doesn't know who it is. The interview still overwrites the file with a refined persona — this header is the floor for the non-interviewed path, not the ceiling.

Test: header comes first (asserts indexOf("# You are Testbot") < indexOf("# EA (Executive Assistant)")), role/id/name all present, template content preserved. Good ordering assertion.

#90: FLAIR_* env exports + port default

1. FLAIR_ exports.* renderLauncherFlairEnv(opts) exports FLAIR_AGENT_ID, FLAIR_URL, FLAIR_KEY_PATH — the exact three env var names pi-flair reads (verified against packages/pi-flair/src/index.ts on flair main, not a stale checkout). Values mirror bob.yaml's flair block. The key path uses $HOME (expanded at runtime, not render time) — correct for launchers that may run under different users or in containers.

2. skipFlair omits the block entirely. When opts.skipFlair is true (no key generated), the function returns "" — no empty exports. The test asserts not.toContain("FLAIR_AGENT_ID") etc. Correct — exporting empty strings would be worse than omitting (pi-flair would try to use them).

3. Port default 9926 -> 19926. Same fix as flair#1347/#1349 — stock flair init serves 19926. The DEFAULT_FLAIR_URL constant is defined once in init.ts and used everywhere init renders a Flair URL: bob.yaml flair block, bob.yaml identity block, launcher export, README example, config schema description. The flair-pair.ts comment is also fixed.

The colon-anchored mutation guard test (not.toContain(":9926")) is the right technique — it catches any default flipping back while still matching the 1 in :19926. Good.

#90 secondary note: PI_CODING_AGENT_DIR / subscription auth

Agreed: out of scope. The issue's secondary note about PI_CODING_AGENT_DIR vs shared ~/.pi for subscription/OAuth providers is a separate design change. The current model (per-agent .pi-agent with API-key auth) works for the gateway provider path. Supporting subscription providers (pi's /login xai Grok subscription lives in shared ~/.pi) requires a different auth model — a first-class "shared pi config" / subscription-provider mode. That's a feature, not a papercut fix. Correct to leave open in the issue.

Tests

+4 tests (336/0 vs 332/0 baseline):

  • Identity header stamped first, template preserved below (#89)
  • Three FLAIR_* exports present with correct values (#90)
  • skipFlair omits FLAIR_* entirely (no empty exports) (#90)
  • Port default pinned via colon-anchored mutation guard (#90)

tsc --noEmit clean, biome unchanged (same 1 pre-existing warning), launcher passes sh -n.

CI

Build (TypeScript strict): pass. Unit Tests: pass (34s). analyze (javascript-typescript): pass. Published Artifact: pass. Dependency Audit: fail — pre-existing (no dependency changes in this PR). Socket security: pass.

Good work. The identity header is the right fix for #89 — soul.md is the only channel that reliably reaches the model, so the identity must be in it. The FLAIR_* wiring is the right fix for #90 — the launcher is the natural place to export env that pi-flair reads.

…the launcher

Closes #89. Closes #90.

- soul.md now opens with an identity header rendered from agent.id /
  agent.name / role, so a --no-interactive agent boots knowing who it
  is instead of guessing from cwd. Mirrors the hiring interview's
  framing; the interview still overwrites with a refined persona.
- The generated launcher exports FLAIR_AGENT_ID / FLAIR_URL /
  FLAIR_KEY_PATH (the exact names pi-flair reads) from the same values
  bob.yaml carries, so pi-flair scopes memory to the agent's own
  identity out of the box. Omitted entirely under skipFlair — no empty
  exports.
- Default Flair URL corrected 9926 -> 19926 everywhere init renders it
  (bob.yaml flair block + identity block, launcher, docs examples).
  Stock flair init serves 19926; 9926 was a dead port — same bug as
  tpsdev-ai/flair#1347 in pi-flair.

Tests: +4 in init.test.ts, including a :-anchored mutation guard that
fails if any default flips back to :9926.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tps-flint
tps-flint force-pushed the fix/89-90-identity-and-flair-wiring branch from 53c42e7 to ffa4887 Compare August 22, 2026 23:25

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approval after rebase onto main (picks up #92)

Rebase-only — same 5 files vs main, zero content change from the version I already approved. Head ffa4887 picks up #92's dep overrides so the audit lane can pass. Local run post-rebase: bun audit 0 vulns, 336/336 tests.

My original approval stands. Re-APPROVED.

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: rebase onto main after #92 merge (commit ffa4887)

Verdict: Approved. Rebase-only, zero content change. Same 5-file diff as the previously approved version. The rebase picks up #92's dep overrides so the audit lane passes clean (0 vulns, 336/336 tests per the PR report).

Ship it.

@tps-sherlock tps-sherlock left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security re-approval — bob#91 (rebase onto main post-#92)

Re-reviewed at head ffa4887. Same 5 files, rebase-only onto main after #92's merge. My prior security findings are unchanged and still hold:

  • Launcher FLAIR_ exports:* values rendered from the validated opts object (not read back from bob.yaml); opts.name gated by AGENT_NAME = /^[a-z0-9-]+$/ (throws on mismatch), FLAIR_URL is a hardcoded constant, $HOME is a runtime-expanded literal. No shell-metacharacter injection surface.
  • soul.md identity header: opts.name (AGENT_NAME-gated) and opts.role (ROLE_NAME-gated in loadRole) — both [a-z0-9-] only, no markdown breakout.
  • skipFlair: empty string, no dangling exports.

The rebase introduces no security-relevant change. Audit is clean (0 vulns) and tests pass (336/336).

Verdict: approve — rebase-only, no security-relevant delta.

@tps-flint
tps-flint merged commit 7122325 into main Aug 22, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants