mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(adopt): an adopted session has no workspace of ours
The wrapper's workingDir is the neutral /tmp, and everything master grew since 1.33.2 that branches on session.remote or session.docker read that as a local session working in /tmp, because an adopted session keeps its connection on adopt.remote and adopt.docker. So uploads wrote into /tmp and closing the tab recursively deleted the shared folder; the Git panel, its fetch and the fetch scheduler ran git on whatever sat there; the image watcher, Read My Mind and the Discord bridge used that folder; last-response paired a transcript by it and could show another conversation's answer as this one's; a magic link in a container's output registered a file from this host's /tmp; and guarded input could type into a human's composer. Each now refuses or answers unsupported, the browser hides the rows that cannot work, and Session Options shows where the foreign pane was instead of /tmp. The custom-model route refuses as well, since it restarts the CLI in the pane. The local version probe is skipped: the local binary says nothing about a process someone else started, possibly on another host. Also moves the guard test to port 0, which test-ports-guard now requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f464a22bc3
commit
7de4201955
22 files changed
+269
-42
No files matched your search
@@ -3,6 +3,7 @@ import { expect, it, vi } from 'vitest';
|
||||
import { formatPaneSnapshot } from '../src/tmux-manager.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { promptSha256 } from '../src/agent-staging.js';
|
||||
import type { SessionAdopt } from '../src/types/session.js';
|
||||
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
|
||||
let screen = '› \n GPT-6-Luna default · ~/case\n';
|
||||
const mux = {
|
||||
@@ -542,3 +543,37 @@ it('stages through a real session whose first post-write capture is the torn fra
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers no guarded input on an adopted pane, however much it looks like a composer', () => {
|
||||
// Stage and submit type a prompt on an agent's behalf, here into a pane a human is using.
|
||||
const screen = '\u203a \n GPT-6-Luna default \u00b7 ~/case\n';
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: 2, cursorY: 0 });
|
||||
},
|
||||
sendInput: async () => true,
|
||||
};
|
||||
const build = (adopt?: SessionAdopt) =>
|
||||
new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
adopt,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
expect(build().guardedInputSnapshot()).not.toBeNull();
|
||||
expect(
|
||||
build({
|
||||
location: 'local',
|
||||
socketPath: '/tmp/tmux-1000/default',
|
||||
targetSession: 'work',
|
||||
viewSession: 'codeman-view-00000000',
|
||||
}).guardedInputSnapshot()
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -164,6 +164,7 @@ describe('unsupportedReason', () => {
|
||||
expect(unsupportedReason(local)).toBeUndefined();
|
||||
expect(unsupportedReason(remote)).toBe('remote');
|
||||
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
|
||||
expect(unsupportedReason({ ...local, adopt: { location: 'local' } })).toBe('adopted');
|
||||
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('the boot workspace-hook sweep', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
applyWorkspaceHooks.mockClear();
|
||||
server = new WebServer({ port: 3197 });
|
||||
server = new WebServer(0);
|
||||
(server as unknown as { getWorkspaceHooksEnabled: () => Promise<boolean> }).getWorkspaceHooksEnabled = async () =>
|
||||
true;
|
||||
});
|
||||
|
||||
@@ -263,6 +263,8 @@ describe('who runs', () => {
|
||||
const h = harness([
|
||||
VIEW('ssh', { remote: { host: 'x' } }),
|
||||
VIEW('docker', { docker: { container: 'c' } }),
|
||||
// A wrapper around a foreign tmux session sits on the neutral `/tmp`, no repository of its own.
|
||||
VIEW('adopted', { adopt: { location: 'local' } }),
|
||||
VIEW('local'),
|
||||
]);
|
||||
await h.scheduler.tick(0);
|
||||
|
||||
@@ -30,6 +30,16 @@ describe('sweepPasteImagesOnce', () => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists nothing for an adopted session, whose folder is the /tmp others may work in', async () => {
|
||||
// The delete path removes what this lists, recursively: closing an adopted tab
|
||||
// must not take the uploads of a real session sitting in that same folder.
|
||||
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
|
||||
dirs.push(workingDir);
|
||||
mkdirSync(join(workingDir, UPLOADS_DIR));
|
||||
expect(await uploadDirs({ workingDir })).toHaveLength(1);
|
||||
expect(await uploadDirs({ workingDir, adopt: { location: 'local' } })).toEqual([]);
|
||||
});
|
||||
|
||||
it('ages paste-* files out of both the current and the legacy upload dir', async () => {
|
||||
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
|
||||
dirs.push(workingDir);
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
*
|
||||
* Port: none (app.inject()).
|
||||
*/
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// The adopt route RE-RESOLVES the candidate rather than trusting the body, and
|
||||
@@ -37,6 +39,7 @@ import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { registerRespawnRoutes } from '../../src/web/routes/respawn-routes.js';
|
||||
import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js';
|
||||
import { registerMuxRoutes } from '../../src/web/routes/mux-routes.js';
|
||||
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
|
||||
import { adoptTargetIdentity, foreignTargetKey } from '../../src/foreign-tmux.js';
|
||||
import type { SessionAdopt } from '../../src/types/session.js';
|
||||
|
||||
@@ -377,3 +380,109 @@ describe('same-named sessions in different locations', () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an adopted session has no workspace of ours', () => {
|
||||
// Its `workingDir` is the neutral `/tmp`, which local-looking code would take for
|
||||
// the session's folder: uploads landed there and the tab's close deleted them, and
|
||||
// a Git panel or fetch ran git on whatever sat there. Each refusal is paired with a
|
||||
// plain session, or a broken route would pass it too.
|
||||
const adopted = (s: { isAdopted: boolean; adopt?: SessionAdopt }) => {
|
||||
s.isAdopted = true;
|
||||
s.adopt = localAdopt('/tmp/tmux-1000/default', 'work');
|
||||
};
|
||||
|
||||
it('refuses a prompt upload before touching disk', async () => {
|
||||
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
|
||||
adopted(ctx._session);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${ctx._sessionId}/paste-image`,
|
||||
headers: { 'x-codeman-csrf': '1' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/adopted/);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('refuses the image watcher, and leaves it alone being switched off', async () => {
|
||||
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
|
||||
adopted(ctx._session);
|
||||
const post = (enabled: boolean) =>
|
||||
app.inject({ method: 'POST', url: `/api/sessions/${ctx._sessionId}/image-watcher`, payload: { enabled } });
|
||||
expect((await post(true)).json().success).toBe(false);
|
||||
expect((await post(false)).json().success).toBe(true);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('refuses the per-session git fetch settings', async () => {
|
||||
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
|
||||
adopted(ctx._session);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${ctx._sessionId}/git-fetch-settings`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().error).toMatch(/adopted/);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('refuses a custom model, which restarts the CLI in the pane', async () => {
|
||||
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
|
||||
adopted(ctx._session);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${ctx._sessionId}/custom-model`,
|
||||
payload: { clear: true },
|
||||
});
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().error).toMatch(/adopted/);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('answers unsupported on the git routes, running no git', async () => {
|
||||
const git = vi.fn(async () => ({ stdout: '', stderr: '' }));
|
||||
const { app, ctx } = await createRouteTestHarness((a, c) =>
|
||||
registerGitStatusRoutes(a, c, git as never, async () => [])
|
||||
);
|
||||
adopted(ctx._session);
|
||||
const status = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/git-status` });
|
||||
expect(status.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } });
|
||||
const fetch = await app.inject({ method: 'POST', url: `/api/sessions/${ctx._sessionId}/git-fetch` });
|
||||
expect(fetch.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } });
|
||||
const diff = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/git-diff?repo=x&path=y&kind=z` });
|
||||
expect(diff.statusCode).toBe(400);
|
||||
expect(git).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('last-response', () => {
|
||||
let project: string;
|
||||
beforeEach(() => {
|
||||
project = join(process.env.HOME!, '.claude', 'projects', '-tmp');
|
||||
mkdirSync(project, { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('never pairs a transcript by the wrapper folder or the id we launched nothing with', async () => {
|
||||
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
|
||||
(ctx._session as unknown as { mode: string }).mode = 'claude';
|
||||
writeFileSync(
|
||||
join(project, `${ctx._sessionId}.jsonl`),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-07-21T00:00:01Z',
|
||||
message: { content: [{ type: 'text', text: 'an answer from another conversation' }] },
|
||||
})
|
||||
);
|
||||
const read = async () =>
|
||||
(await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/last-response` })).json();
|
||||
expect((await read()).text).toBe('an answer from another conversation');
|
||||
adopted(ctx._session);
|
||||
expect((await read()).text).toBe('');
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user