fix(init): stamp agent identity into soul.md + wire FLAIR_* env into the launcher - #91
Conversation
tps-kern
left a comment
There was a problem hiding this comment.
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 —
typeboxmodule not found without install + build). Not regressions from this PR. - Launcher passes
sh -nper the PR. The generated launcher tests assert the FLAIR_* exports are present with correct values.
Ship it.
tps-sherlock
left a comment
There was a problem hiding this comment.
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"-
opts.name— validated at the top ofinitAgentbyAGENT_NAME = /^[a-z0-9-]+$/, which throws on any mismatch. Soopts.namecan 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. -
DEFAULT_FLAIR_URL— a hardcoded constant"http://127.0.0.1:19926". Zero user input. -
FLAIR_KEY_PATH—flairKeyFile(opts.name).replace(/^~/, "$HOME")=$HOME/.flair/keys/${opts.name}.key. The$HOMEis a literal string (expanded at runtime by the shell, not at render time — same pattern as the pre-existinghomedirEscape()), andopts.nameis 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 byAGENT_NAME = /^[a-z0-9-]+$/(throws on mismatch).opts.role— gated byROLE_NAME = /^[a-z0-9-]+$/inloadRole(throws on mismatch), which runs beforerenderSoulIdentityHeaderis called.displayName—capitalize(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→:19926port default fix is correct and matches Flair's stockDEFAULT_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
flairKeyFilehelper centralizes the~/.flair/keys/<name>.keypath, and the launcher's~→$HOMEswap 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
left a comment
There was a problem hiding this comment.
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>
53c42e7 to
ffa4887
Compare
tps-kern
left a comment
There was a problem hiding this comment.
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-sherlock
left a comment
There was a problem hiding this comment.
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
optsobject (not read back from bob.yaml);opts.namegated byAGENT_NAME = /^[a-z0-9-]+$/(throws on mismatch),FLAIR_URLis a hardcoded constant,$HOMEis a runtime-expanded literal. No shell-metacharacter injection surface. - soul.md identity header:
opts.name(AGENT_NAME-gated) andopts.role(ROLE_NAME-gated inloadRole) — 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.
Closes #89. Closes #90.
Two dogfood fixes from Nathan's
bob onboard awl --no-interactiverun, both ininitAgent(src/shell/init.ts).#89 — soul.md never carried the agent's identity
initAgentwrotetemplate.soulverbatim; the name/id inbob.yamlnever reached the model, so a--no-interactiveagent booted identity-less (the interview was the only thing that personalized the soul).Fix:
initAgentnow prepends an identity header rendered fromagent.id/agent.name/ role: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
FLAIR_AGENT_ID,FLAIR_URL,FLAIR_KEY_PATHfrom the same valuesbob.yaml'sflairblock carries. Those are the exact names pi-flair reads (verified againstpackages/pi-flair/src/index.tson 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. WithskipFlair: true(no key generated) the block is omitted entirely — no empty exports.http://127.0.0.1:19926(bob.yamlflair.url,identity.flair_url, the launcher export, plus the schema-description and README examples for the flair block). Stockflair initserves 19926 (DEFAULT_PORTin 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_DIRvs shared~/.pifor 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 intest/shell/init.test.ts):FLAIR_*exports present with values from the flair config (bob: generated launcher doesn't export FLAIR_* env and bob.yaml defaults to wrong :9926 — agent can't reach its own Flair memory #90)skipFlair→ noFLAIR_*lines at all (no empty exports):-anchored mutation guard —not.toContain(":9926")fails if any default flips back, while still matching:19926Also:
tsc --noEmitclean, biome unchanged vs main (same 1 pre-existing warning), generated launcher passessh -n. No changelog entry — this repo adds CHANGELOG sections at release prep (#23/#27 pattern), not per-PR.🤖 Generated with Claude Code