fix(adopt): key adopted targets by location, not just socket and name

Session 0 in two containers, or one name on this machine and on an ssh host
with the same uid, shared a (socket, name) pair, so opening one returned the
other's tab and its row read "Go to tab". foreignTargetKey() is now the one
key, built from the location, the host identity, the socket and the name, and
both the one-wrapper check and adoptedBy read it.

Both also read live sessions instead of mux records, which only exist once the
first attach starts the pane. An in-flight mark closes the rest of that window:
the route awaits the registries and the layout lock before the session lands in
the map. Docker targets are keyed per engine for the same reason, or a second
daemon's container of one name was never scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 03:03:05 +01:00
1 parent 57b2dcc466
commit a0e21109b2
6 files changed
+230 -41

No files matched your search

+6 -3
View File
@@ -184,8 +184,11 @@ function dockerTargets(cases: DockerCase[], hosts: DockerHost[]): DockerTargetIn
const seen = new Set<string>();
for (const c of cases) {
const container = c.container || `codeman-case-${c.name}`;
if (seen.has(container)) continue;
seen.add(container);
// Per engine, not global: two daemons can each run a container of one name,
// and collapsing them here would leave the second one unscanned.
const key = `${c.hostId}\u0000${container}`;
if (seen.has(key)) continue;
seen.add(key);
const host = byId.get(c.hostId);
out.push({
hostId: c.hostId,
@@ -230,7 +233,7 @@ export async function discoverDockerForeign(cases: DockerCase[]): Promise<Foreig
...candidatesFrom(
stdout,
'docker',
t.containerName,
`${t.hostId} ${t.containerName}`,
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
notes
)
+49 -1
View File
@@ -72,7 +72,7 @@ import { collectDescendants } from './proc-tree.js';
import { listClis } from './config/cli-registry/registry.js';
import { FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_MAX_SOCKETS } from './config/foreign-tmux.js';
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
import type { SessionMode } from './types/session.js';
import type { SessionAdopt, SessionMode } from './types/session.js';
// ===========================================================================
// Probe script
@@ -496,6 +496,54 @@ export function foreignSessionId(
return `f${fnv1a(`${location} ${hostKey} ${socketPath} ${sessionName}`)}`;
}
/**
* What names one foreign tmux session across every location Codeman can reach.
* A socket path and a session name are not enough on their own: a socket path is
* only meaningful INSIDE its location. Containers commonly run tmux as root
* (`/tmp/tmux-0/default`) with default names `0`, `1`, so session `0` in two
* containers is the same pair, and so is the same name on this machine and on an
* ssh host with the same uid.
*
* A `ForeignTmuxSession` candidate is one of these as it stands. A wrapper's
* `SessionAdopt` is turned into one by `adoptTargetIdentity()`.
*/
export interface ForeignTargetIdentity {
location: ForeignTmuxLocationKind;
/** Registry id of the docker or remote host. Absent for local. */
hostId?: string;
/** The container (`location === 'docker'`), which is only unique per engine, hence beside `hostId`. */
containerName?: string;
socketPath: string;
sessionName: string;
}
/**
* The ONE key that decides "is this foreign session already wrapped?". The adopt
* route's one-wrapper-per-target check and the listing's `adoptedBy` both read it;
* two keys are how a row ends up offering a tab that belongs to another location.
*
* Unlike `foreignSessionId` this is not opaque, and it stays on the server: the
* opaque id encodes a discovery detail a restored wrapper does not carry, while
* everything here is on `SessionAdopt` too.
*/
export function foreignTargetKey(target: ForeignTargetIdentity): string {
// \u0000 as an ESCAPE: see the note in candidatesFrom() about NUL bytes in source.
return [target.location, target.hostId ?? '', target.containerName ?? '', target.socketPath, target.sessionName].join(
'\u0000'
);
}
/** A wrapper's adoption record, as the identity of the foreign session it wraps. */
export function adoptTargetIdentity(adopt: SessionAdopt): ForeignTargetIdentity {
return {
location: adopt.location,
hostId: adopt.docker?.hostId ?? adopt.remote?.hostId,
containerName: adopt.docker?.containerName,
socketPath: adopt.socketPath,
sessionName: adopt.targetSession,
};
}
function fnv1a(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
+10 -10
View File
@@ -11,14 +11,15 @@
*/
import { FastifyInstance } from 'fastify';
import type { InfraPort } from '../ports/index.js';
import type { InfraPort, SessionPort } from '../ports/index.js';
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
import { requireAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js';
import { adoptTargetIdentity, foreignTargetKey } from '../../foreign-tmux.js';
import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js';
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort & SessionPort): void {
app.get('/api/mux-sessions', async (req, reply) => {
// Multi-user: this recovery/debug surface exposes every user's tmux + workdirs → admin-only
// (requireAdmin is a no-op allow-all in single-user mode, so flag-off is unchanged).
@@ -54,8 +55,9 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
* per target, and having the home page fan those out on every load is the one
* cost this design refuses to pay.
*
* `adoptedBy` is filled from the live mux sessions, so a target Codeman already
* wraps renders as "open" rather than offering a second wrapper.
* `adoptedBy` is filled from the live sessions, so a target Codeman already wraps
* renders as "open" rather than offering a second wrapper. Sessions and not mux
* records: the tmux record only exists once the tab's first attach starts the pane.
*/
app.get('/api/mux/foreign', async (req, reply) => {
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
@@ -77,18 +79,16 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
remoteHosts: wantRemote ? remoteHosts : undefined,
});
// Match on the (socket, session) pair rather than on our opaque candidate id:
// the id encodes a host key that a restored wrapper does not carry, while the
// pair is exactly what the wrapper stores and what it re-attaches to.
// The same key the adopt route's one-wrapper check reads (see foreignTargetKey).
const wrapped = new Map<string, string>();
for (const m of ctx.mux.getSessions()) {
if (m.adopt) wrapped.set(`${m.adopt.socketPath}\u0000${m.adopt.targetSession}`, m.sessionId);
for (const session of ctx.sessions.values()) {
if (session.adopt) wrapped.set(foreignTargetKey(adoptTargetIdentity(session.adopt)), session.id);
}
return {
sessions: result.sessions.map((f) => ({
...f,
adoptedBy: wrapped.get(`${f.socketPath}\u0000${f.sessionName}`),
adoptedBy: wrapped.get(foreignTargetKey(f)),
})),
scannedAt: result.scannedAt,
notes: result.notes,
+51 -22
View File
@@ -41,9 +41,10 @@ import {
readAllDockerCases,
readAllRemoteHosts,
} from '../../foreign-tmux-discovery.js';
import { foreignViewSessionName } from '../../foreign-tmux.js';
import { adoptTargetIdentity, foreignTargetKey, foreignViewSessionName } from '../../foreign-tmux.js';
import { requireAdmin } from '../route-helpers.js';
import type { SessionAdopt } from '../../types/session.js';
import type { ForeignTmuxSession } from '../../types/foreign-tmux.js';
import {
GuardedInputBusyError,
Session,
@@ -1499,6 +1500,21 @@ export function registerSessionRoutes(
// ========== Adopt a foreign tmux session ==========
/**
* The wrapper pane for an adopted session runs `tmux attach`, never the agent,
* so it needs no workspace, and must not borrow the foreign one. Same value
* `resolveMuxAttachCwd()` gives remote and docker panes.
*/
const ADOPTED_WRAPPER_WORKING_DIR = '/tmp';
/**
* Targets with a wrapper being created right now. The live-session check alone
* leaves a gap: between it and `addSession` the route awaits the host registries,
* the history config and the owner's layout lock, so two devices opening one row
* could each pass it.
*/
const adoptsInFlight = new Set<string>();
/**
* Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a
* Codeman session, so it appears as a tab and can be driven from the browser.
@@ -1518,24 +1534,14 @@ export function registerSessionRoutes(
* double-click on one device.
* 4. **Admin-only under multi-user.** Discovery already is (it exposes other
* users' processes), and adopting someone's `shell` is arbitrary execution
* as the server account — which is exactly what the `can-bypass-permissions`
* as the server account, which is exactly what the `can-bypass-permissions`
* grant gates elsewhere. The admin gate subsumes it, so there is deliberately
* no second grant check here.
*/
/**
* The wrapper pane for an adopted session runs `tmux attach`, never the agent,
* so it needs no workspace — and must not borrow the foreign one. Same value
* `resolveMuxAttachCwd()` gives remote and docker panes.
*/
const ADOPTED_WRAPPER_WORKING_DIR = '/tmp';
app.post('/api/sessions/adopt', async (req, reply) => {
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const owner = ownerFor(req);
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body');
// Re-resolve rather than trust: point 1 and 2 above.
@@ -1553,25 +1559,48 @@ export function registerSessionRoutes(
// flaky link makes a perfectly live remote session read as deleted. Measured
// against a real VM whose ssh path dropped ~10% of connections: clicking
// Open failed with "no longer there" while the session was sitting right
// there. Discovery already knows which it was — it wrote a note — so say so.
// there. Discovery already knows which it was: it wrote a note, so say so.
const reach = found.notes.filter((n) => !/skipped/.test(n));
return createErrorResponse(
ApiErrorCode.NOT_FOUND,
reach.length
? `Could not reach it just now (${reach.join('; ')}). It may still be running — try again.`
? `Could not reach it just now (${reach.join('; ')}). It may still be running, try again.`
: 'That tmux session is no longer there. Refresh the list and try again.'
);
}
// Point 3 — one wrapper per (socket, session).
const existing = ctx.mux
.getSessions()
.find((m) => m.adopt?.socketPath === target.socketPath && m.adopt?.targetSession === target.sessionName);
if (existing) {
const live = ctx.sessions.get(existing.sessionId);
if (live) return { session: ctx.getSessionStateWithRespawn(live), alreadyAdopted: true };
// Point 3: one wrapper per target. Live sessions and not mux records, which
// only exist once the first attach starts the pane, so reading those let a
// second adopt through before the first tab had started.
const targetKey = foreignTargetKey(target);
for (const session of ctx.sessions.values()) {
if (session.adopt && foreignTargetKey(adoptTargetIdentity(session.adopt)) === targetKey) {
return { session: ctx.getSessionStateWithRespawn(session), alreadyAdopted: true };
}
}
if (adoptsInFlight.has(targetKey)) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'That tmux session is already being opened');
}
// After the reuse above, which creates nothing and so must not be refused at capacity.
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
adoptsInFlight.add(targetKey);
try {
return await createAdoptedSession(req, target, owner, body);
} finally {
adoptsInFlight.delete(targetKey);
}
});
/** Everything past the guards in `POST /api/sessions/adopt`, held under its in-flight mark. */
async function createAdoptedSession(
req: FastifyRequest,
target: ForeignTmuxSession,
owner: string | undefined,
body: { name?: string; parentSessionId?: string }
) {
// Connection facts are copied onto the session rather than referenced by id:
// a wrapper restored after a server restart must be able to rebuild its
// command even if the host registry was edited in the meantime.
@@ -1648,7 +1677,7 @@ export function registerSessionRoutes(
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
return { session: lightState, adopted: true };
});
}
// ========== Rename Session ==========