fix: adopted tmux targets, hung servers and the attach chain (release review)

Names tmux reads as target syntax (`@1`, `%1`, `=eq`, a bare `~`) passed the
has-session guard by name and then joined something else in new-session -t:
another session, or a fresh shell on the foreign server. They are refused at
discovery now, and take the existing skipped note.

One stopped tmux server emptied the whole local list and left a stuck client
per scan. Each server now gets a bounded ping with stdout at /dev/null first,
because a tmux client hands dups of its stdout to the server: a stopped server
holds our pipe open after the client is dead, so a watchdog around list-panes
cannot work. The probe runs in its own process group, all three transports
keep the rows read before a failure, and a missed ping becomes a note.

The attach and the probe pass -u, so a POSIX-locale container no longer garbles
the tab or lists a non-ASCII name as underscores. A docker attach gets the same
TERM and COLORTERM an owned container's exec gets.

A missing tmux no longer reads as "session is gone", and the launch line's `$`
now reaches bash unexpanded, so the ssh attach's PATH line is expanded at the
far end, as the probe's already was.

fix-plan/write, fix-plan/read and ralph-prompt/write refuse an adopted session:
its workingDir is the neutral /tmp, so they read and wrote a predictable name
in a folder anyone can write to.

FOREIGN_MAX_SOCKETS is gone: it capped nothing, and a real cap in glob order
would never reach `default` on a box with many `codeman-*` sockets (this one
has 212). Closing an adopted tab re-reads the outside-sessions list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 04:33:42 +01:00
1 parent 36d8942744
commit aa40cca343
9 files changed
+620 -81

No files matched your search

+39 -1
View File
@@ -14,7 +14,8 @@
*
* Port: none (app.inject()).
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -102,6 +103,43 @@ describe('adopted sessions refuse every auto-op that drives the pane', () => {
expect(res.json().error).toMatch(/adopted/i);
await h.app.close();
});
// ⚠️ Neither of these is a mock-only concern: an adopted session's `workingDir` is the
// neutral wrapper folder `/tmp`, so a live one wrote `/tmp/@ralph_prompt.md` on the host
// and answered success, a predictable name in a folder anyone can write to.
describe.each([
['fix-plan/write', {}],
['fix-plan/read', {}],
['ralph-prompt/write', { content: 'prompt' }],
])('POST %s', (route, payload) => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'adopted-ralph-'));
writeFileSync(join(dir, '@fix_plan.md'), '- [ ] task\n');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
const post = async (isAdopted: boolean) => {
const h = await createRouteTestHarness(registerRalphRoutes, { sessionId: SID });
const ralphTracker = { todos: [], generateFixPlanMarkdown: () => '', importFixPlanMarkdown: () => 0 };
Object.assign(h.ctx._session as object, { isAdopted, workingDir: dir, ralphTracker });
const res = await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/${route}`, payload });
await h.app.close();
return res.json();
};
it('refuses an adopted session and touches nothing', async () => {
const before = readdirSync(dir);
const body = await post(true);
expect(body.success).toBe(false);
expect(body.error).toMatch(/adopted/i);
expect(readdirSync(dir)).toEqual(before);
});
it('still answers for a session Codeman started', async () => {
expect((await post(false)).success).toBe(true);
});
});
});
describe('GET /api/mux/foreign', () => {