diff --git a/docs/agent-git-identity.md b/docs/agent-git-identity.md new file mode 100644 index 00000000..c660e38d --- /dev/null +++ b/docs/agent-git-identity.md @@ -0,0 +1,186 @@ +# Per-agent git identity + +**Status: idea, not implemented. Nothing in this document has been built.** Written 2026-08-10 from a +read of the live spawn path; the file:line references were verified against `dc6b623`. + +## The goal + +A team of agents works on this project, sometimes several of them in the same repository at once. Each +one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody +having to remember to say so. + +Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS +is concerned. + +## How git identity can be overridden at all + +Identity is unverified metadata, not authentication — a default, never a constraint. Anyone who can +commit can claim any name and email, by any of: + +```bash +git -c user.name=X -c user.email=x@y.z commit # per-invocation config +git commit --author="X " # author only; committer stays whoever ran it +GIT_AUTHOR_NAME=X GIT_AUTHOR_EMAIL=x@y.z \ +GIT_COMMITTER_NAME=X GIT_COMMITTER_EMAIL=x@y.z git commit # env; both identities +``` + +Precedence: `--author` > `GIT_AUTHOR_*` env > `-c user.email` > local config > global config. + +Two consequences that shape the design below: + +- **Author and committer are different fields.** `--author` alone leaves the committer as the owner, and + `git log` shows only the author by default. Set both, or the attribution is half-fiction. To read + both: `git log --format='%an <%ae> | %cn <%ce>'`. +- **Environment beats instruction.** Telling an agent "commit as X" in its prompt is a rule it can + forget. `GIT_AUTHOR_*` in the process environment applies to every git invocation in that process + whether or not anyone remembered. The whole point is to make the identity unforgettable rather than + well-intentioned, so this belongs in the environment. + +## Why the obvious approach does not work here + +The first instinct is a PM2 `env` block per agent, or setting `process.env.GIT_AUTHOR_NAME` in the +sidecar at boot. **Both are wrong on this platform**, and for the same reason: + +`officer-agent` is **one process running many concurrent sessions.** `sessions` is a +`Map` (`src/servers/sidecar/claude/claude-manager.ts:213`); each session owns +its own long-lived `query()` and its own warm `claude` child, reused across turns and collected after 30 +minutes idle. A singleton lockfile enforces one sidecar per owner (`user-instance.ts:95-98`). + +So a process-level variable — whether from PM2, from `.env`, or assigned in `user-instance.ts` — is +shared by every agent and every session on the box. It can say "an agent did this". It cannot say which. + +The `ecosystem.config.cjs` entry is bare anyway, which is worth recording since it looks like a place +where env might already be happening: + +```js +{ name: 'officer-agent', script: 'bun', args: 'run src/servers/sidecar/claude/user-instance.ts', watch: false }, +``` + +No `env`, no `cwd`, no `interpreter` — for any app in the file. + +## What the spawn path actually looks like + +The live path is the Agent SDK, not `Bun.spawn`. `claude-manager.ts:320-360`: + +```ts +const q = query({ + prompt: input.gen as AsyncIterable, + options: { + cwd: params.cwd ?? HOST_HOME, + permissionMode: 'bypassPermissions', + pathToClaudeCodeExecutable: CLAUDE_BIN, + env: cleanEnv as Record, + ... + }, +}); +``` + +`cleanEnv` is built once, at `claude-manager.ts:315`, by destructuring three keys back out of the +sidecar's own environment: + +```ts +const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env; +``` + +That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session +guards, and nothing is added per turn. + +**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there +is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point +for everything below. + +### What identity exists today + +Almost none, and none of it at the OS level. + +- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport + field on the pushed message. +- **Agent name and persona are prompt-only.** `buildAgentPrompt` + (`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user + message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file. +- The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)` + (`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their + transcripts. + +`grep -rn "GIT_AUTHOR\|GIT_COMMITTER" src/` returns nothing. Verified. Any commit an agent makes today +is attributed to whatever `~/.gitconfig` says — the human owner, identically for every agent. + +## The proposed change + +Three edits, all on the claude path: + +1. **`src/servers/sidecar/protocol.ts`** — add an optional `gitIdentity: { name: string; email: string }` + to `ClaudeSpawnStreamingParams` (the type begins at line 170). Optional so every existing caller is + untouched. +2. **`claude-manager.ts:315`** — build `cleanEnv` per session rather than once, merging the identity in + as all four variables when present: + ```ts + ...(params.gitIdentity && { + GIT_AUTHOR_NAME: params.gitIdentity.name, + GIT_AUTHOR_EMAIL: params.gitIdentity.email, + GIT_COMMITTER_NAME: params.gitIdentity.name, + GIT_COMMITTER_EMAIL: params.gitIdentity.email, + }), + ``` +3. **`agent-runner.ts:144`** — populate it from `agent.dirName`, next to where the cwd pin is already + derived from the same field. + +Chat sessions (`api/chat/websocket.ts`) would pass nothing and keep committing as the owner, which is +almost certainly right: a chat turn is the human driving directly. + +Use a domain that is actually controlled — `@officer.dev` — so Gitea can be made to map or +deliberately not-map these authors later. + +## Known limitations of the proposal + +### opencode cannot do this at all + +Since the `serve` migration (`a3dbda7`, phase D) there is **no process spawned per turn**. One shared +`opencode serve` starts at sidecar boot (`src/servers/sidecar/opencode/index.ts:112-116`) with no `env` +key — full inheritance — and turns are driven over HTTP against it, with per-turn cwd carried as a +request header (`serve-runner.ts:155`). Every session shares that one process environment. + +Per-agent git identity on the opencode path therefore requires either a serve per agent, or an upstream +API field. Neither is a small change, and this document does not propose one. + +### Attribution is not isolation, and isolation is the real problem + +There is **no filesystem isolation** between agents. They share one real `HOME` +(`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this +outright, and it is the stated reason `chat` is an `execution` capability that can never be granted. +`grep -ril worktree src/` returns nothing — worktrees are used nowhere. + +cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely. + +So two agents told to work on the same repository will share one working tree: fighting over +`index.lock`, staging each other's half-finished edits, interleaving commits. Per-agent identity makes +that **legible after the fact**. It does nothing to prevent it. + +If agents are genuinely to work the same repo concurrently, the isolation question is the larger and +more urgent one — a worktree or a clone per agent — and per-agent identity composes naturally with it +(a worktree per agent is also the cleanest place to put a per-worktree git identity). + +### If worktrees do arrive, note this trap + +`git worktree` shares one `.git/config`, so `git config --local user.email` in one worktree changes it +for **all** of them. Per-worktree config needs `extensions.worktreeConfig true` and then +`git config --worktree user.email …`. Environment variables sidestep the whole issue, which is another +argument for the env approach above. + +## Deliberately not proposed + +- **Signing.** Per-agent SSH signing keys would make attribution unforgeable rather than conventional. + For a trusted local fleet where every agent already runs as the owner with + `permissionMode: 'bypassPermissions'`, an agent that wanted to forge another's identity has far easier + routes. Revisit only if agents stop being equally trusted. +- **`Co-Authored-By:` trailers.** Useful when a human and an agent genuinely share a commit, and both + Gitea and GitHub attribute them. Orthogonal to this, and a prompt-level convention rather than an + environment one. + +## Open questions + +- Should pipeline steps (`pipeline-executor.ts`) get an identity too, or only named agents? +- Is `dirName` the right identity, or should an agent's `AGENT.md` declare its own name and email — so + the identity is authored where the persona is, rather than derived from a directory? +- Does anything downstream — Gitea webhooks, activity feeds, the dashboards — assume commits belong to + the owner and break when they do not?