write down how an agent could commit as itself
Not built, and deliberately so — this is the idea as it stood, with the spawn path read at dc6b623 so
the next person does not have to re-derive it.
The obvious approach is wrong here and the document leads with why: officer-agent is one process
holding many sessions, so a PM2 env block or anything set in user-instance.ts is shared by every agent
on the box and cannot distinguish them. The injection point that does work is claude-manager.ts:315,
where cleanEnv is built once today but is already a per-query() option.
Recorded alongside it: opencode cannot do this at all since the serve migration, because no process is
spawned per turn; and per-agent identity is attribution, not isolation — agents share one working tree,
so two of them in one repo will still fight over index.lock. That is the larger problem and it is named
rather than solved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <x@y.z>" # 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<string, PersistentSession>` (`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<SdkUserMessage>,
|
||||
options: {
|
||||
cwd: params.cwd ?? HOST_HOME,
|
||||
permissionMode: 'bypassPermissions',
|
||||
pathToClaudeCodeExecutable: CLAUDE_BIN,
|
||||
env: cleanEnv as Record<string, string>,
|
||||
...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`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 — `<dirName>@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?
|
||||
Reference in New Issue
Block a user