diff --git a/docs/api-reference.md b/docs/api-reference.md index 00e64663..38441dfd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1185,10 +1185,10 @@ Closing the session detaches. The foreign session keeps running, and only Codema wrapper and its grouped `codeman-view-` 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 diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 90238859..dcc39f7b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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 diff --git a/src/session.ts b/src/session.ts index 5bc1176c..c08a069e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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'); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index ca0b86ca..1838e40c 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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'); } diff --git a/test/foreign-adoption-guards.test.ts b/test/foreign-adoption-guards.test.ts index bed71d24..2e5605aa 100644 --- a/test/foreign-adoption-guards.test.ts +++ b/test/foreign-adoption-guards.test.ts @@ -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(); + }); }); diff --git a/test/routes/foreign-adoption-routes.test.ts b/test/routes/foreign-adoption-routes.test.ts index 7ca8c0a2..4ba4aec7 100644 --- a/test/routes/foreign-adoption-routes.test.ts +++ b/test/routes/foreign-adoption-routes.test.ts @@ -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;