@volter/supercode-teams 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/attention.mjs +191 -0
- package/bin/fleet.mjs +151 -0
- package/bin/teams.mjs +291 -0
- package/bin/workspace/args.mjs +66 -0
- package/bin/workspace/files.mjs +110 -0
- package/bin/workspace/lifecycle.mjs +113 -0
- package/bin/workspace/volter-device.mjs +42 -0
- package/bin/workspace/volter-login.mjs +185 -0
- package/bin/workspace.mjs +726 -0
- package/browser-attach.mjs +90 -0
- package/client.mjs +212 -0
- package/connector/effects.mjs +95 -0
- package/connector/index.d.ts +69 -0
- package/connector/index.mjs +174 -0
- package/connector/native-continuation.d.ts +8 -0
- package/connector/native-continuation.mjs +50 -0
- package/connector/native-effects.mjs +164 -0
- package/context.mjs +34 -0
- package/contracts/adapter-map.json +16 -0
- package/contracts/index.d.ts +299 -0
- package/contracts/index.mjs +37 -0
- package/execution.mjs +206 -0
- package/fleet.mjs +78 -0
- package/index.mjs +12 -0
- package/machine-door.mjs +36 -0
- package/machine.mjs +1398 -0
- package/package.json +80 -0
- package/porthole-hook.cjs +137 -0
- package/remote/job-exec.py +146 -0
- package/remote/wire.py +23 -0
- package/server/arc2-transactions.mjs +125 -0
- package/server/attachment-relay.mjs +149 -0
- package/server/catalog.mjs +7 -0
- package/server/continuations.mjs +56 -0
- package/server/correlation.d.ts +4 -0
- package/server/correlation.mjs +17 -0
- package/server/effects-transactions.mjs +119 -0
- package/server/effects.mjs +97 -0
- package/server/event-routes.mjs +208 -0
- package/server/exports.mjs +46 -0
- package/server/harness-routes.mjs +248 -0
- package/server/index.d.ts +31 -0
- package/server/index.mjs +52 -0
- package/server/ingest.mjs +24 -0
- package/server/node-upgrade.mjs +39 -0
- package/server/otlp/collector-config.d.ts +2 -0
- package/server/otlp/collector-config.mjs +26 -0
- package/server/otlp/decoders/index.d.ts +63 -0
- package/server/otlp/decoders/index.mjs +311 -0
- package/server/otlp/index.d.ts +4 -0
- package/server/otlp/index.mjs +4 -0
- package/server/otlp/projection.d.ts +5 -0
- package/server/otlp/projection.mjs +17 -0
- package/server/otlp/receiver.d.ts +3 -0
- package/server/otlp/receiver.mjs +36 -0
- package/server/otlp/schema/google/protobuf/any.proto +162 -0
- package/server/otlp/schema/google/rpc/status.proto +48 -0
- package/server/otlp/schema/manifest.json +22 -0
- package/server/otlp/schema/opentelemetry/proto/collector/logs/v1/logs_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/collector/metrics/v1/metrics_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/collector/trace/v1/trace_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/common/v1/common.proto +154 -0
- package/server/otlp/schema/opentelemetry/proto/logs/v1/logs.proto +226 -0
- package/server/otlp/schema/opentelemetry/proto/metrics/v1/metrics.proto +735 -0
- package/server/otlp/schema/opentelemetry/proto/resource/v1/resource.proto +45 -0
- package/server/otlp/schema/opentelemetry/proto/trace/v1/trace.proto +359 -0
- package/server/otlp/transport.d.ts +10 -0
- package/server/otlp/transport.mjs +424 -0
- package/server/otlp/wire-codec.mjs +153 -0
- package/server/store-core.mjs +139 -0
- package/server/store-worker.mjs +23 -0
- package/worker/fetch-adapter.mjs +49 -0
- package/worker/index.mjs +127 -0
- package/worker/websocket-duplex.mjs +29 -0
- package/worker/wrangler.toml +38 -0
- package/workspace-client.d.ts +75 -0
- package/workspace-client.mjs +171 -0
- package/workspace-move.mjs +38 -0
package/machine.mjs
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
// The machine daemon: one per OS user and home. It is the neutral core
|
|
2
|
+
// (machine-core.mjs: identity, log, the local door, the server link, admission and
|
|
3
|
+
// product doors) with the harness's own doors registered on it. It owns one
|
|
4
|
+
// `supercode harness serve` and the terminal host, and serves these streams:
|
|
5
|
+
//
|
|
6
|
+
// stream "harness" operators only: harness.v1 lines to the one serve process,
|
|
7
|
+
// request ids rewritten per connection, notifications routed
|
|
8
|
+
// by the subscription/connection they name (never fanned out)
|
|
9
|
+
// stream "terminal" a pane attachment under observe|control
|
|
10
|
+
// stream "node" describe, launch, launches.get|list, panes.*, files.*,
|
|
11
|
+
// ports.*, attention.*, title.*
|
|
12
|
+
// stream "frontend" frontend.v2 to a hosted runtime, one client id per caller
|
|
13
|
+
// stream "port" one TCP connection to an announced port
|
|
14
|
+
// stream "watch" panes and ports as a snapshot then deltas
|
|
15
|
+
// stream "product" a target another product serves here (products.mjs), admitted first
|
|
16
|
+
// stream "exec" | "sftp" | "exec-server" operators: a pilot's tool process or file view
|
|
17
|
+
// stream "workspace" operators: a project leaving or arriving
|
|
18
|
+
//
|
|
19
|
+
// Two doors reach it. The LOCAL door is an owner-only Unix socket in the home:
|
|
20
|
+
// whoever can open it is this OS user, an operator. The REMOTE door is the
|
|
21
|
+
// Teams server: with `link`, the daemon holds one outbound WebSocket to the
|
|
22
|
+
// server, which forwards a caller's streams stamped with the machine grants it
|
|
23
|
+
// checked; the daemon re-derives that admission from its own synced copy of the
|
|
24
|
+
// grants before acting. There is no inbound port and no address book.
|
|
25
|
+
//
|
|
26
|
+
// It decides nothing on anyone's behalf. Every act is logged.
|
|
27
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
+
import { EventEmitter } from 'node:events';
|
|
29
|
+
import { readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
|
|
30
|
+
import { join, resolve, dirname } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { hostname } from 'node:os';
|
|
33
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
34
|
+
import { WebSocket } from 'ws';
|
|
35
|
+
import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
|
|
36
|
+
import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
|
|
37
|
+
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
38
|
+
import { holds, observesPanes } from '@volter/teams/admission';
|
|
39
|
+
import { MachineCore, MACHINE_VERSION } from '@volter/teams/machine-core';
|
|
40
|
+
import { realpathSync } from 'node:fs';
|
|
41
|
+
import { fingerprint, signBytes, canonical } from '@volter/teams/identity';
|
|
42
|
+
import { teamsHome, paths, readJson, writeJson, Launches } from '@volter/teams/home';
|
|
43
|
+
import { request as httpRequest } from 'node:http';
|
|
44
|
+
import { DEFAULT_RANK, normalizeRank, judge } from './attention.mjs';
|
|
45
|
+
import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from './execution.mjs';
|
|
46
|
+
|
|
47
|
+
/** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
|
|
48
|
+
const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
|
|
49
|
+
/** How long a launch's `initialInput` waits for its pane to become ready for
|
|
50
|
+
* input before it is delivered anyway. Long enough for the slowest cold
|
|
51
|
+
* start anyone has reported (a first-run agent TUI fetching its config over a
|
|
52
|
+
* slow link); past it the input goes in regardless and the launch record says
|
|
53
|
+
* so, because silently dropping a caller's first message is worse than an
|
|
54
|
+
* imperfectly-timed one. */
|
|
55
|
+
const INITIAL_INPUT_READY_DEADLINE_MS = 90_000;
|
|
56
|
+
/** How often the readiness evidence is re-read while waiting. Same cadence the
|
|
57
|
+
* terminal controller polls its own composer at: fast enough that a ready pane
|
|
58
|
+
* is not left idling, cheap enough to run for the whole deadline. */
|
|
59
|
+
const INITIAL_INPUT_POLL_MS = 250;
|
|
60
|
+
/** The pause between the two composer readings a user's turn waits on. */
|
|
61
|
+
const COMPOSER_RECHECK_MS = 400;
|
|
62
|
+
|
|
63
|
+
/** The settle floor for a pane whose kind has no composer pattern: the screen
|
|
64
|
+
* must have stopped changing for this long before the pane counts as ready. */
|
|
65
|
+
const INITIAL_INPUT_SETTLE_MS = 1_000;
|
|
66
|
+
|
|
67
|
+
export class TeamsMachine extends MachineCore {
|
|
68
|
+
/**
|
|
69
|
+
* `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
|
|
70
|
+
* server link; `configuration()` resolves the machine's current configuration from the server
|
|
71
|
+
* (custodian and machine grants). Without it the daemon serves its local door only.
|
|
72
|
+
*/
|
|
73
|
+
constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null } = {}) {
|
|
74
|
+
super({ home, floor, limits, env, link });
|
|
75
|
+
this.supercodeBin = supercode;
|
|
76
|
+
this.tmuxSocket = tmuxSocket ?? null;
|
|
77
|
+
this.launches = new Launches(this.p.launches);
|
|
78
|
+
this.host = new TmuxTerminalHost({
|
|
79
|
+
// Stable owner id so panes this daemon launched stay "owned" across restarts.
|
|
80
|
+
ownerId: `supercode-teams-${fingerprint(this.identity.publicKeyPem)}`,
|
|
81
|
+
...(tmuxSocket ? { socket: tmuxSocket } : {}),
|
|
82
|
+
});
|
|
83
|
+
// How many pane attachments a principal may hold (limits.json); every refusal is logged.
|
|
84
|
+
this.attachmentsByPrincipal = new Map(); // principal → live pane attachments
|
|
85
|
+
this.serve = null;
|
|
86
|
+
this.serveBuffer = '';
|
|
87
|
+
this.routes = new Map(); // routing key (subscription/connection id) → connection index
|
|
88
|
+
this.portsDir = join(home, 'ports');
|
|
89
|
+
// Panes whose REMOTE input is refused. It survives a daemon restart:
|
|
90
|
+
// a hand-back that outlives this process is still a hand-back.
|
|
91
|
+
this.suspended = new Set(Object.keys(readJson(this.p.suspended, { v: 1, panes: {} }).panes));
|
|
92
|
+
// Idle evidence for attention: the last capture we saw of each pane and
|
|
93
|
+
// when it last differed. In memory and nowhere else — there is no timer and
|
|
94
|
+
// no watcher; every `panes.list` / `attention.*` call is the sample. A pane
|
|
95
|
+
// first seen this call is idle 0, which is the truth about what we know, and
|
|
96
|
+
// a daemon restart honestly resets the clock rather than inventing history.
|
|
97
|
+
this.paneScreens = new Map(); // terminal session id → { hash, changedAt }
|
|
98
|
+
// Launches whose `initialInput` is still waiting for its pane to be ready
|
|
99
|
+
//. The launch reply does not wait for delivery — a cold TUI can take
|
|
100
|
+
// longer than the caller's own RPC timeout — so the wait lives here, where
|
|
101
|
+
// `stop()` can abandon it rather than leave a timer running past the daemon.
|
|
102
|
+
this.pendingInitialInputs = new Map(); // launch key → { abort, task }
|
|
103
|
+
this.registerDoor('harness', (entry, stream, accept, refuse, index) => this.#serveHarness(index, entry, stream, accept, refuse));
|
|
104
|
+
this.registerDoor('terminal', (entry, stream, accept, refuse) => this.#attachTerminal(entry, stream, accept, refuse));
|
|
105
|
+
this.registerDoor('node', (entry, stream, accept) => { accept(); return this.#serveNodeRpc(entry, stream); });
|
|
106
|
+
this.registerDoor('frontend', (entry, stream, accept, refuse) => this.#serveFrontendProxy(entry, stream, accept, refuse));
|
|
107
|
+
this.registerDoor('port', (entry, stream, accept, refuse) => this.#servePortForward(entry, stream, accept, refuse));
|
|
108
|
+
this.registerDoor('watch', (entry, stream, accept, refuse) => this.#serveWatch(entry, stream, accept, refuse));
|
|
109
|
+
for (const name of ['exec', 'sftp', 'exec-server']) this.registerDoor(name, (entry, stream, accept, refuse) => this.#serveExecution(entry, stream, accept, refuse));
|
|
110
|
+
this.registerDoor('workspace', (entry, stream, accept, refuse) => this.#serveWorkspace(entry, stream, accept, refuse));
|
|
111
|
+
this.registerDoor('mail', (entry, stream, accept, refuse) => this.#serveMail(entry, stream, accept, refuse));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- lifecycle ------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
async beforeListen() {
|
|
117
|
+
mkdirSync(this.portsDir, { recursive: true }); // the port table's announce directory exists before any pane launches
|
|
118
|
+
this.#startServe();
|
|
119
|
+
this.#startMailWatch();
|
|
120
|
+
this.jobs = new JobOwner({ home: this.home, env: this.env, log: (row) => this.log.append({ principal: null, cap: 'exec', target: `job:${row.job}`, outcome: row.outcome, detail: row.reason }) });
|
|
121
|
+
this.jobs.reapOrphans();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async beforeClose() {
|
|
125
|
+
// A launch still waiting for its pane to be ready is abandoned, not hurried:
|
|
126
|
+
// this daemon is going away, and shoving a message at a half-booted TUI on the
|
|
127
|
+
// way out is the very failure the wait exists to prevent. The launch record
|
|
128
|
+
// already says the input was never delivered.
|
|
129
|
+
for (const { abort } of this.pendingInitialInputs.values()) abort.abort();
|
|
130
|
+
await Promise.allSettled([...this.pendingInitialInputs.values()].map(({ task }) => task));
|
|
131
|
+
this.pendingInitialInputs.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async afterStop() {
|
|
135
|
+
this.jobs?.stopAll();
|
|
136
|
+
this.stoppingMailWatch = true;
|
|
137
|
+
this.mailWatch?.kill('SIGTERM');
|
|
138
|
+
this.serve?.kill('SIGTERM');
|
|
139
|
+
this.host.dispose();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
connectionClosed(index) {
|
|
143
|
+
for (const [key, owners] of this.routes) { owners.delete(index); if (owners.size === 0) this.routes.delete(key); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#startServe() {
|
|
147
|
+
const child = spawn(this.supercodeBin, ['harness', 'serve'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
|
|
148
|
+
child.stdout.setEncoding('utf8');
|
|
149
|
+
child.stdout.on('data', (chunk) => {
|
|
150
|
+
this.serveBuffer += chunk;
|
|
151
|
+
let index;
|
|
152
|
+
while ((index = this.serveBuffer.indexOf('\n')) >= 0) {
|
|
153
|
+
const line = this.serveBuffer.slice(0, index);
|
|
154
|
+
this.serveBuffer = this.serveBuffer.slice(index + 1);
|
|
155
|
+
if (line) this.#routeServeLine(line);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
child.stderr.setEncoding('utf8');
|
|
159
|
+
child.stderr.on('data', (text) => this.emit('serve-stderr', text));
|
|
160
|
+
child.on('exit', (code, signal) => {
|
|
161
|
+
this.emit('serve-exit', { code, signal });
|
|
162
|
+
for (const [, entry] of this.connections) entry.harness?.close('harness serve exited');
|
|
163
|
+
});
|
|
164
|
+
this.serve = child;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Idle notices for every session on this machine (`supercode message watch`):
|
|
168
|
+
// it settles `--notify-when-idle` subscriptions from session activity. It
|
|
169
|
+
// lives beside `harness serve` and is restarted if it exits, so a
|
|
170
|
+
// subscription made at any time has a watcher.
|
|
171
|
+
#startMailWatch() {
|
|
172
|
+
if (this.stoppingMailWatch) return;
|
|
173
|
+
const child = spawn(this.supercodeBin, ['message', 'watch'], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
|
|
174
|
+
child.stderr.setEncoding('utf8');
|
|
175
|
+
child.stderr.on('data', (text) => this.emit('mail-watch-stderr', text));
|
|
176
|
+
child.on('error', (error) => this.emit('mail-watch-stderr', error.message));
|
|
177
|
+
child.on('exit', () => {
|
|
178
|
+
if (this.mailWatch !== child || this.stoppingMailWatch) return;
|
|
179
|
+
setTimeout(() => this.#startMailWatch(), 5000).unref();
|
|
180
|
+
});
|
|
181
|
+
this.mailWatch = child;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One `harness.v1` request of the daemon's own, to its `harness serve`: the
|
|
185
|
+
* connection id INTERNAL_CONNECTION marks the answer as ours. */
|
|
186
|
+
#serveRequest(method, params, timeoutMs = 3000) {
|
|
187
|
+
this.internalRequests ??= new Map();
|
|
188
|
+
const id = (this.internalNext = (this.internalNext ?? 0) + 1);
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
if (!this.serve?.stdin?.writable) return reject(new Error('harness serve is not running'));
|
|
191
|
+
this.internalRequests.set(id, { resolve, reject });
|
|
192
|
+
setTimeout(() => { if (this.internalRequests.delete(id)) reject(new Error(`${method} timed out`)); }, timeoutMs).unref();
|
|
193
|
+
this.serve.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: joinId(INTERNAL_CONNECTION, id), method, params })}\n`);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Response and notification routing: ids carry the connection index. */
|
|
198
|
+
#routeServeLine(line) {
|
|
199
|
+
let message;
|
|
200
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
201
|
+
if (message.id !== undefined && message.id !== null) {
|
|
202
|
+
const [conn, original] = splitId(message.id);
|
|
203
|
+
const entry = this.connections.get(conn);
|
|
204
|
+
if (conn === INTERNAL_CONNECTION) {
|
|
205
|
+
const pending = this.internalRequests?.get(original);
|
|
206
|
+
if (!pending) return;
|
|
207
|
+
this.internalRequests.delete(original);
|
|
208
|
+
if (message.error) pending.reject(new Error(message.error.message ?? 'harness serve refused'));
|
|
209
|
+
else pending.resolve(message.result);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!entry?.harness) return;
|
|
213
|
+
// Learn routing keys from results so later notifications find their owner.
|
|
214
|
+
if (message.result && typeof message.result === 'object') {
|
|
215
|
+
for (const key of ['subscription', 'connection', 'runtime_id', 'subscription_id']) {
|
|
216
|
+
const value = message.result[key];
|
|
217
|
+
// Owners are a set: two operators following one thing both keep receiving (routed, never fanned out to non-owners).
|
|
218
|
+
if (typeof value === 'string') { if (!this.routes.has(value)) this.routes.set(value, new Set()); this.routes.get(value).add(conn); }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
writeLine(entry.harness, JSON.stringify({ ...message, id: original }));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Notification: find the connection that owns what it names.
|
|
225
|
+
const params = message.params ?? {};
|
|
226
|
+
for (const key of ['subscription', 'connection', 'runtime_id', 'subscription_id']) {
|
|
227
|
+
const value = params[key];
|
|
228
|
+
if (typeof value === 'string' && this.routes.has(value)) {
|
|
229
|
+
for (const owner of this.routes.get(value)) {
|
|
230
|
+
const harness = this.connections.get(owner)?.harness;
|
|
231
|
+
if (harness) writeLine(harness, line);
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
this.emit('unrouted-notification', message);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Claim one of a principal's pane attachments (`maxAttachmentsPerPrincipal`
|
|
241
|
+
* in limits.json). Returns the
|
|
242
|
+
* release; throws, refusing the attachment, when the principal is at its bound.
|
|
243
|
+
*/
|
|
244
|
+
claimAttachment(principal) {
|
|
245
|
+
const limit = this.limits.maxAttachmentsPerPrincipal;
|
|
246
|
+
const live = this.attachmentsByPrincipal.get(principal) ?? 0;
|
|
247
|
+
if (Number.isFinite(limit) && limit > 0 && live >= limit) {
|
|
248
|
+
this.log.append({ principal, cap: 'attach', target: '*', outcome: 'rate-limited', detail: `over ${limit} attachments` });
|
|
249
|
+
throw Object.assign(new Error(`this Machine allows ${limit} pane attachments per principal`), { code: 429 });
|
|
250
|
+
}
|
|
251
|
+
this.attachmentsByPrincipal.set(principal, live + 1);
|
|
252
|
+
let released = false;
|
|
253
|
+
return () => {
|
|
254
|
+
if (released) return;
|
|
255
|
+
released = true;
|
|
256
|
+
const now = (this.attachmentsByPrincipal.get(principal) ?? 1) - 1;
|
|
257
|
+
if (now <= 0) this.attachmentsByPrincipal.delete(principal); else this.attachmentsByPrincipal.set(principal, now);
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#serveHarness(index, entry, stream, accept, refuse) {
|
|
262
|
+
const { admitted, principal } = entry;
|
|
263
|
+
if (admitted.class !== 'operator') {
|
|
264
|
+
this.log.append({ principal, cap: 'harness', target: '*', outcome: 'refused' });
|
|
265
|
+
return refuse('harness.v1 is for operators');
|
|
266
|
+
}
|
|
267
|
+
if (entry.harness) return refuse('one harness stream per connection');
|
|
268
|
+
entry.harness = stream;
|
|
269
|
+
accept();
|
|
270
|
+
this.log.append({ principal, cap: 'harness', target: '*', outcome: 'opened' });
|
|
271
|
+
let buffer = '';
|
|
272
|
+
stream.on('data', (chunk) => {
|
|
273
|
+
buffer += chunk;
|
|
274
|
+
let nl;
|
|
275
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
276
|
+
const line = buffer.slice(0, nl);
|
|
277
|
+
buffer = buffer.slice(nl + 1);
|
|
278
|
+
if (!line) continue;
|
|
279
|
+
let message;
|
|
280
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
281
|
+
if (message.id !== undefined && message.id !== null) message.id = joinId(index, message.id);
|
|
282
|
+
this.serve?.stdin.write(JSON.stringify(message) + '\n');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
stream.on('close', () => { entry.harness = null; });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#serveWorkspace(entry, stream, accept, refuse) {
|
|
289
|
+
// A project leaving or arriving (execution.mjs serveWorkspace): the machine's files as
|
|
290
|
+
// this user, so operators only, like exec.
|
|
291
|
+
const { admitted, principal } = entry;
|
|
292
|
+
if (admitted.class !== 'operator') {
|
|
293
|
+
this.log.append({ principal, cap: 'workspace', target: '*', outcome: 'refused' });
|
|
294
|
+
return refuse('workspace is for operators');
|
|
295
|
+
}
|
|
296
|
+
accept();
|
|
297
|
+
const target = `root:${stream.params.path}`;
|
|
298
|
+
const onEnd = (detail) => this.log.append({ principal, cap: 'workspace', target, outcome: stream.params.op, detail });
|
|
299
|
+
// A door that fails closes its stream; it never takes the daemon down with it.
|
|
300
|
+
try { return serveWorkspace(stream, stream.params, { onEnd }); } catch (error) { onEnd(`failed: ${error.message}`); stream.close(error.message); return; }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- mail ----------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
#serveMail(entry, stream, accept, refuse) {
|
|
306
|
+
// One message into a session on this machine, delivered by `supercode message
|
|
307
|
+
// remote-deliver` through the same doors a local send uses. The receiver is the
|
|
308
|
+
// target: `mail:*` reaches every session here, `mail:<address>` one of them; a
|
|
309
|
+
// receiver named rather than addressed needs `mail:*`.
|
|
310
|
+
const { admitted, principal } = entry;
|
|
311
|
+
const request = stream.params?.request;
|
|
312
|
+
const to = typeof request?.to === 'string' ? request.to : '*';
|
|
313
|
+
const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
|
|
314
|
+
if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file or list request');
|
|
315
|
+
if (!holds(admitted, 'mail', target)) {
|
|
316
|
+
this.log.append({ principal, cap: 'mail', target, outcome: 'refused' });
|
|
317
|
+
return refuse(`no mail grant for ${target}`);
|
|
318
|
+
}
|
|
319
|
+
accept();
|
|
320
|
+
const child = spawn(this.supercodeBin, ['message', 'remote-deliver'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
|
|
321
|
+
let out = '';
|
|
322
|
+
let err = '';
|
|
323
|
+
child.stdout.on('data', (chunk) => { out += chunk; });
|
|
324
|
+
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
325
|
+
child.on('error', (error) => { err += error.message; });
|
|
326
|
+
child.on('close', (code) => {
|
|
327
|
+
this.log.append({ principal, cap: 'mail', target, outcome: code === 0 ? request.op : 'failed', detail: err.trim().slice(0, 200) || undefined });
|
|
328
|
+
if (stream.closed) return;
|
|
329
|
+
stream.write(out.trim() || JSON.stringify({ outcome: 'failed', detail: err.trim() || `remote-deliver exited ${code}` }));
|
|
330
|
+
stream.close('delivered');
|
|
331
|
+
});
|
|
332
|
+
// Who asked travels with the request, so the receiving session's envelope can say so.
|
|
333
|
+
child.stdin.end(JSON.stringify({ ...request, via_principal: principal ?? null }));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- terminal ------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
async #attachTerminal(entry, stream, accept, refuse) {
|
|
339
|
+
const { admitted, principal } = entry;
|
|
340
|
+
const { pane, mode = 'observe', cols = 120, rows = 40 } = stream.params;
|
|
341
|
+
const cap = mode === 'control' ? 'control' : 'observe';
|
|
342
|
+
if (!holds(admitted, cap, `pane:${pane}`)) {
|
|
343
|
+
this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused' });
|
|
344
|
+
return refuse(`no ${cap} on pane:${pane}`);
|
|
345
|
+
}
|
|
346
|
+
const session = await this.#paneByContext(pane);
|
|
347
|
+
if (!session) return refuse(`no pane with context ${pane}`);
|
|
348
|
+
let grant;
|
|
349
|
+
let release;
|
|
350
|
+
try {
|
|
351
|
+
release = this.claimAttachment(principal);
|
|
352
|
+
grant = this.host.issueAttachment(session.id, { mode });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
release?.();
|
|
355
|
+
this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused', detail: error.message });
|
|
356
|
+
return refuse(error.message);
|
|
357
|
+
}
|
|
358
|
+
accept();
|
|
359
|
+
const socketLike = this.gateInput(new ChannelWebSocket(stream), pane);
|
|
360
|
+
try {
|
|
361
|
+
const attachment = await this.host.attachSocket(grant.id, socketLike, { cols, rows });
|
|
362
|
+
this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'attached' });
|
|
363
|
+
stream.on('close', () => { release(); attachment.dispose(); });
|
|
364
|
+
} catch (error) {
|
|
365
|
+
release();
|
|
366
|
+
this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'failed', detail: error.message });
|
|
367
|
+
stream.close(error.message);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The environment every launched pane carries: the porthole hook preloaded into
|
|
373
|
+
* each node process the pane runs, the announce directory it writes into, and the pane's
|
|
374
|
+
* own contextKey, so an announced port names the pane that owns it.
|
|
375
|
+
*/
|
|
376
|
+
#paneEnv(contextKey, requested) {
|
|
377
|
+
const env = { ...(requested ?? {}) };
|
|
378
|
+
const inherited = env.NODE_OPTIONS ?? this.env.NODE_OPTIONS ?? '';
|
|
379
|
+
env.NODE_OPTIONS = `${inherited} --require ${PORTHOLE_HOOK}`.trim();
|
|
380
|
+
env.SUPERCODE_TEAMS_PORTS_DIR = this.portsDir;
|
|
381
|
+
env.SUPERCODE_TEAMS_PANE = contextKey;
|
|
382
|
+
return env;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async #paneByContext(contextKey) {
|
|
386
|
+
const sessions = await this.host.listSessions();
|
|
387
|
+
return sessions.find((s) => s.contextKey === contextKey) ?? null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- attention: the evidence, gathered here ---------------------------
|
|
391
|
+
|
|
392
|
+
/** attention.json, with the rank table filled in from the defaults the first
|
|
393
|
+
* time anyone looks. It is written back so the org has a real file to edit —
|
|
394
|
+
* a table that only exists as a default is a table nobody knows they may
|
|
395
|
+
* change. Re-read on every call: a precedence edit lands on the next call
|
|
396
|
+
* with nothing restarted. */
|
|
397
|
+
attentionDoc() {
|
|
398
|
+
const doc = readJson(this.p.attention, { v: 1, requests: {} });
|
|
399
|
+
const before = JSON.stringify(doc.rank ?? null);
|
|
400
|
+
doc.rank = normalizeRank(doc.rank ?? DEFAULT_RANK);
|
|
401
|
+
if (JSON.stringify(doc.rank) !== before) writeJson(this.p.attention, doc);
|
|
402
|
+
return doc;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Seconds since this pane's screen last CHANGED, by hashing the capture and
|
|
406
|
+
* remembering when the hash last moved. The transcript rung: a foreground
|
|
407
|
+
* command whose screen has plateaued is what separates `stuck` from
|
|
408
|
+
* `working`, and nothing cheaper can tell them apart. */
|
|
409
|
+
#idleSeconds(sessionId, capture, now) {
|
|
410
|
+
return Math.round(this.#screenUnchangedMs(sessionId, capture, now) / 1000);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The same plateau in milliseconds, which is the unit a launch's readiness
|
|
414
|
+
* wait needs (its settle floor is a second, not a count of them). One clock,
|
|
415
|
+
* two units: `attention` rounds it to seconds, and a pane sampled by either
|
|
416
|
+
* caller feeds the other's reading rather than keeping a private history. */
|
|
417
|
+
#screenUnchangedMs(sessionId, capture, now) {
|
|
418
|
+
const hash = createHash('sha256').update(capture ?? '').digest('hex');
|
|
419
|
+
const seen = this.paneScreens.get(sessionId);
|
|
420
|
+
if (!seen || seen.hash !== hash) {
|
|
421
|
+
this.paneScreens.set(sessionId, { hash, changedAt: now });
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
return Math.max(0, now - seen.changedAt);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The four attention inputs, for every pane this daemon can see, plus any pane
|
|
428
|
+
* with a standing request whose pane is gone (liveness `ended` — a request
|
|
429
|
+
* outranks `ended`, so a declaration raised at the end of a run survives the
|
|
430
|
+
* pane that raised it).
|
|
431
|
+
*
|
|
432
|
+
* Where each input comes from, and nowhere else:
|
|
433
|
+
* liveness for a pane running a harness session (Claude Code, Codex),
|
|
434
|
+
* the harness's own lifecycle through `harness.v1.sessions.
|
|
435
|
+
* activity_under`: working is `running`, idle is `waiting`,
|
|
436
|
+
* needing input is `waiting` with the `asking` signal
|
|
437
|
+
* (source `cooperative`). Otherwise the terminal host's
|
|
438
|
+
* `activeCommand` — tmux's own
|
|
439
|
+
* `pane_current_command`. The pane's shell in the foreground
|
|
440
|
+
* means the shell is at its prompt (`waiting`); any other
|
|
441
|
+
* command means work is running (`running`); an empty or
|
|
442
|
+
* absent value means tmux named nothing, which is `unknown`
|
|
443
|
+
* and never quietly reported as running.
|
|
444
|
+
* signal `classifyAttentionSignal` from the terminal substrate, over
|
|
445
|
+
* this pane's capture. The classifier is NOT re-derived here:
|
|
446
|
+
* one set of patterns, one precision, every surface.
|
|
447
|
+
* idleSeconds the capture-hash plateau above.
|
|
448
|
+
* requested attention.json — what `attention request` recorded, with the
|
|
449
|
+
* urgency and reason the caller gave, carried through intact.
|
|
450
|
+
*/
|
|
451
|
+
async attentionEvidence(now = Date.now()) {
|
|
452
|
+
const doc = this.attentionDoc();
|
|
453
|
+
const sessions = await this.host.listSessions();
|
|
454
|
+
const harness = new Map();
|
|
455
|
+
const pids = sessions.map((session) => session.rootPid).filter(Number.isInteger);
|
|
456
|
+
if (pids.length) {
|
|
457
|
+
try {
|
|
458
|
+
const answer = await this.#serveRequest('harness.v1.sessions.activity_under', { pids });
|
|
459
|
+
for (const { pid, activity } of answer?.activities ?? []) harness.set(pid, activity);
|
|
460
|
+
} catch { /* no harness evidence this tick: the process tree still answers */ }
|
|
461
|
+
}
|
|
462
|
+
const live = new Set();
|
|
463
|
+
const rows = sessions.map((session) => {
|
|
464
|
+
if (session.contextKey) live.add(session.contextKey);
|
|
465
|
+
const capture = this.#safeCapture(session.id);
|
|
466
|
+
const requested = session.contextKey ? doc.requests[session.contextKey] ?? null : null;
|
|
467
|
+
const own = harnessLiveness(harness.get(session.rootPid));
|
|
468
|
+
const evidence = {
|
|
469
|
+
liveness: own?.liveness ?? livenessOf(session.activeCommand),
|
|
470
|
+
signal: classifyAttentionSignal(capture) ?? own?.signal ?? null,
|
|
471
|
+
idleSeconds: this.#idleSeconds(session.id, capture, now),
|
|
472
|
+
requested,
|
|
473
|
+
...(own ? { livenessSource: 'cooperative' } : {}),
|
|
474
|
+
};
|
|
475
|
+
return { session, ...judged(evidence, doc.rank) };
|
|
476
|
+
});
|
|
477
|
+
for (const [pane, request] of Object.entries(doc.requests)) {
|
|
478
|
+
if (live.has(pane)) continue;
|
|
479
|
+
const evidence = { liveness: 'ended', signal: null, idleSeconds: null, requested: request };
|
|
480
|
+
rows.push({ session: { contextKey: pane, label: null, id: null }, ...judged(evidence, doc.rank) });
|
|
481
|
+
}
|
|
482
|
+
return rows;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** One capture, never fatal: a pane that died between the listing and the read
|
|
486
|
+
* is evidence of nothing, not a failed door. */
|
|
487
|
+
#safeCapture(sessionId) {
|
|
488
|
+
if (!sessionId) return '';
|
|
489
|
+
try {
|
|
490
|
+
return this.host.capture(sessionId, { lines: 120 });
|
|
491
|
+
} catch {
|
|
492
|
+
return '';
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---- launch: the first input ------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Wait for the pane to be ready for input, then deliver the launch's first
|
|
500
|
+
* message and record what happened. Started by `launch` and deliberately NOT
|
|
501
|
+
* awaited by it: the reply says the pane exists, and this says the message
|
|
502
|
+
* went in.
|
|
503
|
+
*
|
|
504
|
+
* Never rejects. The only caller is a fire-and-forget from the request path,
|
|
505
|
+
* where an unhandled rejection would take the daemon down over one pane.
|
|
506
|
+
*/
|
|
507
|
+
#deliverInitialInput({ key, contextKey, session, kind, input, act }) {
|
|
508
|
+
const abort = new AbortController();
|
|
509
|
+
const task = (async () => {
|
|
510
|
+
const readyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
|
|
511
|
+
if (readyBy === 'aborted') return;
|
|
512
|
+
if (readyBy === 'pane-gone') {
|
|
513
|
+
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
|
|
514
|
+
act('launch', `pane:${contextKey}`, 'failed', 'first input undelivered: the pane ended before it was ready');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// `single` stays `single`: one paste, one submit. The repeated Enter of
|
|
518
|
+
// `retry` exists for a composer that swallowed the first one, and pressing
|
|
519
|
+
// Enter again at an agent that already took the message sends an empty
|
|
520
|
+
// turn. The fix for a lost submit is waiting until there is something to
|
|
521
|
+
// submit into, which is what happened above.
|
|
522
|
+
try {
|
|
523
|
+
await this.host.sendInput(session.id, submission(input), { submitMode: 'single' });
|
|
524
|
+
} catch (error) {
|
|
525
|
+
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
|
|
526
|
+
act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${error.code ?? error.message}`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// `inputDeliveredLate` is the deadline's own confession: the pane never
|
|
530
|
+
// showed us it was ready, and the message went in on a screen nobody
|
|
531
|
+
// vouched for. A launch that reads `ready` without it was given its
|
|
532
|
+
// message by an input box that existed.
|
|
533
|
+
const late = readyBy === 'deadline';
|
|
534
|
+
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
|
|
535
|
+
act('launch', `pane:${contextKey}`, 'ok', `first input delivered (${readyBy})`);
|
|
536
|
+
})().catch((error) => {
|
|
537
|
+
// Nothing above is expected to throw; if something does, the launch record
|
|
538
|
+
// is where it is answerable, not a crashed daemon.
|
|
539
|
+
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
|
|
540
|
+
}).finally(() => {
|
|
541
|
+
if (this.pendingInitialInputs.get(key)?.task === task) this.pendingInitialInputs.delete(key);
|
|
542
|
+
});
|
|
543
|
+
this.pendingInitialInputs.set(key, { abort, task });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The program a pane runs, as the terminal package names kinds: what it was
|
|
547
|
+
* launched as, else what its foreground command is. */
|
|
548
|
+
#paneKind(contextKey, session) {
|
|
549
|
+
const launched = this.launches.all().find((row) => row.pane === contextKey && row.kind)?.kind;
|
|
550
|
+
const named = String(launched ?? '').trim().toLowerCase();
|
|
551
|
+
if (named === 'claude' || named === 'claude-code') return 'claude-code';
|
|
552
|
+
if (named) return named;
|
|
553
|
+
const command = String(session.activeCommand ?? '').toLowerCase().replace(/\.exe$/u, '');
|
|
554
|
+
return command === 'claude' ? 'claude-code' : command;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Is this pane ready to be given text, and on what evidence? Polls until it
|
|
559
|
+
* is, the pane is gone, the deadline passes or the daemon stops. Returns the
|
|
560
|
+
* rung that decided it:
|
|
561
|
+
*
|
|
562
|
+
* composer the kind's idle composer is on screen — the program has
|
|
563
|
+
* finished booting and is waiting for a message. The patterns
|
|
564
|
+
* are the terminal package's own (`composerReadyForInput`), the
|
|
565
|
+
* same ones its controller waits on, never a second copy here.
|
|
566
|
+
* settled the kind has no composer pattern, so the terminal host's own
|
|
567
|
+
* settle heuristic decides: the screen has not changed for
|
|
568
|
+
* `INITIAL_INPUT_SETTLE_MS` and the program has printed
|
|
569
|
+
* something — or, on a pane with nothing to print, its shell is
|
|
570
|
+
* the foreground command (`awaiting_me`'s evidence: a prompt,
|
|
571
|
+
* turn over).
|
|
572
|
+
* deadline neither, for `INITIAL_INPUT_READY_DEADLINE_MS`. The caller
|
|
573
|
+
* delivers anyway and says so.
|
|
574
|
+
*/
|
|
575
|
+
async #awaitInputReady(session, contextKey, kind, signal) {
|
|
576
|
+
const composerKind = typeof kind === 'string' ? kind.trim().toLowerCase() : '';
|
|
577
|
+
const until = Date.now() + INITIAL_INPUT_READY_DEADLINE_MS;
|
|
578
|
+
while (!signal.aborted && Date.now() < until) {
|
|
579
|
+
const capture = this.#safeCapture(session.id);
|
|
580
|
+
const unchangedMs = this.#screenUnchangedMs(session.id, capture, Date.now());
|
|
581
|
+
const blank = capture.trim() === '';
|
|
582
|
+
// A blank screen is the one reading that can mean the pane is GONE rather
|
|
583
|
+
// than merely quiet, so that is when its existence is worth a tmux call —
|
|
584
|
+
// and the same lookup carries the foreground command, which is the only
|
|
585
|
+
// evidence a shell with nothing to print is nonetheless at its prompt.
|
|
586
|
+
const live = blank ? await this.#paneByContext(contextKey) : null;
|
|
587
|
+
if (blank && !live) return 'pane-gone';
|
|
588
|
+
// `null` from the terminal package means it has no pattern for this kind —
|
|
589
|
+
// never "not yet", or an unrecognized kind would wait out the whole
|
|
590
|
+
// deadline on a screen nobody is reading.
|
|
591
|
+
const composer = composerReadyForInput(composerKind, capture);
|
|
592
|
+
if (composer === true) return 'composer';
|
|
593
|
+
if (composer === null && unchangedMs >= INITIAL_INPUT_SETTLE_MS && (!blank || livenessOf(live.activeCommand) === 'waiting')) return 'settled';
|
|
594
|
+
await new Promise((resolve) => setTimeout(resolve, INITIAL_INPUT_POLL_MS));
|
|
595
|
+
}
|
|
596
|
+
if (signal.aborted) return 'aborted';
|
|
597
|
+
return (await this.#paneByContext(contextKey)) ? 'deadline' : 'pane-gone';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* A host-key-signed statement a service records as its attestation input: which
|
|
602
|
+
* key this machine signs with, the run-spec hash and egress mode it was started
|
|
603
|
+
* under, and the server it is linked to.
|
|
604
|
+
*/
|
|
605
|
+
describe() {
|
|
606
|
+
const body = {
|
|
607
|
+
v: MACHINE_VERSION,
|
|
608
|
+
id: this.id,
|
|
609
|
+
hostname: hostname(),
|
|
610
|
+
socket: this.socketPath,
|
|
611
|
+
server: this.link?.server ?? null,
|
|
612
|
+
machineId: this.link?.machineId ?? null,
|
|
613
|
+
node: { pid: process.pid, version: MACHINE_VERSION, protocol: NODE_PROTOCOL, floor: this.floor },
|
|
614
|
+
egress: this.env.SUPERCODE_TEAMS_EGRESS ?? 'unrestricted',
|
|
615
|
+
execution: executionCapabilities(this.env),
|
|
616
|
+
runSpec: this.env.SUPERCODE_TEAMS_RUN_SPEC_SHA256 ?? null,
|
|
617
|
+
at: Date.now(),
|
|
618
|
+
};
|
|
619
|
+
return { ...body, signature: signBytes(this.identity.privateKeyPem, Buffer.from(canonical(body))) };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
listPorts() { return listAnnouncedPorts(this.portsDir); }
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Rename the tmux window behind an owned pane. The terminal host exposes
|
|
626
|
+
* no rename, so this is `tmux` on the host's own socket; automatic-rename is
|
|
627
|
+
* turned off first, or the shell would take the name straight back. Returns
|
|
628
|
+
* the tmux label renamed, or null if the pane is gone or not ours.
|
|
629
|
+
*/
|
|
630
|
+
async #renameWindow(contextKey, title) {
|
|
631
|
+
const session = await this.#paneByContext(contextKey);
|
|
632
|
+
if (!session || !session.owned) return null;
|
|
633
|
+
const socketArgs = this.tmuxSocket ? ['-L', this.tmuxSocket] : [];
|
|
634
|
+
const target = `${session.label}:0`;
|
|
635
|
+
const run = (args) => spawnSync('tmux', [...socketArgs, ...args], { timeout: 5_000, encoding: 'utf8' });
|
|
636
|
+
if (title == null) {
|
|
637
|
+
run(['set-window-option', '-t', target, 'automatic-rename', 'on']);
|
|
638
|
+
return session.label;
|
|
639
|
+
}
|
|
640
|
+
run(['set-window-option', '-t', target, 'automatic-rename', 'off']);
|
|
641
|
+
const result = run(['rename-window', '-t', target, String(title)]);
|
|
642
|
+
if (result.status !== 0) throw new Error(`tmux rename-window failed: ${(result.stderr ?? '').trim() || result.error?.message || 'unknown'}`);
|
|
643
|
+
return session.label;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Wrap a ws-shaped socket so that while its pane is suspended the
|
|
648
|
+
* peer's `input` frames are dropped with one notice back, and everything
|
|
649
|
+
* else — output, resize, hello, exit — passes through untouched.
|
|
650
|
+
*/
|
|
651
|
+
gateInput(socketLike, pane) {
|
|
652
|
+
return new SuspendGate(socketLike, {
|
|
653
|
+
suspended: () => this.suspended.has(pane),
|
|
654
|
+
notice: `pane ${pane} is suspended: remote input is refused until resume`,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
paneByContext(contextKey) { return this.#paneByContext(contextKey); }
|
|
659
|
+
|
|
660
|
+
// ---- the node stream: request/reply doors -----------------------------------
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* frontend.v2 over the channel, with the runtime's receipt resolved here on
|
|
664
|
+
* the machine. The caller's FrontendClient
|
|
665
|
+
* talks to a fetch that rides this stream; the daemon resolves the runtime's
|
|
666
|
+
* live receipt, mints THIS principal its own credential through the loopback
|
|
667
|
+
* mint door (so the lease coordinator sees one RuntimeClientId per remote
|
|
668
|
+
* principal), and forwards each request to the runtime's own server.
|
|
669
|
+
*
|
|
670
|
+
* → { id, method, path, headers, body(base64) }
|
|
671
|
+
* ← { id, status, headers } then { id, chunk(base64) }* then { id, end }
|
|
672
|
+
* → { id, abort }
|
|
673
|
+
*/
|
|
674
|
+
async #serveFrontendProxy(entry, stream, accept, refuse) {
|
|
675
|
+
const { admitted, principal } = entry;
|
|
676
|
+
const runtimeId = String(stream.params.runtime ?? '');
|
|
677
|
+
const wantsInteract = admitted.class === 'operator' || holds(admitted, 'interact', `runtime:${runtimeId}`);
|
|
678
|
+
if (!wantsInteract && !holds(admitted, 'observe', `runtime:${runtimeId}`)) {
|
|
679
|
+
this.log.append({ principal, cap: 'observe', target: `runtime:${runtimeId}`, outcome: 'refused' });
|
|
680
|
+
return refuse(`no observe on runtime:${runtimeId}`);
|
|
681
|
+
}
|
|
682
|
+
const receipt = findLiveReceipt(runtimeId, this.env);
|
|
683
|
+
if (!receipt) return refuse(`no live runtime ${runtimeId} on this Machine`);
|
|
684
|
+
// One RuntimeClientId per remote principal. Two runtime servers exist in core: the SDK-hosted
|
|
685
|
+
// one offers the loopback mint door (a credential BOUND to a client id); the frontend server
|
|
686
|
+
// for ACP-backed runtimes does not, and instead takes the client id from
|
|
687
|
+
// `x-supercode-client-id` and clamps rights from `x-supercode-permissions` when the credential
|
|
688
|
+
// is unbound (server.rs coordinated_http_client). So: mint when the door exists, otherwise
|
|
689
|
+
// forward the receipt's own token with this principal's id and a clamped permission header.
|
|
690
|
+
const clientId = `teams-${principal.slice(0, 12)}-${randomBytes(4).toString('hex')}`;
|
|
691
|
+
let credential = { clientId, token: receipt.token, minted: false };
|
|
692
|
+
try {
|
|
693
|
+
const token = await mintFrontendCredential(receipt, { clientId, grant: wantsInteract ? 'interactive' : 'observer' });
|
|
694
|
+
credential = { clientId, token, minted: true };
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (!/^mint 404/.test(error.message)) {
|
|
697
|
+
this.log.append({ principal, cap: 'observe', target: `runtime:${runtimeId}`, outcome: 'failed', detail: error.message });
|
|
698
|
+
return refuse(`could not mint a credential: ${error.message}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
accept();
|
|
702
|
+
this.log.append({ principal, cap: wantsInteract ? 'interact' : 'observe', target: `runtime:${runtimeId}`, outcome: 'attached', detail: credential.minted ? 'channel, minted credential' : 'channel, receipt token + client id' });
|
|
703
|
+
const inflight = new Map(); // id → ClientRequest
|
|
704
|
+
stream.on('data', (chunk) => {
|
|
705
|
+
let message;
|
|
706
|
+
try { message = JSON.parse(chunk); } catch { return; }
|
|
707
|
+
if (message.abort) { inflight.get(message.id)?.destroy(); inflight.delete(message.id); return; }
|
|
708
|
+
// Only a path on the runtime's own server: an absolute URL or a `//host` prefix would turn
|
|
709
|
+
// the daemon into a proxy carrying the runtime's bearer to an arbitrary origin.
|
|
710
|
+
if (typeof message.path !== 'string' || !/^\/(?!\/)/.test(message.path)) {
|
|
711
|
+
if (!stream.closed) stream.write(JSON.stringify({ id: message.id, status: 400, headers: {}, error: 'path must be a root-relative path on the runtime', end: true }));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const target = new URL(message.path, receipt.base_url);
|
|
715
|
+
const headers = { ...(message.headers ?? {}) };
|
|
716
|
+
// The principal's own identity replaces whatever the caller sent: the minted credential
|
|
717
|
+
// and its clientId are what the runtime's lease coordinator must see.
|
|
718
|
+
headers.authorization = `Bearer ${credential.token}`;
|
|
719
|
+
headers['x-supercode-client-id'] = credential.clientId;
|
|
720
|
+
// Clamp to the grant: observers get observe; interact holders get the three runtime rungs the
|
|
721
|
+
// grant carries (approve travels with interact); an operator keeps the token's own authorization.
|
|
722
|
+
if (!wantsInteract) headers['x-supercode-permissions'] = 'observe';
|
|
723
|
+
else if (admitted.class !== 'operator') headers['x-supercode-permissions'] = 'observe,interact,approve';
|
|
724
|
+
headers.host = target.host;
|
|
725
|
+
delete headers['content-length'];
|
|
726
|
+
const body = message.body ? Buffer.from(message.body, 'base64') : null;
|
|
727
|
+
if (body) headers['content-length'] = String(body.length);
|
|
728
|
+
const upstream = httpRequest(target, { method: message.method ?? 'GET', headers }, (res) => {
|
|
729
|
+
if (stream.closed) { res.destroy(); return; }
|
|
730
|
+
stream.write(JSON.stringify({ id: message.id, status: res.statusCode, headers: res.headers }));
|
|
731
|
+
res.on('data', (piece) => { if (!stream.closed) stream.write(JSON.stringify({ id: message.id, chunk: Buffer.from(piece).toString('base64') })); });
|
|
732
|
+
res.on('end', () => { inflight.delete(message.id); if (!stream.closed) stream.write(JSON.stringify({ id: message.id, end: true })); });
|
|
733
|
+
});
|
|
734
|
+
upstream.on('error', (error) => { inflight.delete(message.id); if (!stream.closed) stream.write(JSON.stringify({ id: message.id, status: 502, headers: {}, error: error.message, end: true })); });
|
|
735
|
+
inflight.set(message.id, upstream);
|
|
736
|
+
upstream.end(body ?? undefined);
|
|
737
|
+
});
|
|
738
|
+
stream.on('close', () => { for (const req of inflight.values()) req.destroy(); inflight.clear(); });
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* A Machine port forwarded to a keyed caller (`port:<pane>/<n>`). One stream is
|
|
743
|
+
* one TCP connection to 127.0.0.1:<port> on this Machine; bytes ride base64. A participant
|
|
744
|
+
* needs `ports` on `port:<pane>/<n>` and the port must be one that pane announced (the live
|
|
745
|
+
* port table); an operator may reach any port.
|
|
746
|
+
*/
|
|
747
|
+
/**
|
|
748
|
+
* A pilot's tool process (`exec`) or its view of this machine's files (`sftp`), each one job
|
|
749
|
+
* the daemon owns (execution.mjs). Arbitrary code as this user, so operators only: the owner
|
|
750
|
+
* through the local door, or an `operate` grant through the server.
|
|
751
|
+
*/
|
|
752
|
+
#serveExecution(entry, stream, accept, refuse) {
|
|
753
|
+
const { admitted, principal } = entry;
|
|
754
|
+
if (admitted.class !== 'operator') {
|
|
755
|
+
this.log.append({ principal, cap: stream.name, target: '*', outcome: 'refused' });
|
|
756
|
+
return refuse(`${stream.name} is for operators`);
|
|
757
|
+
}
|
|
758
|
+
const endpoint = streamProgram(stream.name, stream.params ?? {}, this.env);
|
|
759
|
+
const served = stream.name === 'exec-server' ? Boolean(endpoint) : executionCapabilities(this.env)[stream.name];
|
|
760
|
+
if (!endpoint || !served) return refuse(stream.name === 'exec-server' ? `this machine serves no exec-server for ${stream.params?.harness}` : `this machine does not serve ${stream.name}`);
|
|
761
|
+
accept();
|
|
762
|
+
try {
|
|
763
|
+
// an exec-server runs its processes in the directories its client names; it starts in the daemon's home
|
|
764
|
+
const job = this.jobs.run(stream, { kind: stream.name, ...endpoint, cwd: this.home });
|
|
765
|
+
this.log.append({ principal, cap: stream.name, target: `job:${job.id}`, outcome: 'started', detail: job.cgroup ? 'cgroup' : 'process-group' });
|
|
766
|
+
} catch (error) {
|
|
767
|
+
this.log.append({ principal, cap: stream.name, target: '*', outcome: 'failed', detail: error.message });
|
|
768
|
+
stream.close(error.message);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async #servePortForward(entry, stream, accept, refuse) {
|
|
773
|
+
const { admitted, principal } = entry;
|
|
774
|
+
const port = Number(stream.params.port);
|
|
775
|
+
const pane = stream.params.pane ? String(stream.params.pane) : null;
|
|
776
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return refuse('port must be 1–65535');
|
|
777
|
+
const target = `port:${pane ?? '*'}/${port}`;
|
|
778
|
+
if (admitted.class !== 'operator') {
|
|
779
|
+
if (!holds(admitted, 'ports', target) && !(pane && holds(admitted, 'ports', `pane:${pane}`))) {
|
|
780
|
+
this.log.append({ principal, cap: 'ports', target, outcome: 'refused' });
|
|
781
|
+
return refuse(`no ports on ${target}`);
|
|
782
|
+
}
|
|
783
|
+
const announced = this.listPorts().some((p) => p.port === port && (pane == null || p.pane === pane));
|
|
784
|
+
if (!announced) { this.log.append({ principal, cap: 'ports', target, outcome: 'refused', detail: 'not announced by that pane' }); return refuse(`port ${port} is not announced by pane ${pane}`); }
|
|
785
|
+
}
|
|
786
|
+
const { connect } = await import('node:net');
|
|
787
|
+
const socket = connect({ host: '127.0.0.1', port });
|
|
788
|
+
socket.once('connect', () => {
|
|
789
|
+
accept();
|
|
790
|
+
this.log.append({ principal, cap: 'ports', target, outcome: 'forwarded' });
|
|
791
|
+
socket.on('data', (bytes) => { if (!stream.closed) stream.write(bytes.toString('base64')); });
|
|
792
|
+
socket.on('close', () => stream.close('port closed'));
|
|
793
|
+
stream.on('data', (chunk) => { if (!socket.destroyed) socket.write(Buffer.from(chunk, 'base64')); });
|
|
794
|
+
stream.on('close', () => socket.destroy());
|
|
795
|
+
});
|
|
796
|
+
socket.once('error', (error) => { this.log.append({ principal, cap: 'ports', target, outcome: 'failed', detail: error.code ?? error.message }); refuse(`port ${port}: ${error.code ?? error.message}`); });
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* The Machine's live state as a stream ("transition stream"; the local state a
|
|
801
|
+
* caller would otherwise poll): one `snapshot` on open, then `delta`s — panes appearing,
|
|
802
|
+
* changing or vanishing (with attention label, holder, title, suspended), ports announced or
|
|
803
|
+
* gone. Computed from what the daemon already has (listSessions, the label, the port table);
|
|
804
|
+
* nothing new is decided. Participants see only the panes their grants cover; operators all.
|
|
805
|
+
*/
|
|
806
|
+
async #serveWatch(entry, stream, accept, refuse) {
|
|
807
|
+
const { admitted, principal } = entry;
|
|
808
|
+
if (admitted.class !== 'operator' && !holds(admitted, 'view', '*') && !observesPanes(admitted)) {
|
|
809
|
+
this.log.append({ principal, cap: 'view', target: '*', outcome: 'refused', detail: 'watch' });
|
|
810
|
+
return refuse('no view on this Machine');
|
|
811
|
+
}
|
|
812
|
+
accept();
|
|
813
|
+
this.log.append({ principal, cap: 'view', target: '*', outcome: 'watching' });
|
|
814
|
+
const visible = (pane) => admitted.class === 'operator' || holds(admitted, 'view', '*') || holds(admitted, 'observe', `pane:${pane}`);
|
|
815
|
+
const intervalMs = Math.max(500, Math.min(Number(stream.params.intervalMs) || 1000, 10_000));
|
|
816
|
+
let last = new Map();
|
|
817
|
+
let lastPorts = '';
|
|
818
|
+
const tick = async () => {
|
|
819
|
+
if (stream.closed) return;
|
|
820
|
+
try {
|
|
821
|
+
const rows = (await this.#dispatch(entry, 'panes.list', {})).filter((r) => r.pane && visible(r.pane));
|
|
822
|
+
const now = new Map(rows.map((r) => [r.pane, r]));
|
|
823
|
+
const ports = this.listPorts().filter((p) => (p.pane ? visible(p.pane) : admitted.class === 'operator' || holds(admitted, 'view', '*') || holds(admitted, 'ports', `port:*/${p.port}`)));
|
|
824
|
+
const portsKey = JSON.stringify(ports);
|
|
825
|
+
if (last.size === 0 && lastPorts === '') {
|
|
826
|
+
stream.write(JSON.stringify({ type: 'snapshot', panes: rows, ports, at: Date.now() }));
|
|
827
|
+
} else {
|
|
828
|
+
const deltas = [];
|
|
829
|
+
for (const [pane, row] of now) {
|
|
830
|
+
const before = last.get(pane);
|
|
831
|
+
if (!before) deltas.push({ kind: 'pane-added', pane, row });
|
|
832
|
+
else if (JSON.stringify(before) !== JSON.stringify(row)) deltas.push({ kind: 'pane-changed', pane, row, was: { attention: before.attention, title: before.title, suspended: before.suspended } });
|
|
833
|
+
}
|
|
834
|
+
for (const pane of last.keys()) if (!now.has(pane)) deltas.push({ kind: 'pane-gone', pane });
|
|
835
|
+
if (portsKey !== lastPorts) deltas.push({ kind: 'ports', ports });
|
|
836
|
+
if (deltas.length) stream.write(JSON.stringify({ type: 'delta', deltas, at: Date.now() }));
|
|
837
|
+
}
|
|
838
|
+
last = now; lastPorts = portsKey;
|
|
839
|
+
} catch (error) {
|
|
840
|
+
if (!stream.closed) stream.write(JSON.stringify({ type: 'error', message: error.message }));
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
await tick();
|
|
844
|
+
const timer = setInterval(tick, intervalMs);
|
|
845
|
+
stream.on('close', () => clearInterval(timer));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
#serveNodeRpc(entry, stream) {
|
|
849
|
+
let buffer = '';
|
|
850
|
+
stream.on('data', async (chunk) => {
|
|
851
|
+
buffer += chunk;
|
|
852
|
+
let nl;
|
|
853
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
854
|
+
const line = buffer.slice(0, nl);
|
|
855
|
+
buffer = buffer.slice(nl + 1);
|
|
856
|
+
if (!line) continue;
|
|
857
|
+
let request;
|
|
858
|
+
try { request = JSON.parse(line); } catch { continue; }
|
|
859
|
+
const startedAt = Date.now();
|
|
860
|
+
// A reply to a stream the caller closed mid-door is dropped, never thrown: a throw here
|
|
861
|
+
// would reject the data listener and take the daemon down with it.
|
|
862
|
+
const reply = (envelope) => { if (stream.closed) return; try { stream.write(JSON.stringify(envelope) + '\n'); } catch { /* peer gone */ } };
|
|
863
|
+
try {
|
|
864
|
+
// Every door answers or fails within a bound; a hung substrate call becomes an error, never silence.
|
|
865
|
+
// The caller may shorten the bound, never lengthen it past the daemon's own 30 s.
|
|
866
|
+
const bound = Math.min(Number(request.params?.timeoutMs) || 30_000, 30_000);
|
|
867
|
+
const result = await withTimeout(this.#dispatch(entry, request.method, request.params ?? {}), bound, request.method);
|
|
868
|
+
reply({ id: request.id, result });
|
|
869
|
+
this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: 'ok' });
|
|
870
|
+
} catch (error) {
|
|
871
|
+
this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: error.code ?? 'error', detail: error.message });
|
|
872
|
+
reply({ id: request.id, error: { code: error.code ?? -1, message: error.message } });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async #dispatch(entry, method, params) {
|
|
879
|
+
// A server-forwarded caller is re-admitted on every door: a grant revoked or expired
|
|
880
|
+
// since the stream opened stops mattering NOW, not at the next stream.
|
|
881
|
+
if (entry.verify) {
|
|
882
|
+
const fresh = await entry.verify();
|
|
883
|
+
if (!fresh) {
|
|
884
|
+
this.log.append({ principal: entry.principal, cap: method, target: '*', outcome: 'refused', detail: 'no live machine grant' });
|
|
885
|
+
throw Object.assign(new Error('no live machine grant'), { code: 403 });
|
|
886
|
+
}
|
|
887
|
+
entry.admitted = fresh;
|
|
888
|
+
}
|
|
889
|
+
const { admitted, principal } = entry;
|
|
890
|
+
const operator = admitted.class === 'operator';
|
|
891
|
+
const need = (cap, target) => {
|
|
892
|
+
if (!holds(admitted, cap, target)) {
|
|
893
|
+
this.log.append({ principal, cap, target, outcome: 'refused' });
|
|
894
|
+
throw Object.assign(new Error(`no ${cap} on ${target}`), { code: 403 });
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const act = (cap, target, outcome = 'ok', detail = null) => this.log.append({ principal, cap, target, outcome, detail });
|
|
898
|
+
|
|
899
|
+
switch (method) {
|
|
900
|
+
case 'describe':
|
|
901
|
+
return this.describe();
|
|
902
|
+
case 'panes.list': {
|
|
903
|
+
// A participant lists the panes its grants let it observe; view on the whole
|
|
904
|
+
// machine (or operator) lists them all.
|
|
905
|
+
const whole = operator || holds(admitted, 'view', '*');
|
|
906
|
+
if (!whole && !observesPanes(admitted)) need('view', '*');
|
|
907
|
+
const shown = (pane) => whole || holds(admitted, 'observe', `pane:${pane}`);
|
|
908
|
+
const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
|
|
909
|
+
// Every row carries its attention because the evidence is already in
|
|
910
|
+
// hand here: a list that made a caller ask a second door "and is anything
|
|
911
|
+
// wrong with these?" would be a list that knows and will not say.
|
|
912
|
+
const rows = await this.attentionEvidence();
|
|
913
|
+
// `title` is what a viewer should show: the override if one is set, else the tmux label.
|
|
914
|
+
return rows.filter((row) => row.session.id && shown(row.session.contextKey)).map(({ session: s, attention, rank, notify, evidence }) => ({
|
|
915
|
+
pane: s.contextKey, label: s.label, title: titles[`pane:${s.contextKey}`] ?? s.label, cwd: s.cwd, owned: s.owned, activeCommand: s.activeCommand, size: s.size, suspended: this.suspended.has(s.contextKey),
|
|
916
|
+
attention, rank, notify, evidence,
|
|
917
|
+
}));
|
|
918
|
+
}
|
|
919
|
+
case 'launch': {
|
|
920
|
+
// A pane launch. Operators only; the body may carry secrets and is never logged.
|
|
921
|
+
if (!operator) throw Object.assign(new Error('launch is for operators'), { code: 403 });
|
|
922
|
+
const key = params.key ?? `lk_${randomBytes(9).toString('base64url')}`;
|
|
923
|
+
const existing = this.launches.find(key);
|
|
924
|
+
// A key whose launch is in flight, or whose pane still lives, is reused; a failed
|
|
925
|
+
// launch, or one whose pane has ended, may be launched again.
|
|
926
|
+
if (existing && (existing.status === 'issued' || (existing.status === 'ready' && await this.#paneByContext(existing.pane)))) {
|
|
927
|
+
return { key, pane: existing.pane, reused: true, status: existing.status };
|
|
928
|
+
}
|
|
929
|
+
const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
|
|
930
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'issued' });
|
|
931
|
+
let session;
|
|
932
|
+
try {
|
|
933
|
+
session = await this.host.createSession({
|
|
934
|
+
command: { program: params.program, arguments: params.arguments ?? [] },
|
|
935
|
+
contextKey,
|
|
936
|
+
cwd: params.cwd,
|
|
937
|
+
env: this.#paneEnv(contextKey, params.env),
|
|
938
|
+
name: params.name ?? contextKey,
|
|
939
|
+
});
|
|
940
|
+
} catch (error) {
|
|
941
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'failed', error: error.message });
|
|
942
|
+
act('launch', `pane:${contextKey}`, 'failed', error.code ?? error.message);
|
|
943
|
+
throw error;
|
|
944
|
+
}
|
|
945
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'ready' });
|
|
946
|
+
act('launch', `pane:${contextKey}`, 'ok', params.kind ?? params.program);
|
|
947
|
+
// The first input is delivered when the pane can TAKE it, not when the
|
|
948
|
+
// pane exists: a program that is still painting its banner has no
|
|
949
|
+
// input box yet, and text sent into one lands half in the scrollback and
|
|
950
|
+
// half in a composer that never sees the submit. The wait runs past this
|
|
951
|
+
// reply — a cold TUI outlasts the caller's own RPC timeout — and its
|
|
952
|
+
// outcome is recorded against the launch key, so `launches.get|list`
|
|
953
|
+
// answers "did my message go in, and was it late?" without anyone
|
|
954
|
+
// reading the screen.
|
|
955
|
+
if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, input: params.initialInput, act });
|
|
956
|
+
return { key, pane: contextKey, reused: false, status: 'ready' };
|
|
957
|
+
}
|
|
958
|
+
case 'launches.get': {
|
|
959
|
+
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
960
|
+
const found = this.launches.find(params.key);
|
|
961
|
+
if (!found) return null;
|
|
962
|
+
const session = await this.#paneByContext(found.pane);
|
|
963
|
+
return {
|
|
964
|
+
key: found.key, pane: found.pane, live: Boolean(session), label: session?.label ?? null, activeCommand: session?.activeCommand ?? null,
|
|
965
|
+
...initialInputFacts(found),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
case 'launches.list': {
|
|
969
|
+
// Everything this daemon launched, by key, with whether the pane still lives: how an
|
|
970
|
+
// operator tells its own launches from a person's panes without reading labels.
|
|
971
|
+
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
972
|
+
const rows = [];
|
|
973
|
+
for (const found of this.launches.all()) {
|
|
974
|
+
const session = found.pane ? await this.#paneByContext(found.pane) : null;
|
|
975
|
+
rows.push({ key: found.key, pane: found.pane ?? null, kind: found.kind ?? null, status: found.status ?? null, at: found.t, live: Boolean(session), ...initialInputFacts(found) });
|
|
976
|
+
}
|
|
977
|
+
return rows;
|
|
978
|
+
}
|
|
979
|
+
case 'panes.close': {
|
|
980
|
+
need(operator ? 'control' : 'control', `pane:${params.pane}`);
|
|
981
|
+
const session = await this.#paneByContext(params.pane);
|
|
982
|
+
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
983
|
+
this.host.closeSession(session.id);
|
|
984
|
+
act('close', `pane:${params.pane}`);
|
|
985
|
+
return { closed: params.pane };
|
|
986
|
+
}
|
|
987
|
+
case 'panes.suspend': case 'panes.resume': {
|
|
988
|
+
// a holder of `control` (an operator always) stops REMOTE input on a
|
|
989
|
+
// pane — attachments keep streaming output and `panes.capture` keeps
|
|
990
|
+
// working, only `{input}` is refused — until `resume`, which may carry
|
|
991
|
+
// the input that hands the pane back. Local tmux input is never touched.
|
|
992
|
+
need('control', `pane:${params.pane}`);
|
|
993
|
+
const session = await this.#paneByContext(params.pane);
|
|
994
|
+
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
995
|
+
const doc = readJson(this.p.suspended, { v: 1, panes: {} });
|
|
996
|
+
if (method === 'panes.suspend') {
|
|
997
|
+
this.suspended.add(params.pane);
|
|
998
|
+
doc.panes[params.pane] = { at: Date.now(), by: principal };
|
|
999
|
+
if (params.interrupt) await this.host.sendInput(session.id, '\u0003', { submitMode: 'single' });
|
|
1000
|
+
} else {
|
|
1001
|
+
this.suspended.delete(params.pane);
|
|
1002
|
+
delete doc.panes[params.pane];
|
|
1003
|
+
if (params.input) await this.host.sendInput(session.id, submission(params.input), { submitMode: params.submitMode ?? 'single' });
|
|
1004
|
+
}
|
|
1005
|
+
writeJson(this.p.suspended, doc);
|
|
1006
|
+
act(method, `pane:${params.pane}`, 'ok', method === 'panes.resume' && params.input ? 'with input' : null);
|
|
1007
|
+
return { pane: params.pane, suspended: method === 'panes.suspend' };
|
|
1008
|
+
}
|
|
1009
|
+
case 'panes.input': {
|
|
1010
|
+
// Input as a request (the terminal host's own sendInput, with its paste/settle/submit
|
|
1011
|
+
// mechanics) for callers that are services rather than terminals. Same gate as an
|
|
1012
|
+
// attachment in control mode, same suspend rule as remote input everywhere.
|
|
1013
|
+
need('control', `pane:${params.pane}`);
|
|
1014
|
+
const session = await this.#paneByContext(params.pane);
|
|
1015
|
+
if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
|
|
1016
|
+
if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
|
|
1017
|
+
// `deadlineMs` is how long the caller will wait (default 10 s, which covers `retry`'s 5.5 s of
|
|
1018
|
+
// submit presses; at most 30 s); the terminal host takes an absolute deadline.
|
|
1019
|
+
const delivery = await this.host.sendInput(session.id, submission(params.text, params.submit !== false), { submitMode: params.submit === false ? 'single' : 'retry', deadlineMs: Date.now() + Math.min(Number(params.deadlineMs) || 10_000, 30_000) });
|
|
1020
|
+
act('control', `pane:${params.pane}`, 'input');
|
|
1021
|
+
return { delivered: true, delivery };
|
|
1022
|
+
}
|
|
1023
|
+
case 'panes.turn': {
|
|
1024
|
+
// A user's own turn, as its own submitted turn: typed only into an empty
|
|
1025
|
+
// composer, read twice so a person who just started typing is not typed
|
|
1026
|
+
// over. A draft, or a screen with no composer (a menu, a dialog), is an
|
|
1027
|
+
// answer, not an error: the caller keeps the turn and asks again. Its own
|
|
1028
|
+
// method, so a daemon without it refuses instead of typing into a draft.
|
|
1029
|
+
need('control', `pane:${params.pane}`);
|
|
1030
|
+
const session = await this.#paneByContext(params.pane);
|
|
1031
|
+
if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
|
|
1032
|
+
if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
|
|
1033
|
+
const kind = this.#paneKind(params.pane, session);
|
|
1034
|
+
const draftOf = () => composerHoldsDraft(kind, this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
|
|
1035
|
+
const first = draftOf();
|
|
1036
|
+
await new Promise((resolve) => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
1037
|
+
const second = draftOf();
|
|
1038
|
+
if (first !== false || second !== false) {
|
|
1039
|
+
return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
|
|
1040
|
+
}
|
|
1041
|
+
const delivery = await this.host.sendInput(session.id, submission(params.text, true), { submitMode: 'single', deadlineMs: Date.now() + 10_000 });
|
|
1042
|
+
act('control', `pane:${params.pane}`, 'input');
|
|
1043
|
+
return { delivered: true, delivery };
|
|
1044
|
+
}
|
|
1045
|
+
case 'panes.capture': {
|
|
1046
|
+
need('observe', `pane:${params.pane}`);
|
|
1047
|
+
const session = await this.#paneByContext(params.pane);
|
|
1048
|
+
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
1049
|
+
return { text: this.host.capture(session.id, { lines: params.lines ?? 200 }) };
|
|
1050
|
+
}
|
|
1051
|
+
case 'files.stat': case 'files.list': case 'files.read': case 'files.write': case 'files.mkdir': case 'files.rename': case 'files.delete': case 'files.search': {
|
|
1052
|
+
// Authorize the REAL path: a symlink under a granted root must not reach outside it. Reading (stat, list,
|
|
1053
|
+
// read, search) is `files` or `observe` on the root, as observing a pane is reading it; changing is `files`.
|
|
1054
|
+
const path = realOrResolved(params.path);
|
|
1055
|
+
const reads = method === 'files.stat' || method === 'files.list' || method === 'files.read' || method === 'files.search';
|
|
1056
|
+
if (!(reads && holds(admitted, 'observe', `root:${path}`))) need('files', `root:${path}`);
|
|
1057
|
+
// A rename's destination is a write to a second path: it is authorized on its own.
|
|
1058
|
+
const to = params.to ? realOrResolved(params.to) : null;
|
|
1059
|
+
if (to) need('files', `root:${to}`);
|
|
1060
|
+
const outcome = await filesOp(method, { ...params, path }, to);
|
|
1061
|
+
act('files', `root:${path}`, 'ok', method);
|
|
1062
|
+
return outcome;
|
|
1063
|
+
}
|
|
1064
|
+
case 'ports.list': {
|
|
1065
|
+
// Operators see every port; a participant sees the ports its grants reach.
|
|
1066
|
+
if (operator) return this.listPorts();
|
|
1067
|
+
const reach = (p) => holds(admitted, 'ports', `port:${p.pane ?? '*'}/${p.port}`) || (p.pane && holds(admitted, 'ports', `pane:${p.pane}`));
|
|
1068
|
+
const shown = this.listPorts().filter(reach);
|
|
1069
|
+
if (!shown.length && !admitted.grants.some((g) => g.caps.includes('ports'))) need('ports', 'port:*');
|
|
1070
|
+
return shown;
|
|
1071
|
+
}
|
|
1072
|
+
case 'ports.resolve': {
|
|
1073
|
+
// One pane's announced port, for a caller holding `ports` on that pane.
|
|
1074
|
+
if (typeof params.pane !== 'string' || !params.pane) throw Object.assign(new Error('ports.resolve needs a pane'), { code: 400 });
|
|
1075
|
+
if (!operator) need('ports', `pane:${params.pane}`);
|
|
1076
|
+
return this.listPorts().find((entry) => entry.pane === params.pane) ?? null;
|
|
1077
|
+
}
|
|
1078
|
+
case 'attention.request': case 'attention.clear': {
|
|
1079
|
+
// The declaration rung: somebody SAYS this pane needs a human. The
|
|
1080
|
+
// urgency and the reason ride with the record and reach the evidence
|
|
1081
|
+
// verbatim — the daemon never grades a reason, and `now` versus `whenever`
|
|
1082
|
+
// is the caller's claim about their own work, not a label we infer.
|
|
1083
|
+
need('interact', `pane:${params.pane}`);
|
|
1084
|
+
const doc = this.attentionDoc();
|
|
1085
|
+
if (method === 'attention.request') doc.requests[params.pane] = { urgency: params.urgency ?? 'soon', reason: params.reason ?? null, at: Date.now(), by: principal };
|
|
1086
|
+
else delete doc.requests[params.pane];
|
|
1087
|
+
writeJson(this.p.attention, doc);
|
|
1088
|
+
act(method, `pane:${params.pane}`, 'ok', method === 'attention.request' ? (params.urgency ?? 'soon') : null);
|
|
1089
|
+
return { ok: true };
|
|
1090
|
+
}
|
|
1091
|
+
case 'attention.list': {
|
|
1092
|
+
// The standing declarations, as recorded. The computed view is
|
|
1093
|
+
// `attention.snapshot`; this door stays the raw map so a caller asking
|
|
1094
|
+
// "what did anyone ASK for" is never handed an inference as an answer.
|
|
1095
|
+
if (!operator) need('view', '*');
|
|
1096
|
+
return this.attentionDoc().requests;
|
|
1097
|
+
}
|
|
1098
|
+
case 'attention.snapshot': {
|
|
1099
|
+
// The label for every pane, from the evidence and the table, computed
|
|
1100
|
+
// here on the Machine. It carries `notify` — whether this label is
|
|
1101
|
+
// the kind that warrants interrupting a human — and stops there: who is
|
|
1102
|
+
// paged, and whether they already know, is the consumer's.
|
|
1103
|
+
if (!operator) need('view', '*');
|
|
1104
|
+
const rows = await this.attentionEvidence();
|
|
1105
|
+
return rows.map(({ session, attention, rank, notify, evidence }) => ({ pane: session.contextKey, attention, rank, notify, evidence }));
|
|
1106
|
+
}
|
|
1107
|
+
case 'attention.rank': {
|
|
1108
|
+
// The rank table as this Machine reads it — the configuration the label
|
|
1109
|
+
// is a function of, so a caller surprised by a label can see the
|
|
1110
|
+
// precedence that produced it rather than guess at a default.
|
|
1111
|
+
if (!operator) need('view', '*');
|
|
1112
|
+
return this.attentionDoc().rank;
|
|
1113
|
+
}
|
|
1114
|
+
case 'title.set': {
|
|
1115
|
+
// The title is a teams file, never a store in core. A pane the daemon
|
|
1116
|
+
// owns also gets the name in tmux, so the rename is visible to anyone
|
|
1117
|
+
// looking at the tmux server rather than only through this door.
|
|
1118
|
+
need('control', params.target);
|
|
1119
|
+
const doc = readJson(this.p.titles, { v: 1, titles: {} });
|
|
1120
|
+
if (params.title == null) delete doc.titles[params.target]; else doc.titles[params.target] = params.title;
|
|
1121
|
+
writeJson(this.p.titles, doc);
|
|
1122
|
+
const renamed = params.target.startsWith('pane:') ? await this.#renameWindow(params.target.slice(5), params.title) : null;
|
|
1123
|
+
act('title', params.target, 'ok', renamed);
|
|
1124
|
+
return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
|
|
1125
|
+
}
|
|
1126
|
+
case 'jobs.list': {
|
|
1127
|
+
// The execution jobs this daemon owns now (execution.mjs): a pilot changes its binding
|
|
1128
|
+
// only when the target it leaves and the one it takes run none.
|
|
1129
|
+
if (!operator) throw Object.assign(new Error('jobs are for operators'), { code: 403 });
|
|
1130
|
+
return { jobs: [...(this.jobs?.live.values() ?? [])].map(({ id, kind, cgroup, at }) => ({ id, kind, contained: Boolean(cgroup), at })) };
|
|
1131
|
+
}
|
|
1132
|
+
case 'title.list': {
|
|
1133
|
+
// Operators and whole-machine viewers see every title; others see the titles of targets they observe.
|
|
1134
|
+
const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
|
|
1135
|
+
if (operator || holds(admitted, 'view', '*')) return titles;
|
|
1136
|
+
if (!observesPanes(admitted)) need('view', '*');
|
|
1137
|
+
return Object.fromEntries(Object.entries(titles).filter(([target]) => holds(admitted, 'observe', target)));
|
|
1138
|
+
}
|
|
1139
|
+
default:
|
|
1140
|
+
throw Object.assign(new Error(`unknown method ${method}`), { code: -32601 });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ---- helpers ------------------------------------------------------------------
|
|
1146
|
+
|
|
1147
|
+
/** The shells a pane may be sitting in. `activeCommand` is tmux's
|
|
1148
|
+
* `pane_current_command`: the name of the process in the foreground of the
|
|
1149
|
+
* pane's tty. When that is the pane's own shell, no command is running and the
|
|
1150
|
+
* shell is at its prompt; when it is anything else, that thing is the work. The
|
|
1151
|
+
* list is names, not paths, because that is the form tmux reports. */
|
|
1152
|
+
const PANE_SHELLS = new Set(['ash', 'bash', 'csh', 'dash', 'fish', 'ksh', 'ksh93', 'nu', 'pwsh', 'sh', 'tcsh', 'xonsh', 'zsh']);
|
|
1153
|
+
|
|
1154
|
+
/** Liveness of a live pane from its foreground command (attention.mjs LIVENESS).
|
|
1155
|
+
* A missing or empty value is `unknown`, the honest floor: tmux named nothing,
|
|
1156
|
+
* which is absence of evidence and never reported as `running`. */
|
|
1157
|
+
/** A pane's liveness from the harness session running in it, or null when
|
|
1158
|
+
* the harness has no trustworthy turn state (the process tree answers then). */
|
|
1159
|
+
function harnessLiveness(activity) {
|
|
1160
|
+
if (!activity || activity.presence !== 'running') return null;
|
|
1161
|
+
if (activity.turn === 'working') return { liveness: 'running' };
|
|
1162
|
+
if (activity.turn === 'idle') return { liveness: 'waiting' };
|
|
1163
|
+
if (activity.turn === 'needs_input') return { liveness: 'waiting', signal: 'asking' };
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function livenessOf(activeCommand) {
|
|
1168
|
+
if (typeof activeCommand !== 'string' || activeCommand.trim() === '') return 'unknown';
|
|
1169
|
+
const name = activeCommand.trim().replace(/^-/, '').split('/').pop();
|
|
1170
|
+
return PANE_SHELLS.has(name) ? 'waiting' : 'running';
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** The label, its rank and its notify flag, with the evidence tagged by the rung
|
|
1174
|
+
* that decided it — the shape every attention-bearing row carries. */
|
|
1175
|
+
function judged(evidence, rankTable) {
|
|
1176
|
+
const verdict = judge(evidence, rankTable);
|
|
1177
|
+
return {
|
|
1178
|
+
attention: verdict.attention,
|
|
1179
|
+
rank: verdict.rank,
|
|
1180
|
+
notify: verdict.notify,
|
|
1181
|
+
evidence: { ...evidence, source: verdict.source },
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Text a door delivers as one message: the terminal host submits (a paste, then Enter) only
|
|
1186
|
+
* text that ends in a newline and types anything else as keystrokes, so a door that promises
|
|
1187
|
+
* a submission ends the text with exactly one, and `submit: false` leaves it without. */
|
|
1188
|
+
function submission(text, submit = true) {
|
|
1189
|
+
const body = String(text ?? '').replace(/(?:\r\n|\n|\r)+$/u, '');
|
|
1190
|
+
return submit ? `${body}\n` : body;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Characters of a harness line sent per channel frame. A frame is capped at 8 MiB,
|
|
1194
|
+
* and one answer (a whole machine's `sessions.discover`) can be larger; the reader
|
|
1195
|
+
* joins a stream's chunks back into lines, so a long line travels as several frames.
|
|
1196
|
+
* 512 Ki characters stay under the cap even when every one needs four bytes. */
|
|
1197
|
+
const LINE_CHUNK = 512 * 1024;
|
|
1198
|
+
|
|
1199
|
+
/** Write one JSON line to a harness stream, in as many frames as it needs. */
|
|
1200
|
+
function writeLine(stream, line) {
|
|
1201
|
+
const text = line + '\n';
|
|
1202
|
+
for (let at = 0; at < text.length; at += LINE_CHUNK) stream.write(text.slice(at, at + LINE_CHUNK));
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** What a launch record says about its first input, for the rows `launches.get`
|
|
1206
|
+
* and `launches.list` return. Absent from a launch that carried no
|
|
1207
|
+
* `initialInput`, and absent while one is still waiting for its pane — a
|
|
1208
|
+
* launch that has not answered yet must not read as one that answered "no". */
|
|
1209
|
+
function initialInputFacts(record) {
|
|
1210
|
+
if (record?.inputDelivered === undefined) return {};
|
|
1211
|
+
return {
|
|
1212
|
+
inputDelivered: Boolean(record.inputDelivered),
|
|
1213
|
+
inputReadyBy: record.inputReadyBy ?? null,
|
|
1214
|
+
...(record.inputDeliveredLate ? { inputDeliveredLate: true } : {}),
|
|
1215
|
+
...(record.inputError ? { inputError: record.inputError } : {}),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* The path a `files.*` door authorizes: the real path when it exists (so a symlink under a granted
|
|
1221
|
+
* root cannot point outside it), else the resolved path with its deepest existing ancestor made real.
|
|
1222
|
+
*/
|
|
1223
|
+
function realOrResolved(candidate) {
|
|
1224
|
+
const resolved = resolve(String(candidate ?? ''));
|
|
1225
|
+
try { return realpathSync(resolved); } catch { /* not there yet: a write or mkdir target */ }
|
|
1226
|
+
let dir = dirname(resolved);
|
|
1227
|
+
while (dir !== dirname(dir)) {
|
|
1228
|
+
try { return join(realpathSync(dir), resolved.slice(dir.length)); } catch { dir = dirname(dir); }
|
|
1229
|
+
}
|
|
1230
|
+
return resolved;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function withTimeout(promise, ms, label) {
|
|
1234
|
+
let timer;
|
|
1235
|
+
const bound = new Promise((_, reject) => {
|
|
1236
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`${label} did not answer within ${ms} ms`), { code: 504 })), ms);
|
|
1237
|
+
});
|
|
1238
|
+
return Promise.race([promise, bound]).finally(() => clearTimeout(timer));
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** Connection id of the daemon's own requests to its `harness serve`. */
|
|
1242
|
+
const INTERNAL_CONNECTION = -1;
|
|
1243
|
+
|
|
1244
|
+
function joinId(conn, id) { return `${conn}:${typeof id === 'string' ? 's' : 'n'}:${id}`; }
|
|
1245
|
+
function splitId(id) {
|
|
1246
|
+
if (typeof id !== 'string') return [null, id];
|
|
1247
|
+
const first = id.indexOf(':');
|
|
1248
|
+
const second = id.indexOf(':', first + 1);
|
|
1249
|
+
if (first < 0 || second < 0) return [null, id];
|
|
1250
|
+
const conn = Number(id.slice(0, first));
|
|
1251
|
+
const kind = id.slice(first + 1, second);
|
|
1252
|
+
const rest = id.slice(second + 1);
|
|
1253
|
+
return [conn, kind === 'n' ? Number(rest) : rest];
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** A `ws`-shaped socket over a channel stream, for the terminal substrate. */
|
|
1257
|
+
class ChannelWebSocket extends EventEmitter {
|
|
1258
|
+
constructor(stream) {
|
|
1259
|
+
super();
|
|
1260
|
+
this.OPEN = 1;
|
|
1261
|
+
this.CLOSED = 3;
|
|
1262
|
+
this.readyState = 1;
|
|
1263
|
+
this.stream = stream;
|
|
1264
|
+
stream.on('data', (chunk) => {
|
|
1265
|
+
// Inbound from the caller: JSON {input} | {resize}. The substrate wants {type:'input',data} | {type:'resize',cols,rows}.
|
|
1266
|
+
let message;
|
|
1267
|
+
try { message = JSON.parse(chunk); } catch { return; }
|
|
1268
|
+
if (message.input !== undefined) this.emit('message', Buffer.from(JSON.stringify({ type: 'input', data: message.input })));
|
|
1269
|
+
else if (message.resize) this.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: message.resize.cols, rows: message.resize.rows })));
|
|
1270
|
+
});
|
|
1271
|
+
stream.on('close', () => { this.readyState = 3; this.emit('close'); });
|
|
1272
|
+
}
|
|
1273
|
+
send(data) {
|
|
1274
|
+
if (this.readyState !== 1) return;
|
|
1275
|
+
const bytes = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
|
|
1276
|
+
this.stream.write(bytes.toString('base64'));
|
|
1277
|
+
}
|
|
1278
|
+
close(code, reason) {
|
|
1279
|
+
if (this.readyState === 3) return;
|
|
1280
|
+
this.readyState = 3;
|
|
1281
|
+
this.stream.close(reason ? String(reason) : null);
|
|
1282
|
+
this.emit('close');
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* The suspend gate. It is a socket in front of a socket: the substrate
|
|
1288
|
+
* (tmux-stream) sees an ordinary ws-shaped peer, and the only difference is
|
|
1289
|
+
* that an `input` message arriving while the pane is suspended never reaches
|
|
1290
|
+
* it — the sender gets one `notice` frame instead.
|
|
1291
|
+
*/
|
|
1292
|
+
class SuspendGate extends EventEmitter {
|
|
1293
|
+
constructor(inner, { suspended, notice }) {
|
|
1294
|
+
super();
|
|
1295
|
+
this.inner = inner;
|
|
1296
|
+
this.OPEN = 1;
|
|
1297
|
+
this.CLOSED = 3;
|
|
1298
|
+
inner.on('message', (raw) => {
|
|
1299
|
+
let type = null;
|
|
1300
|
+
try { type = JSON.parse(raw.toString()).type; } catch { /* not ours to parse */ }
|
|
1301
|
+
if (type === 'input' && suspended()) {
|
|
1302
|
+
this.send(JSON.stringify({ type: 'notice', message: notice }));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
this.emit('message', raw);
|
|
1306
|
+
});
|
|
1307
|
+
inner.on('close', () => this.emit('close'));
|
|
1308
|
+
}
|
|
1309
|
+
get readyState() { return this.inner.readyState; }
|
|
1310
|
+
send(data) { this.inner.send(data); }
|
|
1311
|
+
close(code, reason) { this.inner.close(code, reason); }
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async function filesOp(method, params, to) {
|
|
1315
|
+
const path = params.path;
|
|
1316
|
+
switch (method) {
|
|
1317
|
+
case 'files.stat': {
|
|
1318
|
+
const s = statSync(path);
|
|
1319
|
+
return { path, size: s.size, mode: s.mode, mtimeMs: s.mtimeMs, kind: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other' };
|
|
1320
|
+
}
|
|
1321
|
+
case 'files.list':
|
|
1322
|
+
return readdirSync(path, { withFileTypes: true }).map((d) => ({ name: d.name, kind: d.isDirectory() ? 'directory' : d.isFile() ? 'file' : 'other' }));
|
|
1323
|
+
case 'files.read': {
|
|
1324
|
+
const fd = openSync(path, 'r');
|
|
1325
|
+
try {
|
|
1326
|
+
// One read is at most 8 MiB (RH2's transfer chunks at 8 MiB); a caller asks again for more.
|
|
1327
|
+
const length = Math.min(Number(params.length) || statSync(path).size, 8 * 1024 * 1024);
|
|
1328
|
+
const buffer = Buffer.alloc(length);
|
|
1329
|
+
const read = readSync(fd, buffer, 0, length, params.offset ?? 0);
|
|
1330
|
+
return { bytes: buffer.subarray(0, read).toString('base64'), read };
|
|
1331
|
+
} finally { closeSync(fd); }
|
|
1332
|
+
}
|
|
1333
|
+
case 'files.write': {
|
|
1334
|
+
const bytes = Buffer.from(params.bytes ?? '', 'base64');
|
|
1335
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1336
|
+
if (params.truncate || params.offset == null) writeFileSync(path, bytes, { mode: params.mode ?? 0o644 });
|
|
1337
|
+
else {
|
|
1338
|
+
const fd = openSync(path, 'r+');
|
|
1339
|
+
try { writeSync(fd, bytes, 0, bytes.length, params.offset); } finally { closeSync(fd); }
|
|
1340
|
+
}
|
|
1341
|
+
return { written: bytes.length };
|
|
1342
|
+
}
|
|
1343
|
+
case 'files.mkdir':
|
|
1344
|
+
mkdirSync(path, { recursive: true, mode: params.mode ?? 0o755 });
|
|
1345
|
+
return { ok: true };
|
|
1346
|
+
case 'files.rename':
|
|
1347
|
+
renameSync(path, to);
|
|
1348
|
+
return { ok: true };
|
|
1349
|
+
case 'files.delete':
|
|
1350
|
+
rmSync(path, { recursive: Boolean(params.recursive), force: false });
|
|
1351
|
+
return { ok: true };
|
|
1352
|
+
case 'files.search': {
|
|
1353
|
+
const needle = String(params.query ?? '').toLowerCase();
|
|
1354
|
+
const out = [];
|
|
1355
|
+
const walk = (dir, depth) => {
|
|
1356
|
+
// Bounded walk: depth ≤ 16, results ≤ 1000, whatever the caller asks.
|
|
1357
|
+
if (depth > Math.min(Number(params.maxDepth) || 8, 16) || out.length >= Math.min(Number(params.limit) || 200, 1000)) return;
|
|
1358
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
1359
|
+
const full = join(dir, d.name);
|
|
1360
|
+
if (d.name.toLowerCase().includes(needle)) out.push({ path: full, kind: d.isDirectory() ? 'directory' : 'file' });
|
|
1361
|
+
if (d.isDirectory() && d.name !== 'node_modules' && d.name !== '.git') walk(full, depth + 1);
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
walk(path, 0);
|
|
1365
|
+
return out;
|
|
1366
|
+
}
|
|
1367
|
+
default:
|
|
1368
|
+
throw new Error(`unknown files op ${method}`);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* The live port table: one JSON file per announced port under <home>/ports.
|
|
1373
|
+
* A hook that was killed before its exit handler ran leaves its file behind, so the
|
|
1374
|
+
* table is swept here — an entry whose pid is gone is neither returned nor kept.
|
|
1375
|
+
*/
|
|
1376
|
+
function listAnnouncedPorts(dir) {
|
|
1377
|
+
if (!existsSync(dir)) return [];
|
|
1378
|
+
const out = [];
|
|
1379
|
+
for (const name of readdirSync(dir)) {
|
|
1380
|
+
if (!name.endsWith('.json')) continue;
|
|
1381
|
+
const file = join(dir, name);
|
|
1382
|
+
try {
|
|
1383
|
+
const entry = JSON.parse(readFileSync(file, 'utf8'));
|
|
1384
|
+
if (!alive(entry.pid)) { rmSync(file, { force: true }); continue; }
|
|
1385
|
+
out.push({ port: entry.port, pid: entry.pid, pane: entry.pane ?? null, label: entry.label ?? null, kind: entry.kind ?? 'http', at: entry.at ?? null });
|
|
1386
|
+
} catch { /* a half-written announce file is skipped */ }
|
|
1387
|
+
}
|
|
1388
|
+
return out;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** A socket file left by a daemon that is gone is removed; one a live daemon answers on is not ours to take. */
|
|
1392
|
+
/** Signal 0: does this pid still exist? EPERM means it does, under another uid. */
|
|
1393
|
+
function alive(pid) {
|
|
1394
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1395
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
export { listAnnouncedPorts };
|