fix: refuse one-shot prompts on an adopted session (release review)

`POST /api/sessions/:id/run` had no adopted gate, so while the wrapper had no
attached PTY it spawned `claude -p --dangerously-skip-permissions <prompt>`
in /tmp, a launch every other adopted route refuses.

The route answers INVALID_INPUT, and `runPrompt()` refuses too, which also
covers the legacy `sendInput()` path and leaves no direct spawn in Session
that an adopted session can reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 07:10:49 +01:00
1 parent 7e70f6f0c3
commit 2c084966f1
6 files changed
+43 -5

No files matched your search

+4 -4
View File
@@ -1185,10 +1185,10 @@ Closing the session detaches. The foreign session keeps running, and only Codema
wrapper and its grouped `codeman-view-<id>` session go, so `DELETE` removes nothing that
belongs to someone else. Codeman did not start this agent, so these refuse an adopted
session: respawn, Ralph (its config and the `fix-plan/write`, `fix-plan/read` and
`ralph-prompt/write` file routes), auto-clear, auto-compact, auto-resume, custom model
endpoints, prompt uploads and the image watcher. `stop` waits are a 400, and so are
`blocked` waits unless Codeman reads that CLI's dialogs off its screen (codex), the git
routes answer `unsupported` with `reason: "adopted"`, and
`ralph-prompt/write` file routes), one-shot `POST .../run`, auto-clear, auto-compact,
auto-resume, custom model endpoints, prompt uploads and the image watcher. `stop` waits
are a 400, and so are `blocked` waits unless Codeman reads that CLI's dialogs off its
screen (codex), the git routes answer `unsupported` with `reason: "adopted"`, and
`last-response` answers empty, since Codeman knows no transcript for it. Its `paneExit`
stays absent, because the pane holds a `tmux attach` and not the agent. Reboot restore
does not rebuild one. A wrapper that cannot start answers `OPERATION_FAILED` and leaves
+1 -1
View File
@@ -109,7 +109,7 @@ Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, ne
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response`, guarded input and Ralph's `@fix_plan.md` and `@ralph_prompt.md` routes all do. The file surfaces (Files panel, File Viewer and its edit mode) still treat `/tmp` as the workspace, which is harmless: it is world-writable anyway, and adoption is admin-only in multi-user mode. A wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
**Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command. ⚠️ A wrapper that cannot start fails the start, and selecting the tab tries it again (`_failAdoptedStart()`). It never takes the direct-PTY fallback, which would run a new local `claude --dangerously-skip-permissions` or a server-account shell in `/tmp` under the foreign session's name.
**Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command. ⚠️ A wrapper that cannot start fails the start, and selecting the tab tries it again (`_failAdoptedStart()`). It never takes the direct-PTY fallback, which would run a new local `claude --dangerously-skip-permissions` or a server-account shell in `/tmp` under the foreign session's name, and `runPrompt()` refuses it for the same reason.
### Docker cases
+2
View File
@@ -4581,6 +4581,8 @@ export class Session extends EventEmitter {
prompt: string,
options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void }
): Promise<{ result: string; cost: number }> {
// ⚠️ A one-shot `claude -p` is a new local process in `/tmp`, never the agent in that pane.
if (this._adopt) throw new Error('One-shot prompts are not available for adopted sessions');
if (this.guardedInputHeld) {
this._guardedInputRevision++;
throw new Error('Guarded submission in progress');
+9
View File
@@ -2070,6 +2070,15 @@ export function registerSessionRoutes(
const { prompt } = parseBody(RunPromptSchema, req.body);
const session = findSessionOrFail(ctx, id, req);
// Its own check: `runPrompt` starts a new local claude, and the answer has to say so
// here rather than as a `session:error` after a success.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'One-shot prompts are not available for adopted sessions: Codeman did not start this agent'
);
}
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
+6
View File
@@ -251,4 +251,10 @@ describe('the direct-PTY fallback', () => {
await expect(s[start]()).rejects.toThrow(/tmux/);
expect(ptySpawn).not.toHaveBeenCalled();
});
it('refuses a one-shot prompt, a new local claude rather than the agent in that pane', async () => {
const { s } = session('claude', { adopt });
await expect(s.runPrompt('hello')).rejects.toThrow(/adopted/i);
expect(ptySpawn).not.toHaveBeenCalled();
});
});
@@ -82,6 +82,27 @@ describe('adopted sessions refuse every auto-op that drives the pane', () => {
});
}
it('refuses a one-shot prompt, which would start a new local claude in /tmp', async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/run`,
payload: { prompt: 'hello' },
});
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/i);
expect(h.ctx._session.runPrompt).not.toHaveBeenCalled();
await h.app.close();
});
it('still runs a one-shot prompt on a session Codeman started', async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/run`, payload: { prompt: 'hello' } });
expect(h.ctx._session.runPrompt).toHaveBeenCalledWith('hello');
await h.app.close();
});
it('refuses respawn, for a harder reason: it kills and restarts the pane', async () => {
const h = await createRouteTestHarness(registerRespawnRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;