Skip to content

Commit aa8f48a

Browse files
authored
feat(tmux): support explicitly configured local sockets (#127)
Owners can now discover and verify a bounded, explicit set of local tmux -L names or -S socket paths with CHATMUX_TMUX_SOCKETS. Default discovery is preserved when the option is absent. The P2 contract and configuration guide define the limits and conservative behavior when any configured socket is unavailable. Pane/idle identities include the socket. Fresh actions reject unconfigured, replaced, symlinked, or incorrectly owned sockets. Existing terminal leases retain private socket evidence and revalidate it before replay or reconnect; valid leases can reconnect after capability expiry, while stale evidence leaves the original PTY untouched. Validation: 166 focused discovery/action checks passed, including isolated real -L/-S servers. Independent Astra review found and fixed a cached-PTY reconnect gap; 49 focused lease checks, including 13 new regressions, typecheck and lint passed. Combined verification exposed four old idle-ID expectations in three real-tmux tests; those expectations now use the socket-qualified identity contract and are being rerun before merge. Required Node 22/24/bundle checks must pass on latest main. Limit: an unavailable configured socket conservatively retains the whole discovery lane and delays fresh agent actions. This bounded first implementation does not scan arbitrary filesystem sockets or terminate underlying tmux work.
1 parent 99e8097 commit aa8f48a

21 files changed

Lines changed: 1446 additions & 146 deletions

docs/P2-DISCOVERY-STREAM-RFC.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# P2 Discovery Stream RFC (M4a) — rev.2
1+
# P2 Discovery Stream RFC (M4a) — rev.3
22

3-
상태: **승인 대기(approval gate)** — 본 문서는 구현이 아니라 승인 게이트다. 승인 전 B12~B15 제품 소스 착수 금지(승인 계획 §5 B14 «승인 게이트», §9.2 BLOCK).
3+
상태: rev.2의 역사적 승인 게이트 기록은 아래에 보존한다. **rev.3의 명시적 로컬 tmux socket 지원(§4.1.1)은 2026-09-05 사용자 요청으로 승인되었다.** 이 승인은 해당 범위의 계약 개정과 구현을 함께 허용하며 다른 계약을 확대하지 않는다.
44

55
대상 백로그: B12(단일 수집기/권위 스냅샷), B13(epoch + reconnect snapshot→delta), B14(unavailable vs 종료 구분 + 응답 계약 신설), B15(폴링 제거 + 상수 비용 측정).
66

@@ -14,6 +14,7 @@
1414

1515
| rev | 사유 | 반영 내용 | 추적 |
1616
|---|---|---|---|
17+
| **rev.3** | 2026-09-05 owner-authorized explicit local socket inventory | §4.1.1: bounded configuration, socket-qualified identity, partial failure and action safety; default unchanged | `docs/TMUX-DISCOVERY.md` |
1718
| rev.1 | 최초 작성 | §1~§10 + 부록 A/B ||
1819
| **rev.2** | critic **ITERATE** 판정, required_changes 6건 | 아래 6행 | 부록 B |
1920
| rev.2 / RC1 | 4번째 폴링(live roster 5s) 누락 | §2.1에 **C0** 행 신설(절 제목 «폴링 4종(타이머 5개)»), §2.4 유일 호출부 명시, §2.6 `R_live` 재계산, §6.2 grace 근거 재산정(2초→5초, lane별 분리), §7.1 제거 대상 추가, §7.3 assert A/B/G 재작성 + U16, §8 B15-3 확장, §8.1 잔상 상한표, 부록 A #3·#7, 부록 B |
@@ -224,6 +225,57 @@ R'(N) = (2 + S)/C_SCAN + c_live/C_SCAN # 수집기 1개,
224225

225226
비책임: HTTP 응답 조립, authorization, tmux 쓰기, pane 캡처.
226227

228+
### 4.1.1 Explicit local tmux socket inventory (rev.3, normative)
229+
230+
- The owner MAY set `CHATMUX_TMUX_SOCKETS` in the existing server environment.
231+
Its value MUST be a JSON array of **1–8** objects, each containing exactly one
232+
`name` (`tmux -L`) or `path` (`tmux -S`). Names MUST match
233+
`[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}`; paths MUST be absolute, normalized, at most
234+
4,096 UTF-8 bytes, and contain no control characters. The encoded inventory
235+
MUST be at most 32 KiB. Invalid or duplicate entries MUST fail closed as a
236+
whole; invalid configuration MUST NOT fall back to the default server.
237+
- An unset or empty value MUST preserve the existing unqualified `tmux`
238+
discovery behavior, including tmux's inherited environment selection. An
239+
explicit inventory replaces that implicit selection: the owner MUST include
240+
`{"name":"default"}` when that server is wanted. No filesystem enumeration,
241+
shell evaluation, glob expansion, automatic server/session/agent creation, or
242+
client-supplied inventory is allowed. `-L` uses the service account UID and
243+
tmux's `TMUX_TMPDIR` (or `/tmp`), independent of inherited `TMUX`.
244+
- The host capture MUST inspect at most K configured sockets (`1 <= K <= 8`)
245+
and take **one shared `ps` roster** per full capture. A lightweight capture
246+
MUST run at most K `list-panes` commands and no `ps`. Each child MUST have a
247+
timeout and output bound; cancellation MUST stop only collector children.
248+
Filesystem waits MUST have a logical deadline and cancellation; uncancellable
249+
underlying work MUST remain globally bounded until completion. Superseded
250+
capture generations MUST drain before a new inventory capture starts.
251+
Cost is O(K + host processes), independent of browser count N. The original
252+
§7 single-socket bounds are the K=1 case; multiply only its `list-panes` term
253+
by K. The existing single collector and in-flight sharing remain mandatory.
254+
- Inventory membership and exact socket-qualified pane identity MUST be reused
255+
by collection and fresh actions. Equal session/window/pane IDs on different
256+
sockets MUST stay distinct through `tmuxPaneIdentityKey`. A matching cwd,
257+
label, pane ID, old display row, or another healthy socket MUST NOT authorize
258+
an action. Every explicit socket MUST be an owner-owned Unix socket; symlink
259+
leaves, changed filesystem identity during inspection, reported socket
260+
mismatches and inaccessible sockets MUST fail closed. Request-time actions
261+
MUST recheck that the exact socket remains in the explicit inventory and
262+
retain existing pane/process/provider generation checks. Filesystem evidence
263+
is server-private and MUST NOT become a browser credential.
264+
- Capture results MUST keep successful socket evidence separate from failed
265+
sockets. Failed sockets yield **no fresh panes**, and unavailable MUST NOT
266+
mean confirmed empty. A partial capture MUST report aggregate `ok: false`;
267+
current lane adapters MAY conservatively retain the complete prior display
268+
snapshot until a complete capture succeeds. This can delay healthy-socket
269+
display updates and fresh actions during an outage, but MUST NOT erase their
270+
prior rows, manufacture stale action targets, or reset missing-row grace as
271+
if failed sockets had succeeded. Fresh captures MUST never contain retained
272+
panes. A successful zero-pane capture is distinct from a failed command.
273+
- Per-socket outcomes, configuration, resolved paths, ownership/inode evidence,
274+
and raw command failures MUST remain server-private. Public diagnostics MAY
275+
expose bounded counts and closed reason codes only, never socket names,
276+
paths, argv, labels, or exception text. Fleet descriptors and host-qualified
277+
action routing remain governed by the existing Fleet RFC.
278+
227279
### 4.2 스냅샷 구조체
228280

229281
```ts

docs/TMUX-DISCOVERY.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Explicit local tmux sockets
2+
3+
ChatMux can discover coding-agent panes across a small, owner-configured set of
4+
local tmux servers. With `CHATMUX_TMUX_SOCKETS` unset or empty, it keeps the existing
5+
unqualified `tmux list-panes` selection, including inherited tmux environment
6+
behavior. It does not search socket directories or create tmux servers or agents.
7+
8+
## Configuration
9+
10+
Set `CHATMUX_TMUX_SOCKETS` in the existing server environment: the checkout's `.env`
11+
for development, or the managed installation's `chatmux.env` selected by
12+
`CHATMUX_ENV_FILE`. Restart ChatMux to load an edited environment file. Existing
13+
tmux sessions continue running independently.
14+
15+
```dotenv
16+
CHATMUX_TMUX_SOCKETS=[{"name":"default"},{"name":"work"},{"path":"/run/user/1000/agents.sock"}]
17+
```
18+
19+
The example is an environment-file line with an unquoted JSON value, accepted by
20+
both the managed runtime and Node’s development environment-file loader. For a
21+
shell `export`, quote the whole JSON value with single quotes.
22+
23+
The value is one JSON array. Every entry has exactly one field:
24+
25+
| Entry | Selection | Requirements |
26+
|---|---|---|
27+
| `{"name":"work"}` | `tmux -L work` | 1–64 ASCII letters, digits, `_`, `.`, `-`; first character must be a letter, digit or `_` |
28+
| `{"path":"/absolute/agents.sock"}` | `tmux -S /absolute/agents.sock` | Absolute, normalized path, at most 4,096 UTF-8 bytes; no control characters |
29+
30+
An explicit list **replaces** the implicit selection. Include `{"name":"default"}`
31+
if that server should remain visible. There must be 1–8 entries and at most 32 KiB
32+
of encoded configuration. Empty arrays, malformed JSON, extra fields, duplicate
33+
entries and names resolving to the same path as another entry are rejected as a
34+
whole. Invalid configuration does not fall back to another server.
35+
36+
Named sockets use the service account's UID and `TMUX_TMPDIR`, or `/tmp` when that
37+
variable is unset. `TMUX_TMPDIR` must identify an existing absolute directory;
38+
path lists, tilde expansion and custom compiled socket-directory layouts are not
39+
inferred. Use an exact `path` entry for those layouts. Explicit selection ignores
40+
inherited `TMUX` for choosing a server.
41+
42+
The existing Unix socket must belong to the account running ChatMux. Socket files
43+
and their paths must be canonical: symlink socket leaves or symlink path aliases
44+
are refused. A missing socket, permissions failure, unexpected reported socket
45+
path, or socket replacement during inspection makes that entry unavailable.
46+
Nothing creates or repairs sockets automatically.
47+
48+
## Identity and failures
49+
50+
The existing `socketPath + sessionId + windowId + paneId` identity is preserved.
51+
Two servers can both have `$0/@0/%0` and a session named `work`; those are different
52+
panes. Synthetic idle GJC row IDs use a SHA-256 digest of length-prefixed UTF-8
53+
fields from that complete identity and retain
54+
the `idle-gjc:` prefix, so equal labels and pane IDs cannot overwrite each other.
55+
This changes temporary idle row IDs once on upgrade; provider transcript IDs do
56+
not change. Working-directory matches never authorize actions.
57+
58+
Each capture records separate, server-private outcomes for the configured sockets.
59+
A failed socket contributes no fresh panes. If any socket fails, aggregate
60+
freshness is unavailable. The current external/live adapters conservatively
61+
retain the previous display rows for the entire lane; the existing lane health
62+
policy reports sustained failure after 30 failed ticks. Healthy-socket display
63+
updates and fresh agent actions can therefore be delayed until every configured
64+
socket is readable again. Missing sockets are not reported as confirmed session
65+
termination. A successful empty capture is different: normal missing-row grace
66+
can remove old rows.
67+
68+
Retained rows cannot authorize fresh actions or mint new shell/SSH attach
69+
capabilities. Actions recheck exact inventory membership and current socket
70+
ownership/filesystem generation along with the existing pane, process and provider
71+
checks. Socket evidence follows verified targets, attach capabilities and cached
72+
terminal leases only in server memory. Reconnecting a leased terminal rechecks its
73+
original pane generation, socket ownership and inventory membership before replay
74+
or PTY adoption. A matching lease can outlive the initial capability's expiry;
75+
missing or changed proof refuses the reconnect without stopping the tmux jobs.
76+
Replacing a socket at the same path, even with matching pane IDs, invalidates old
77+
targets and leases. Existing tmux multi-command operations still have their
78+
documented race between the final check and command execution; this feature does
79+
not make those operations atomic.
80+
81+
## Cost, privacy and verification
82+
83+
A shared full host capture starts at most K `tmux list-panes` commands and one
84+
`ps` command, where K is the configured socket count. A lightweight presence
85+
capture starts at most K tmux commands and no `ps`. The existing collector and
86+
in-flight sharing keep discovery cost independent of browser count. Each
87+
observation child has a four-second timeout and an 8 MiB output bound. Cancellation
88+
terminates only those observation children. Filesystem waits also have a four-second
89+
logical deadline and support cancellation. At most 16 filesystem operations may
90+
remain outstanding; timed-out syscalls retain their slots until they actually
91+
finish. Captures from a changed configuration drain before another generation
92+
starts, so configuration churn cannot multiply concurrent capture commands.
93+
94+
Configuration, raw selector values, socket outcomes and filesystem evidence are
95+
server-private. Public diagnostics retain closed codes and counts; command
96+
metrics count `tmux list-panes` without storing `-L`/`-S` values. Fleet descriptors
97+
and host routing follow the existing Fleet contract.
98+
99+
The normative contract is [P2 RFC §4.1.1](P2-DISCOVERY-STREAM-RFC.md#411-explicit-local-tmux-socket-inventory-rev3-normative).
100+
Selector behavior was checked against upstream tmux 3.5a's `tmux(1)` options and
101+
`tmux.c:make_label`, then exercised with isolated real tmux 3.5a servers. Focused
102+
regressions live in `host-discovery-snapshot.service.test.ts`,
103+
`local-tmux-discovery.service.test.ts`, and `explicit-tmux-sockets.test.ts` under
104+
`server/modules/providers/tests/`. They cover duplicate IDs, partial failure and
105+
retention, configuration rejection/change, unavailable ownership evidence,
106+
replacement, fresh-action/capability refusal, bounded output, and cancellation.

server/modules/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export {
3838
attachCapabilityService,
3939
createAttachCapabilityService,
4040
type AttachCapabilityService,
41+
type TmuxAttachLease,
4142
} from './services/attach-capability.service.js';
4243
export {
4344
getCurrentTmuxPaneIdentity,

server/modules/providers/provider.routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,7 @@ router.get(
925925
connectionIssue: session.connectionIssue,
926926
};
927927
if (session.kind === 'ssh' || session.kind === 'shell') {
928+
if (!result.ok) return base;
928929
const attachCapability = await attachCapabilityService.issue(
929930
String((req as typeof req & { user?: { id?: string | number } }).user?.id),
930931
session.tmux,

server/modules/providers/services/attach-capability.service.ts

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
22

33
import type { TmuxPaneIdentity } from '../../../../shared/tmux.js';
44

5+
import { assertLocalTmuxSocket, copyLocalTmuxSocketEvidence, rememberLocalTmuxSocket, sameLocalTmuxSocket, type LocalTmuxSocketInspector } from './local-tmux-discovery.service.js';
56
import { runTmux } from './builtin-relay.service.js';
67

78
const ATTACH_CAPABILITY_TTL_MS = 60_000;
@@ -16,20 +17,29 @@ type AttachCapabilityRecord = Readonly<{
1617
expiresAtMs: number;
1718
}>;
1819

20+
/** Server-only lease handle. Generation/ownership proof lives in the issuer's WeakMap. */
21+
export type TmuxAttachLease = Readonly<{
22+
principal: string;
23+
tmux: Readonly<TmuxPaneIdentity>;
24+
}>;
25+
1926
export type AttachCapabilityService = Readonly<{
2027
issue: (principal: string, tmux: TmuxPaneIdentity) => Promise<string | null>;
2128
verify: (token: unknown, principal: string, tmux: TmuxPaneIdentity) => Promise<boolean>;
29+
createLease: (token: unknown, principal: string, tmux: TmuxPaneIdentity) => Promise<TmuxAttachLease | null>;
30+
verifyLease: (lease: unknown, principal: string, tmux: TmuxPaneIdentity) => Promise<boolean>;
2231
size: () => number;
2332
}>;
2433

2534
export async function readTmuxPaneGeneration(tmux: TmuxPaneIdentity): Promise<string | null> {
2635
const result = await runTmux([
2736
'-S', tmux.socketPath,
2837
'display-message', '-p', '-t', tmux.paneId,
29-
'#{pane_pid}',
38+
'#{session_id}\t#{window_id}\t#{pane_id}\t#{pane_pid}',
3039
]);
31-
const generation = result.output.trim();
32-
return result.code === 0 && generation ? generation : null;
40+
const [sessionId, windowId, paneId, generation] = result.output.trim().split('\t');
41+
return result.code === 0 && sessionId === tmux.sessionId && windowId === tmux.windowId
42+
&& paneId === tmux.paneId && /^\d+$/.test(generation ?? '') ? generation : null;
3343
}
3444

3545
function samePane(a: TmuxPaneIdentity, b: TmuxPaneIdentity): boolean {
@@ -48,15 +58,27 @@ function paneKey(principal: string, tmux: TmuxPaneIdentity): string {
4858
* service (including on server restart) intentionally invalidates every token.
4959
*/
5060
export function createAttachCapabilityService(
51-
options: Readonly<{ now?: () => number; ttlMs?: number; maxRecords?: number; readPaneGeneration?: PaneGenerationReader }> = {},
61+
options: Readonly<{ now?: () => number; ttlMs?: number; maxRecords?: number; readPaneGeneration?: PaneGenerationReader; socketInspector?: LocalTmuxSocketInspector }> = {},
5262
): AttachCapabilityService {
5363
const records = new Map<string, AttachCapabilityRecord>();
64+
// Leases follow cached PTY lifetime, independently of token expiry/eviction.
65+
// Copying or deserializing a handle cannot recreate its private authority.
66+
const leases = new WeakMap<TmuxAttachLease, AttachCapabilityRecord>();
5467
const activeTokens = new Map<string, string>();
5568
const now = options.now ?? Date.now;
5669
const ttlMs = options.ttlMs ?? ATTACH_CAPABILITY_TTL_MS;
5770
const maxRecords = options.maxRecords ?? MAX_ATTACH_CAPABILITIES;
5871
const readPaneGeneration = options.readPaneGeneration ?? readTmuxPaneGeneration;
5972

73+
const readCheckedGeneration = async (tmux: TmuxPaneIdentity): Promise<string | null> => {
74+
const before = await assertLocalTmuxSocket(tmux, process.env, options.socketInspector);
75+
const generation = await readPaneGeneration(tmux);
76+
const after = await assertLocalTmuxSocket(tmux, process.env, options.socketInspector);
77+
if (before && (!after || !sameLocalTmuxSocket(before, after))) return null;
78+
if (after) rememberLocalTmuxSocket(tmux, after);
79+
return generation && after ? `${generation}\0${after.generation}` : generation;
80+
};
81+
6082
const remove = (token: string, record = records.get(token)): void => {
6183
if (!record) return;
6284
records.delete(token);
@@ -77,12 +99,31 @@ export function createAttachCapabilityService(
7799
}
78100
};
79101

102+
const verifiedRecord = async (token: unknown, principal: string, tmux: TmuxPaneIdentity): Promise<AttachCapabilityRecord | null> => {
103+
pruneExpired();
104+
if (typeof token !== 'string') return null;
105+
const record = records.get(token);
106+
if (!record || record.principal !== principal || !samePane(record.tmux, tmux)) return null;
107+
try {
108+
const generation = await readCheckedGeneration(record.tmux);
109+
if (record.expiresAtMs <= now()) {
110+
remove(token, record);
111+
return null;
112+
}
113+
// A concurrent issue() may supersede this token while inspection waits.
114+
if (records.get(token) !== record || activeTokens.get(paneKey(principal, tmux)) !== token) return null;
115+
return generation === record.generation ? record : null;
116+
} catch {
117+
return null;
118+
}
119+
};
120+
80121
return Object.freeze({
81122
async issue(principal, tmux) {
82123
pruneExpired();
83124
let generation: string | null;
84125
try {
85-
generation = await readPaneGeneration(tmux);
126+
generation = await readCheckedGeneration(tmux);
86127
} catch {
87128
return null;
88129
}
@@ -95,37 +136,33 @@ export function createAttachCapabilityService(
95136
if (previous) remove(previous);
96137
enforceLimit();
97138
const token = randomBytes(32).toString('base64url');
139+
const identity = Object.freeze({ ...tmux });
140+
copyLocalTmuxSocketEvidence(tmux, identity);
98141
records.set(token, Object.freeze({
99142
principal,
100-
tmux: Object.freeze({ ...tmux }),
143+
tmux: identity,
101144
generation,
102145
expiresAtMs: now() + ttlMs,
103146
}));
104147
activeTokens.set(key, token);
105148
return token;
106149
},
107150
async verify(token, principal, tmux) {
108-
pruneExpired();
109-
if (typeof token !== 'string') return false;
110-
const record = records.get(token);
111-
if (!record || record.principal !== principal || !samePane(record.tmux, tmux)) {
112-
return false;
113-
}
114-
151+
return await verifiedRecord(token, principal, tmux) !== null;
152+
},
153+
async createLease(token, principal, tmux) {
154+
const record = await verifiedRecord(token, principal, tmux);
155+
if (!record) return null;
156+
const lease = Object.freeze({ principal: record.principal, tmux: record.tmux });
157+
leases.set(lease, record);
158+
return lease;
159+
},
160+
async verifyLease(lease, principal, tmux) {
161+
if (!lease || typeof lease !== 'object') return false;
162+
const record = leases.get(lease as TmuxAttachLease);
163+
if (!record || record.principal !== principal || !samePane(record.tmux, tmux)) return false;
115164
try {
116-
const generation = await readPaneGeneration(tmux);
117-
if (record.expiresAtMs <= now()) {
118-
remove(token, record);
119-
return false;
120-
}
121-
// A concurrent issue() may have observed a newer pane generation and
122-
// superseded this token while the read was pending. Accepting the stale
123-
// snapshot would revive a revoked capability, so re-assert that this
124-
// record is still the active one for its principal and pane.
125-
if (records.get(token) !== record || activeTokens.get(paneKey(principal, tmux)) !== token) {
126-
return false;
127-
}
128-
return generation === record.generation;
165+
return await readCheckedGeneration(record.tmux) === record.generation;
129166
} catch {
130167
return false;
131168
}

0 commit comments

Comments
 (0)