mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
7e70f6f0c3
commit
2c084966f1
6 files changed
+43
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user