@ccmsg/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type Capability,
|
|
5
|
+
type Endpoint,
|
|
6
|
+
type InstanceId,
|
|
7
|
+
type InstancePingResult,
|
|
8
|
+
type NetOnlineEvent,
|
|
9
|
+
type RestartingEvent,
|
|
10
|
+
type Sid,
|
|
11
|
+
type Timestamp,
|
|
12
|
+
} from "@ccmsg/protocol";
|
|
13
|
+
import {
|
|
14
|
+
callerOf,
|
|
15
|
+
CallerError,
|
|
16
|
+
callerOfIdentity,
|
|
17
|
+
dispatch,
|
|
18
|
+
type DispatchResult,
|
|
19
|
+
failure,
|
|
20
|
+
type Handlers,
|
|
21
|
+
type Requester,
|
|
22
|
+
} from "../dispatch/index.ts";
|
|
23
|
+
import {
|
|
24
|
+
Containment,
|
|
25
|
+
fileHandlers,
|
|
26
|
+
sandboxCapabilities,
|
|
27
|
+
SandboxGrants,
|
|
28
|
+
sandboxHandlers,
|
|
29
|
+
type SessionRoots,
|
|
30
|
+
} from "../files/index.ts";
|
|
31
|
+
import {
|
|
32
|
+
ClaudeCodeSocketRoute,
|
|
33
|
+
Delivery,
|
|
34
|
+
DisabledDirectRoute,
|
|
35
|
+
type DirectRoute,
|
|
36
|
+
inboxPath,
|
|
37
|
+
messagingHandlers,
|
|
38
|
+
Notify,
|
|
39
|
+
sessionLabel,
|
|
40
|
+
} from "../messaging/index.ts";
|
|
41
|
+
import { Inbox } from "../messaging/inbox.ts";
|
|
42
|
+
import {
|
|
43
|
+
hostProcessDeps,
|
|
44
|
+
hostTerminalReader,
|
|
45
|
+
sessionCapabilities,
|
|
46
|
+
sessionHandlers,
|
|
47
|
+
SessionProcesses,
|
|
48
|
+
Sessions,
|
|
49
|
+
sessionStatusOf,
|
|
50
|
+
SessionStatus,
|
|
51
|
+
} from "../sessions/index.ts";
|
|
52
|
+
import { topicHandlers, Topics } from "../topics/index.ts";
|
|
53
|
+
import { TranscriptFiles, Transcripts } from "../transcript/index.ts";
|
|
54
|
+
import {
|
|
55
|
+
ConnRegistry,
|
|
56
|
+
type EntryPolicy,
|
|
57
|
+
type Listener,
|
|
58
|
+
listenUds,
|
|
59
|
+
serveWs,
|
|
60
|
+
Transport,
|
|
61
|
+
type UpgradeDecision,
|
|
62
|
+
} from "../transport/index.ts";
|
|
63
|
+
import { Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
|
|
64
|
+
import {
|
|
65
|
+
Gateway,
|
|
66
|
+
gatewayCapabilities,
|
|
67
|
+
gatewayHandlers,
|
|
68
|
+
type GatewaySetup,
|
|
69
|
+
gatewaySetup,
|
|
70
|
+
} from "../upstream/index.ts";
|
|
71
|
+
import { KV_DIR, kvHandlers, KvStore } from "../kv/index.ts";
|
|
72
|
+
import { Launcher, launcherCapabilities, launcherHandlers } from "../launcher/index.ts";
|
|
73
|
+
import {
|
|
74
|
+
Translate,
|
|
75
|
+
translateCapabilities,
|
|
76
|
+
translateHandlers,
|
|
77
|
+
translateSetup,
|
|
78
|
+
} from "../translate/index.ts";
|
|
79
|
+
import { type EntryConfig, type InstanceConfig, loadConfig } from "./config.ts";
|
|
80
|
+
import { completeHandlers } from "./handlers.ts";
|
|
81
|
+
import { acquireLock, type Held, isHeldByUs, type Lock } from "./lock.ts";
|
|
82
|
+
import { Log } from "./log.ts";
|
|
83
|
+
import { prepareSocketDir, publishSocket, sweepOrphanSockets } from "./socket.ts";
|
|
84
|
+
import { instanceIdentity } from "./identity.ts";
|
|
85
|
+
import { type Env, type InstancePaths, resolvePaths } from "./paths.ts";
|
|
86
|
+
import { VERSION } from "../version.ts";
|
|
87
|
+
|
|
88
|
+
export interface StartOptions {
|
|
89
|
+
readonly env?: Env;
|
|
90
|
+
/** Mirror the log to stderr. A foreground run wants it; a test does not. */
|
|
91
|
+
readonly echoLog?: boolean;
|
|
92
|
+
/** Overrides the confirmation poll of the sessions watch, for tests. */
|
|
93
|
+
readonly pollMs?: number;
|
|
94
|
+
/** Overrides the mesh's own intervals, for a test that cannot wait out a
|
|
95
|
+
* heartbeat or a reconnection backoff. */
|
|
96
|
+
readonly meshTiming?: MeshTiming;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The mesh intervals a caller may shorten. The values themselves, and why they
|
|
100
|
+
* are what they are, belong to the mesh (§8.2, §8.3). */
|
|
101
|
+
export interface MeshTiming {
|
|
102
|
+
readonly heartbeatMs?: number;
|
|
103
|
+
readonly heartbeatTimeoutMs?: number;
|
|
104
|
+
readonly reconnectMinMs?: number;
|
|
105
|
+
readonly forwardTimeoutMs?: number;
|
|
106
|
+
/** The clock the retention window of §7.5 is read against, so a test can
|
|
107
|
+
* pass it without waiting a week. */
|
|
108
|
+
readonly now?: () => Timestamp;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Startup found another instance already serving this config home. Nothing
|
|
112
|
+
* was created and nothing has to be undone (§8.3 step 2). */
|
|
113
|
+
export interface AlreadyRunning {
|
|
114
|
+
readonly kind: "already_running";
|
|
115
|
+
readonly pid: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type StartOutcome = Instance | AlreadyRunning;
|
|
119
|
+
|
|
120
|
+
export function isRunning(outcome: StartOutcome): outcome is Instance {
|
|
121
|
+
return outcome instanceof Instance;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Start one instance, in the order of §8.3.
|
|
125
|
+
*
|
|
126
|
+
* The order is the point of this function: the lock before anything is
|
|
127
|
+
* created, the config before anything is derived from it, the pid before the
|
|
128
|
+
* listeners so a file naming this process exists by the time anything can
|
|
129
|
+
* connect to it, and the dial last. */
|
|
130
|
+
export async function start(options: StartOptions = {}): Promise<StartOutcome> {
|
|
131
|
+
// 1. paths, and the directory the rest of them live in
|
|
132
|
+
const env = options.env ?? process.env;
|
|
133
|
+
const paths = resolvePaths(env);
|
|
134
|
+
mkdirSync(paths.stateDir, { recursive: true });
|
|
135
|
+
|
|
136
|
+
// 2. the single instance. A previous run's file with nobody behind it is
|
|
137
|
+
// taken over inside `acquireLock`; a live holder ends the start here.
|
|
138
|
+
const lock = acquireLock(paths.lockFile);
|
|
139
|
+
if (!isHeldByUs(lock)) {
|
|
140
|
+
return { kind: "already_running", pid: (lock as Held).pid };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const log = new Log(paths.logFile, options.echoLog ?? true);
|
|
144
|
+
try {
|
|
145
|
+
// 3. the config. A broken one ends the start rather than turning the
|
|
146
|
+
// setting it carried silently off (DV-Q9).
|
|
147
|
+
const config = loadConfig(paths.configFile, paths.configHome);
|
|
148
|
+
// What the config says of the gateway, resolved before anything is built
|
|
149
|
+
// from it: a webhook source whose secret cannot be read ends the start
|
|
150
|
+
// here, for the same reason a broken config does (DV-Q9).
|
|
151
|
+
const gateway = gatewaySetup(config.upstream, paths.configFile, env);
|
|
152
|
+
// The translation helper, checked the same way and for the same reason: a
|
|
153
|
+
// program that was named and cannot be run is a setting that cannot be
|
|
154
|
+
// honoured (DV-Q9).
|
|
155
|
+
const helper = translateSetup(config.upstream, paths.configFile);
|
|
156
|
+
// 4. this instance's identity, written the first time it is asked for.
|
|
157
|
+
//
|
|
158
|
+
// Before anything derived from it: `mid`, the store's keys and `last_live`
|
|
159
|
+
// are all keyed by it, and a config home started here for the first time —
|
|
160
|
+
// `daemon run` on one the shared file does not list — gets its id now
|
|
161
|
+
// rather than from an `add` that never happened (DR-0001 §2.1).
|
|
162
|
+
const id = instanceIdentity(paths.instanceIdFile);
|
|
163
|
+
// 5. the endpoint list, for an instance that has a mesh.
|
|
164
|
+
//
|
|
165
|
+
// `self` is configured, so nothing has to be settled; what the probe does
|
|
166
|
+
// is check it, and it has to arrive at a listener — so the WebSocket is
|
|
167
|
+
// bound here and handed to the instance. A `self` that answers as somebody
|
|
168
|
+
// else ends the start.
|
|
169
|
+
const mesh = meshFor(id, config, log, options.meshTiming);
|
|
170
|
+
const wiring = mesh === undefined ? undefined : await bindForMesh(config, mesh);
|
|
171
|
+
// 6-8 are the instance's own construction and listen.
|
|
172
|
+
const instance = new Instance(
|
|
173
|
+
paths,
|
|
174
|
+
config,
|
|
175
|
+
id,
|
|
176
|
+
lock,
|
|
177
|
+
log,
|
|
178
|
+
options.pollMs,
|
|
179
|
+
gateway,
|
|
180
|
+
helper,
|
|
181
|
+
wiring,
|
|
182
|
+
);
|
|
183
|
+
wiring?.attach(instance);
|
|
184
|
+
await instance.listen();
|
|
185
|
+
return instance;
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
log.write("startup failed", { error: String(cause) });
|
|
188
|
+
lock.release();
|
|
189
|
+
throw cause;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The mesh, on an instance configured for one.
|
|
194
|
+
*
|
|
195
|
+
* Two things have to be true: peers to dial, and an address they can dial back.
|
|
196
|
+
* An instance serving only the unix socket is reachable by nothing on another
|
|
197
|
+
* host, so a peer list on one is a setting with no effect rather than a mesh. */
|
|
198
|
+
function meshFor(
|
|
199
|
+
id: InstanceId,
|
|
200
|
+
config: InstanceConfig,
|
|
201
|
+
log: Log,
|
|
202
|
+
timing?: MeshTiming,
|
|
203
|
+
): Mesh | undefined {
|
|
204
|
+
if (config.peers.length === 0 || config.entry === undefined || config.self === undefined) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
return new Mesh({
|
|
208
|
+
id,
|
|
209
|
+
self: config.self,
|
|
210
|
+
peers: config.peers,
|
|
211
|
+
conns: new ConnRegistry(),
|
|
212
|
+
log: (msg, fields) => {
|
|
213
|
+
log.write(msg, fields);
|
|
214
|
+
},
|
|
215
|
+
...timing,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** What an instance is handed when its listener had to exist before it did.
|
|
220
|
+
*
|
|
221
|
+
* The connection registry is shared rather than copied: a connection accepted
|
|
222
|
+
* during self-identification is one of the instance's, and two registries would
|
|
223
|
+
* mean the stop order (§8.5 step 3) reaching only one of them. */
|
|
224
|
+
export interface MeshWiring {
|
|
225
|
+
readonly conns: ConnRegistry;
|
|
226
|
+
readonly ws: Listener;
|
|
227
|
+
readonly mesh: Mesh;
|
|
228
|
+
/** Point the listener at the instance, once there is one. */
|
|
229
|
+
attach(instance: Instance): void;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Bind the WebSocket, settle `self` against the peer list, and hand both on.
|
|
233
|
+
*
|
|
234
|
+
* The listener answers the two pre-authentication routes from the moment it is
|
|
235
|
+
* up — the probe of self-identification and the key of mesh-peer-auth §6 — and
|
|
236
|
+
* refuses everything else until the instance exists, which is a window of one
|
|
237
|
+
* round of probes. */
|
|
238
|
+
async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiring> {
|
|
239
|
+
const entry = config.entry as EntryConfig;
|
|
240
|
+
let instance: Instance | undefined;
|
|
241
|
+
const ws = serveWs({
|
|
242
|
+
hostname: entry.host,
|
|
243
|
+
port: entry.port,
|
|
244
|
+
conns: mesh.conns,
|
|
245
|
+
handle: (frame, conn) =>
|
|
246
|
+
instance === undefined
|
|
247
|
+
? Promise.resolve(failure(undefined, "internal_error", "this instance is still starting"))
|
|
248
|
+
: instance.handle(frame, conn),
|
|
249
|
+
entry: entryPolicy(config, true),
|
|
250
|
+
route: async (request) => (await mesh.route(request)) ?? (await instance?.route(request)),
|
|
251
|
+
onConn: (conn, info) => {
|
|
252
|
+
mesh.accept(conn, info);
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
try {
|
|
256
|
+
await mesh.verify();
|
|
257
|
+
} catch (cause) {
|
|
258
|
+
// The listener is bound before the endpoint list is checked, so it is this
|
|
259
|
+
// function's to release when the check refuses — nothing else holds it yet,
|
|
260
|
+
// and a port left bound by a refused start is one the next start cannot
|
|
261
|
+
// have.
|
|
262
|
+
await ws.close();
|
|
263
|
+
throw cause;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
conns: mesh.conns,
|
|
267
|
+
ws,
|
|
268
|
+
mesh,
|
|
269
|
+
attach(built: Instance): void {
|
|
270
|
+
instance = built;
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** One running instance: the layers wired together, and the two lifecycle
|
|
276
|
+
* orders of §8.3 and §8.5. */
|
|
277
|
+
export class Instance {
|
|
278
|
+
readonly startedAt: Timestamp = Date.now();
|
|
279
|
+
readonly #conns: ConnRegistry;
|
|
280
|
+
/** The mesh, on an instance configured for one (§7). */
|
|
281
|
+
readonly #mesh: Mesh | undefined;
|
|
282
|
+
/** The WebSocket listener, when it had to be bound before this instance
|
|
283
|
+
* existed so that self-identification could reach it. */
|
|
284
|
+
readonly #boundWs: Listener | undefined;
|
|
285
|
+
readonly #transport = new Transport();
|
|
286
|
+
readonly #topics: Topics;
|
|
287
|
+
readonly #sessions: Sessions;
|
|
288
|
+
readonly #status: SessionStatus;
|
|
289
|
+
readonly #transcripts: Transcripts;
|
|
290
|
+
readonly #gateway: Gateway;
|
|
291
|
+
readonly #delivery: Delivery;
|
|
292
|
+
readonly #direct: DirectRoute;
|
|
293
|
+
readonly #notify: Notify;
|
|
294
|
+
readonly #translate: Translate | undefined;
|
|
295
|
+
readonly #handlers: Handlers;
|
|
296
|
+
readonly #capabilities: ReadonlySet<Capability>;
|
|
297
|
+
/** Set the moment shutdown starts, which is the re-entry guard of §8.5 step
|
|
298
|
+
* 1: a request arriving after it is refused rather than half-served. */
|
|
299
|
+
#stopping = false;
|
|
300
|
+
#stopped: Promise<void> | undefined;
|
|
301
|
+
/** The link state the last `net_online` announced, so the event marks a
|
|
302
|
+
* change rather than repeating what every client already holds. */
|
|
303
|
+
#announced: boolean | undefined;
|
|
304
|
+
/** Resolved once the stop order has run to the end, so a foreground run has
|
|
305
|
+
* something to wait on that does not depend on what asked it to stop. */
|
|
306
|
+
readonly #done = Promise.withResolvers<void>();
|
|
307
|
+
|
|
308
|
+
constructor(
|
|
309
|
+
readonly paths: InstancePaths,
|
|
310
|
+
readonly config: InstanceConfig,
|
|
311
|
+
/** What this instance is called, everywhere and to everyone (DR-0001
|
|
312
|
+
* §2.1). Read from the state directory, so it survives the instance moving
|
|
313
|
+
* to another host or another URL. */
|
|
314
|
+
readonly self: InstanceId,
|
|
315
|
+
private readonly lock: Lock,
|
|
316
|
+
private readonly log: Log,
|
|
317
|
+
pollMs?: number,
|
|
318
|
+
setup: GatewaySetup = {},
|
|
319
|
+
helper?: string,
|
|
320
|
+
wiring?: MeshWiring,
|
|
321
|
+
) {
|
|
322
|
+
this.#conns = wiring?.conns ?? new ConnRegistry();
|
|
323
|
+
this.#mesh = wiring?.mesh;
|
|
324
|
+
this.#boundWs = wiring?.ws;
|
|
325
|
+
// Every capability rests on an upstream, so what is configured is what
|
|
326
|
+
// this instance can name. A client is told before it subscribes, rather
|
|
327
|
+
// than being refused when it does.
|
|
328
|
+
this.#capabilities = new Set([
|
|
329
|
+
...gatewayCapabilities(setup),
|
|
330
|
+
...sandboxCapabilities(config.upstream.sandbox_origin),
|
|
331
|
+
...launcherCapabilities(config.upstream.launcher),
|
|
332
|
+
...translateCapabilities(helper),
|
|
333
|
+
...sessionCapabilities({
|
|
334
|
+
fork_origin: config.fork_origin,
|
|
335
|
+
...(config.upstream.terminal_gateway === undefined
|
|
336
|
+
? {}
|
|
337
|
+
: { terminal_gateway: config.upstream.terminal_gateway }),
|
|
338
|
+
}),
|
|
339
|
+
]);
|
|
340
|
+
// The mesh is the rest of the cluster as the topic mechanism sees it: what
|
|
341
|
+
// the peers have stated, and where a local subscription has to travel to
|
|
342
|
+
// (§7.4). An instance without one has no other instance to hear from.
|
|
343
|
+
this.#topics = new Topics(this.self, this.#capabilities, this.#mesh);
|
|
344
|
+
this.#mesh?.bind({
|
|
345
|
+
handle: (frame, conn) => this.handle(frame, conn),
|
|
346
|
+
publish: (topic, data, instance) => {
|
|
347
|
+
this.#topics.publish(topic, data, instance);
|
|
348
|
+
},
|
|
349
|
+
// What `peers` says about the instances is this instance's own view, so
|
|
350
|
+
// it is restated when that view moves (§7.5).
|
|
351
|
+
changed: () => {
|
|
352
|
+
this.#sessions.refresh();
|
|
353
|
+
this.#linkMoved();
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// What the gateway saw. It feeds two topics and one input of the sessions
|
|
358
|
+
// domain (§5.1), so it is built before both.
|
|
359
|
+
this.#gateway = new Gateway({
|
|
360
|
+
self: this.self,
|
|
361
|
+
setup,
|
|
362
|
+
publish: (topic, data) => {
|
|
363
|
+
this.#topics.publish(topic, data);
|
|
364
|
+
},
|
|
365
|
+
onActivity: () => {
|
|
366
|
+
this.#sessions.refresh();
|
|
367
|
+
},
|
|
368
|
+
log: (msg, fields) => {
|
|
369
|
+
this.log.write(msg, fields);
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// The transcript tails and their folds. Built before the sessions domain
|
|
374
|
+
// and reading from it lazily: the fold is one of the sessions domain's
|
|
375
|
+
// inputs (§5.1) while the path to follow is one of its outputs, and the
|
|
376
|
+
// two meet at the moment a tail starts rather than at construction.
|
|
377
|
+
this.#transcripts = new Transcripts({
|
|
378
|
+
self: this.self,
|
|
379
|
+
pathOf: (sid) => this.#sessions.transcriptPath(sid),
|
|
380
|
+
publish: (topic, data) => {
|
|
381
|
+
this.#topics.publish(topic, data);
|
|
382
|
+
},
|
|
383
|
+
onFacts: () => {
|
|
384
|
+
this.#sessions.refresh();
|
|
385
|
+
},
|
|
386
|
+
...(pollMs === undefined ? {} : { pollMs }),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// 6. `last_live` and the inbox, read as the domains are constructed.
|
|
390
|
+
this.#sessions = new Sessions({
|
|
391
|
+
self: this.self,
|
|
392
|
+
endpoint: selfEndpoint(config),
|
|
393
|
+
configHome: paths.configHome,
|
|
394
|
+
stateDir: paths.stateDir,
|
|
395
|
+
capabilities: [...this.#capabilities],
|
|
396
|
+
version: VERSION,
|
|
397
|
+
startedAt: this.startedAt,
|
|
398
|
+
publish: (topic, data) => {
|
|
399
|
+
this.#topics.publish(topic, data);
|
|
400
|
+
},
|
|
401
|
+
transcript: this.#transcripts,
|
|
402
|
+
gateway: this.#gateway,
|
|
403
|
+
terminals: hostTerminalReader(),
|
|
404
|
+
...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
|
|
405
|
+
onChanged: () => {
|
|
406
|
+
this.#status.refresh();
|
|
407
|
+
// A session that is live again is one route (a) can be tried against,
|
|
408
|
+
// which is what the inbox is waiting for (§4.3).
|
|
409
|
+
void this.#delivery.retry();
|
|
410
|
+
},
|
|
411
|
+
...(pollMs === undefined ? {} : { pollMs }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// The topics whose value is the fold's error state, over the sessions the
|
|
415
|
+
// instance holds. They are the other thing that keeps a tail running: a
|
|
416
|
+
// subscriber watching the list of stopped sessions is watching every
|
|
417
|
+
// session's fold, and the tails behind it run only while it does (§6.3).
|
|
418
|
+
this.#status = new SessionStatus({
|
|
419
|
+
self: this.self,
|
|
420
|
+
sessions: () => this.#sessions.connectedSids(),
|
|
421
|
+
facts: (sid) => this.#transcripts.facts(sid),
|
|
422
|
+
where: (sid) => this.#sessions.where(sid),
|
|
423
|
+
hold: (sid) => {
|
|
424
|
+
this.#transcripts.hold(sid);
|
|
425
|
+
},
|
|
426
|
+
release: (sid) => {
|
|
427
|
+
this.#transcripts.release(sid);
|
|
428
|
+
},
|
|
429
|
+
publish: (topic, data) => {
|
|
430
|
+
this.#topics.publish(topic, data);
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const inbox = new Inbox(inboxPath(paths.stateDir));
|
|
435
|
+
inbox.load();
|
|
436
|
+
this.#direct = config.direct_delivery
|
|
437
|
+
? new ClaudeCodeSocketRoute({ configHome: paths.configHome })
|
|
438
|
+
: new DisabledDirectRoute();
|
|
439
|
+
this.#delivery = new Delivery({
|
|
440
|
+
self: this.self,
|
|
441
|
+
sessions: this.#sessions,
|
|
442
|
+
...(this.#mesh === undefined ? {} : { cluster: this.#mesh }),
|
|
443
|
+
inbox,
|
|
444
|
+
direct: this.#direct,
|
|
445
|
+
publish: (topic, data, instance, to) => {
|
|
446
|
+
this.#topics.publish(topic, data, instance, to);
|
|
447
|
+
},
|
|
448
|
+
listeners: (topic, to) => this.#topics.subscriberCount(topic, to),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
this.#notify = new Notify({
|
|
452
|
+
self: this.self,
|
|
453
|
+
label: (sid) => sessionLabel(this.#sessions, sid),
|
|
454
|
+
publish: (topic, data, instance) => {
|
|
455
|
+
this.#topics.publish(topic, data, instance);
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
this.#topics.attach("peers", this.#sessions);
|
|
460
|
+
this.#topics.attach("agents", this.#sessions);
|
|
461
|
+
this.#topics.attach("inbox", this.#delivery);
|
|
462
|
+
this.#topics.attach("notify", this.#notify);
|
|
463
|
+
this.#topics.attach("transcript", this.#transcripts);
|
|
464
|
+
this.#topics.attach("session_status", this.#status);
|
|
465
|
+
this.#topics.attach("session_errors", this.#status);
|
|
466
|
+
this.#topics.attach("llm_requests", this.#gateway.requests);
|
|
467
|
+
this.#topics.attach("llm_status", this.#gateway.statusResource);
|
|
468
|
+
|
|
469
|
+
// The one thing here that is written down and is nobody's derived value
|
|
470
|
+
// (§3.6): what a person saved through a client, which no other party holds
|
|
471
|
+
// a copy of. It owns `kv:<ns>` and is the only publisher of it.
|
|
472
|
+
const kv = new KvStore(join(paths.stateDir, KV_DIR), this.self, (topic, data) => {
|
|
473
|
+
this.#topics.publish(topic, data);
|
|
474
|
+
});
|
|
475
|
+
this.#topics.attach("kv", kv);
|
|
476
|
+
|
|
477
|
+
// The upstreams that answer a question rather than hold a value. Each is
|
|
478
|
+
// built only where its config named one, and dispatch has already refused
|
|
479
|
+
// the ops for the capability this instance then does not have.
|
|
480
|
+
const launcher =
|
|
481
|
+
config.upstream.launcher === undefined ? undefined : new Launcher(config.upstream.launcher);
|
|
482
|
+
this.#translate = helper === undefined ? undefined : new Translate(helper);
|
|
483
|
+
|
|
484
|
+
// The one decision every file op starts from. The three allowlists it reads
|
|
485
|
+
// are the session's own facts, gathered from where each is stated: the
|
|
486
|
+
// greeting says where the session works, and the fold says which folders
|
|
487
|
+
// its editor names and which files outside them its transcript named.
|
|
488
|
+
const files = new Containment({
|
|
489
|
+
roots: (sid): SessionRoots | undefined => {
|
|
490
|
+
const where = this.#sessions.where(sid);
|
|
491
|
+
if (where.root === undefined && where.cwd === undefined) return undefined;
|
|
492
|
+
const status = sessionStatusOf(sid, this.#transcripts.facts(sid), where);
|
|
493
|
+
return {
|
|
494
|
+
...where,
|
|
495
|
+
workspace_folders: status.workspace_folders.map((folder) => folder.path),
|
|
496
|
+
external_files: status.external_files.map((file) => file.path),
|
|
497
|
+
};
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
const origin = config.upstream.sandbox_origin;
|
|
501
|
+
|
|
502
|
+
// Which transcript an op means, for the ops that read one rather than
|
|
503
|
+
// follow one. Only this instance's config home is ever looked in (M6): a
|
|
504
|
+
// session that greeted said where its transcript is, and one that never
|
|
505
|
+
// greeted is looked for under that home and nowhere else.
|
|
506
|
+
const transcriptFiles = new TranscriptFiles({
|
|
507
|
+
configHome: paths.configHome,
|
|
508
|
+
announced: (sid) => this.#sessions.transcriptPath(sid),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
this.#handlers = completeHandlers({
|
|
512
|
+
hello: this.#sessions.hello,
|
|
513
|
+
session_stopping: this.#sessions.stopping,
|
|
514
|
+
...topicHandlers(this.#topics),
|
|
515
|
+
...messagingHandlers(this.#delivery, this.#notify),
|
|
516
|
+
...fileHandlers(files),
|
|
517
|
+
...sessionHandlers({
|
|
518
|
+
self: this.self,
|
|
519
|
+
configHome: paths.configHome,
|
|
520
|
+
stateDir: paths.stateDir,
|
|
521
|
+
files: transcriptFiles,
|
|
522
|
+
processes: new SessionProcesses(
|
|
523
|
+
hostProcessDeps(() => this.#sessions.rowsNow(), config.upstream.terminal_gateway),
|
|
524
|
+
),
|
|
525
|
+
forget: (sid) => this.#sessions.forget(sid),
|
|
526
|
+
}),
|
|
527
|
+
// The sandbox ops answer only where an origin is configured. Without one
|
|
528
|
+
// there is nothing to serve a minted URL, and dispatch already refuses
|
|
529
|
+
// them for the capability this instance then does not have.
|
|
530
|
+
...(origin === undefined ? {} : sandboxHandlers(new SandboxGrants(files, origin))),
|
|
531
|
+
...(launcher === undefined ? {} : launcherHandlers(launcher)),
|
|
532
|
+
...(this.#translate === undefined ? {} : translateHandlers(this.#translate)),
|
|
533
|
+
...gatewayHandlers(setup),
|
|
534
|
+
...kvHandlers(kv),
|
|
535
|
+
instance_ping: (): InstancePingResult => this.ping(),
|
|
536
|
+
instance_shutdown: () => {
|
|
537
|
+
// The reply goes out when this handler's value reaches the driver, so
|
|
538
|
+
// stopping is deferred past that turn of the loop rather than run
|
|
539
|
+
// here — the caller is told the request was accepted, which is what
|
|
540
|
+
// the contract says this op answers.
|
|
541
|
+
setTimeout(() => void this.stop(), 0);
|
|
542
|
+
return {};
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 7-8 of §8.3: the pid, then the listeners with the unix socket first, then
|
|
548
|
+
* the peers. */
|
|
549
|
+
async listen(): Promise<void> {
|
|
550
|
+
// Before any listener: a client that can connect can always find the
|
|
551
|
+
// process behind the socket.
|
|
552
|
+
writeFileSync(this.paths.pidFile, `${process.pid}\n`);
|
|
553
|
+
prepareSocketDir(this.paths);
|
|
554
|
+
// What a killed run left behind, cleared before this one adds its own.
|
|
555
|
+
sweepOrphanSockets(this.paths);
|
|
556
|
+
this.#transport.add(
|
|
557
|
+
listenUds({
|
|
558
|
+
path: this.paths.socketReal,
|
|
559
|
+
conns: this.#conns,
|
|
560
|
+
handle: (frame, conn) => this.handle(frame, conn),
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
// The address clients use, moved onto this process once it is accepting.
|
|
564
|
+
publishSocket(this.paths);
|
|
565
|
+
if (this.#boundWs !== undefined) {
|
|
566
|
+
// Already listening: it had to be, for self-identification to reach it.
|
|
567
|
+
this.#transport.add(this.#boundWs);
|
|
568
|
+
} else if (this.config.entry !== undefined) {
|
|
569
|
+
this.#transport.add(
|
|
570
|
+
serveWs({
|
|
571
|
+
hostname: this.config.entry.host,
|
|
572
|
+
port: this.config.entry.port,
|
|
573
|
+
conns: this.#conns,
|
|
574
|
+
handle: (frame, conn) => this.handle(frame, conn),
|
|
575
|
+
entry: entryPolicy(this.config, false),
|
|
576
|
+
// The gateway posts to the address this instance already serves,
|
|
577
|
+
// behind the same entry check (§3.1).
|
|
578
|
+
route: (request) => this.route(request),
|
|
579
|
+
}),
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
// 8. the peers. Every instance dials every one of them, and one that is not
|
|
583
|
+
// there is retried rather than waited for (§7.2).
|
|
584
|
+
this.#mesh?.connect();
|
|
585
|
+
await Promise.resolve();
|
|
586
|
+
this.log.write("started", {
|
|
587
|
+
instance: this.self,
|
|
588
|
+
pid: process.pid,
|
|
589
|
+
socket: this.paths.socket,
|
|
590
|
+
http: this.http,
|
|
591
|
+
peers: this.config.peers.length,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** An HTTP request on the WebSocket's listener that is not the upgrade. The
|
|
596
|
+
* gateway's webhook is the one such route this instance answers itself; the
|
|
597
|
+
* mesh's two are answered before this is asked, because they are served
|
|
598
|
+
* before anything is proven and this instance's own routes are not. */
|
|
599
|
+
route(request: Request): Promise<Response | undefined> {
|
|
600
|
+
return this.#gateway.route(request);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Every bound WebSocket address, as `host:port`. */
|
|
604
|
+
get http(): string[] {
|
|
605
|
+
return this.#transport.listeners.filter((l) => l.kind === "ws").map((l) => l.address);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
get socketPath(): string {
|
|
609
|
+
return this.paths.socket;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The mesh, on an instance that has one.
|
|
613
|
+
*
|
|
614
|
+
* Which peers are reachable is a fact about this instance that no op carries
|
|
615
|
+
* on its own — `hello` states it to a client, and this is where it is
|
|
616
|
+
* observable from inside the process, as `watching` is for the sessions
|
|
617
|
+
* watch. */
|
|
618
|
+
get mesh(): Mesh | undefined {
|
|
619
|
+
return this.#mesh;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Whether the sessions watch is running. It is driven by subscription
|
|
623
|
+
* (§6.3), so this is how "the upstream watches stopped" is observable from
|
|
624
|
+
* outside the domain that owns them. */
|
|
625
|
+
get watching(): boolean {
|
|
626
|
+
return this.#sessions.watching;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** When the gateway last saw inference for a session (§5.1).
|
|
630
|
+
*
|
|
631
|
+
* The one input of the classification that arrives from outside this host,
|
|
632
|
+
* and the only place it is observable from: it is an attribute of a row
|
|
633
|
+
* rather than a state (§5.2), so nothing on the wire carries it yet. */
|
|
634
|
+
gatewayActiveAt(sid: Sid): Timestamp | undefined {
|
|
635
|
+
return this.#gateway.activeAt(sid);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
ping(): InstancePingResult {
|
|
639
|
+
return {
|
|
640
|
+
instance: this.self,
|
|
641
|
+
version: VERSION,
|
|
642
|
+
pid: process.pid,
|
|
643
|
+
started_at: this.startedAt,
|
|
644
|
+
clients: this.#conns.size,
|
|
645
|
+
exe: process.execPath,
|
|
646
|
+
...(process.argv[1] === undefined ? {} : { script: process.argv[1] }),
|
|
647
|
+
http: this.http,
|
|
648
|
+
network: this.network,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** What this instance can say about the host link.
|
|
653
|
+
*
|
|
654
|
+
* The mesh is the only thing here that reaches off the host, so it is what
|
|
655
|
+
* the answer is read from: a peer that answers is the link working, and
|
|
656
|
+
* every configured peer silent at once is the link gone. Nothing else is
|
|
657
|
+
* probed — an instance does not dial the internet to have an opinion about
|
|
658
|
+
* it, and the peers are already being dialled for their own reasons (§8.3).
|
|
659
|
+
*
|
|
660
|
+
* Two cases state no verdict rather than guessing one. `off` is an instance
|
|
661
|
+
* with no mesh: nothing here watches the link at all. `unknown` is a mesh
|
|
662
|
+
* whose peer list holds nobody but ourselves — the link is watched, and no
|
|
663
|
+
* observation of it can be made. */
|
|
664
|
+
get network(): InstancePingResult["network"] {
|
|
665
|
+
if (this.#mesh === undefined) return "off";
|
|
666
|
+
const peers = this.#mesh.peers;
|
|
667
|
+
if (peers.length === 0) return "unknown";
|
|
668
|
+
return peers.some((peer) => this.#mesh?.reachable(peer) === true) ? "online" : "offline";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** A link came up or went down. Told to every client when it changes what
|
|
672
|
+
* the instance would answer about the host link, and to nobody when the set
|
|
673
|
+
* of reachable peers moved without changing that — a five-peer cluster
|
|
674
|
+
* losing one is not this host going offline. */
|
|
675
|
+
#linkMoved(): void {
|
|
676
|
+
const network = this.network;
|
|
677
|
+
if (network !== "online" && network !== "offline") return;
|
|
678
|
+
const online = network === "online";
|
|
679
|
+
if (this.#announced === online) return;
|
|
680
|
+
this.#announced = online;
|
|
681
|
+
const event: NetOnlineEvent = { ev: "net_online", instance: this.self, online };
|
|
682
|
+
for (const conn of this.#conns) conn.send(event);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** One frame, from either transport. The re-entry guard of §8.5 step 1 sits
|
|
686
|
+
* here because this is the single door every request comes through. */
|
|
687
|
+
async handle(frame: unknown, conn: Requester): Promise<DispatchResult> {
|
|
688
|
+
if (this.#stopping) {
|
|
689
|
+
return failure(requestIdOf(frame), "bad_request", `${this.self} is shutting down`);
|
|
690
|
+
}
|
|
691
|
+
// A frame that is not an op: the mesh handshake's own traffic, which the
|
|
692
|
+
// op vocabulary has no name for (contract, `Plane`). It is taken here
|
|
693
|
+
// because this is the one door, and it decides nothing — the judgement is
|
|
694
|
+
// in the `hello` handler, which is the only thing that settles a peer.
|
|
695
|
+
if (this.#mesh?.frame(conn, frame) === true) {
|
|
696
|
+
return { kind: "none" };
|
|
697
|
+
}
|
|
698
|
+
if (this.#mesh !== undefined) {
|
|
699
|
+
// Mid-handshake, an ordinary request is a protocol violation rather than
|
|
700
|
+
// an early call: the peer must send nothing before the acknowledgement,
|
|
701
|
+
// and one that does is dropped rather than buffered (mesh-peer-auth §5.8).
|
|
702
|
+
if (this.#mesh.handshaking(conn)) {
|
|
703
|
+
conn.close();
|
|
704
|
+
return failure(
|
|
705
|
+
requestIdOf(frame),
|
|
706
|
+
"bad_request",
|
|
707
|
+
"a peer waits for its acknowledgement before it speaks",
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
// Let in as a peer rather than on the entry token, and still unproven:
|
|
711
|
+
// the greeting is the one thing it was admitted to make.
|
|
712
|
+
if (this.#mesh.unproven(conn) && opOf(frame) !== "hello") {
|
|
713
|
+
conn.close();
|
|
714
|
+
return failure(
|
|
715
|
+
requestIdOf(frame),
|
|
716
|
+
"hello_required",
|
|
717
|
+
"a peer connection greets before anything else",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// What `last_activity_at` means on the `peers` row: the most recent request
|
|
722
|
+
// on any of the session's connections. Here, because this is the one door
|
|
723
|
+
// every request comes through, and a session with several connections has
|
|
724
|
+
// one row for all of them.
|
|
725
|
+
const identity = conn.identity;
|
|
726
|
+
if (identity.state === "settled" && identity.sid !== undefined) {
|
|
727
|
+
this.#sessions.touch(identity.sid);
|
|
728
|
+
}
|
|
729
|
+
// Who this request runs as. On a peer's link it is the caller the envelope
|
|
730
|
+
// names, believed because the link is authenticated and refused as a
|
|
731
|
+
// malformed request when the two fields disagree; on every other
|
|
732
|
+
// connection it is the connection's own identity, and a `caller` written
|
|
733
|
+
// there is a field the sender does not get to fill in.
|
|
734
|
+
const fromPeer = this.#mesh?.isLink(conn) === true;
|
|
735
|
+
const stated = fromPeer ? callerOf(frame) : undefined;
|
|
736
|
+
if (stated instanceof CallerError) {
|
|
737
|
+
return failure(requestIdOf(frame), "bad_request", stated.message);
|
|
738
|
+
}
|
|
739
|
+
const decided = await dispatch(frame, this.#mesh?.caller(conn, stated) ?? conn, {
|
|
740
|
+
self: this.self,
|
|
741
|
+
capabilities: this.#capabilities,
|
|
742
|
+
resolveInstance: (_op, fields) => this.#owner(fields),
|
|
743
|
+
handlers: this.#handlers,
|
|
744
|
+
});
|
|
745
|
+
if (decided.kind !== "forward") return decided;
|
|
746
|
+
// The op belongs to another instance. Mesh carries it and brings the
|
|
747
|
+
// answer back under the id the caller used (§7.3); without a mesh there is
|
|
748
|
+
// nothing that can reach it, which the driver names.
|
|
749
|
+
//
|
|
750
|
+
// Who it is forwarded as is stated here rather than copied from the
|
|
751
|
+
// request: a caller that came over a peer's link travels on unchanged, and
|
|
752
|
+
// anyone else is named from the connection they are actually on.
|
|
753
|
+
return this.#mesh === undefined
|
|
754
|
+
? decided
|
|
755
|
+
: await this.#mesh.forward(decided.to, decided.frame, stated ?? callerOfIdentity(identity));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Which instance owns the subject of an instance-local op.
|
|
759
|
+
*
|
|
760
|
+
* The subject is the session an op names, and an op that names none is about
|
|
761
|
+
* this instance and stays here. A session this instance holds is its own
|
|
762
|
+
* whatever the cluster last said; one it does not hold is looked for in the
|
|
763
|
+
* routing table the `peers` topic is (§7.3). */
|
|
764
|
+
#owner(fields: Record<string, unknown>): InstanceId | undefined {
|
|
765
|
+
const sid = fields["sid"];
|
|
766
|
+
if (typeof sid !== "string" || this.#mesh === undefined) return undefined;
|
|
767
|
+
if (this.#sessions.classify(sid as Sid) !== undefined) return undefined;
|
|
768
|
+
return this.#mesh.ownerOf(sid as Sid);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Stop, in the order of §8.5. Repeating it waits for the first one. */
|
|
772
|
+
stop(): Promise<void> {
|
|
773
|
+
this.#stopped ??= this.#stop().finally(() => {
|
|
774
|
+
this.#done.resolve();
|
|
775
|
+
});
|
|
776
|
+
return this.#stopped;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Resolves when this instance has finished leaving, however that was
|
|
780
|
+
* asked for — the op, a signal, or a direct call. */
|
|
781
|
+
whenStopped(): Promise<void> {
|
|
782
|
+
return this.#done.promise;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async #stop(): Promise<void> {
|
|
786
|
+
// 1. refuse new work
|
|
787
|
+
this.#stopping = true;
|
|
788
|
+
// 2. stop the upstream watches. They run only while something is
|
|
789
|
+
// subscribed (§6.3), so dropping the subscriptions is what stops them.
|
|
790
|
+
for (const conn of this.#conns) this.#topics.dropAll(conn);
|
|
791
|
+
// A tail may also be held for a value this instance states rather than for
|
|
792
|
+
// a subscriber, and those holds end here.
|
|
793
|
+
this.#transcripts.stopAll();
|
|
794
|
+
// The read the gateway's own events can ask for is one more thing that
|
|
795
|
+
// outlives its subscribers if nothing drops it here.
|
|
796
|
+
this.#gateway.close();
|
|
797
|
+
// The translation helper is a process this instance started, so it leaves
|
|
798
|
+
// with it rather than outliving the daemon that has its pipe.
|
|
799
|
+
this.#translate?.stop();
|
|
800
|
+
// Route (a) holds a socket of its own, bound where the sessions' sockets
|
|
801
|
+
// are so their receipts can reach it (§4.1). It has a name on disk, so it
|
|
802
|
+
// is taken down here rather than left for the next run to find.
|
|
803
|
+
this.#direct.close();
|
|
804
|
+
// The mesh's links and its timers, let go here for the same reason: they
|
|
805
|
+
// are this instance's and do not outlive it (§7).
|
|
806
|
+
this.#mesh?.stop();
|
|
807
|
+
// 3. tell the connections, while they can still be told
|
|
808
|
+
const restarting: RestartingEvent = { ev: "restarting", instance: this.self };
|
|
809
|
+
for (const conn of this.#conns) conn.send(restarting);
|
|
810
|
+
// 4. settle what is persisted. `last_live` and the inbox are written as
|
|
811
|
+
// they change rather than at exit, so there is nothing held back to flush;
|
|
812
|
+
// the log's writer is synchronous for the same reason (§3.6).
|
|
813
|
+
this.log.write("stopping", { instance: this.self });
|
|
814
|
+
// 5. let the resources go, the unix socket last. The pid and the lock go
|
|
815
|
+
// before it, because a client reads a refusing socket as this instance
|
|
816
|
+
// having finished leaving and a successor may claim what it sees free.
|
|
817
|
+
remove(this.paths.pidFile);
|
|
818
|
+
this.lock.release();
|
|
819
|
+
// Closing takes the path this process bound, and only that one: the stable
|
|
820
|
+
// address is a symlink nothing here touches, because a successor may have
|
|
821
|
+
// already pointed it at itself (§8.5).
|
|
822
|
+
await this.#transport.close();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Where this instance says it is reached, or nothing when it is reached by
|
|
827
|
+
* no URL at all.
|
|
828
|
+
*
|
|
829
|
+
* The config's `self` when there is one, which is the answer for anything with
|
|
830
|
+
* a mesh. Without one the bound address stands in, and an instance serving only
|
|
831
|
+
* the unix socket has neither — so it states no endpoint rather than a URL that
|
|
832
|
+
* reaches nothing (DR-0001 §2.1). A client on the unix socket already has the
|
|
833
|
+
* instance it is talking to. */
|
|
834
|
+
export function selfEndpoint(config: InstanceConfig): Endpoint | undefined {
|
|
835
|
+
if (config.self !== undefined) return config.self;
|
|
836
|
+
const entry = config.entry;
|
|
837
|
+
if (entry === undefined || entry.port === 0) return undefined;
|
|
838
|
+
return `ws://${entry.host}:${entry.port}`;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Who may reach the WebSocket at all (§3.1): an Origin the operator named and
|
|
842
|
+
* an address the operator named.
|
|
843
|
+
*
|
|
844
|
+
* The two config lists are read as allowlists in both directions. An empty
|
|
845
|
+
* `origins` admits no browser: a permission that was never granted is not a
|
|
846
|
+
* permission, and the one deployment that would want "any page may connect" is
|
|
847
|
+
* the one that must say so. An empty `source_ips` leaves the addresses to the
|
|
848
|
+
* bind, which for the default loopback host is this machine.
|
|
849
|
+
*
|
|
850
|
+
* These two are the whole of it until the person's own authentication lands
|
|
851
|
+
* (DR-0001): the entry token they replaced only ever restated the uid boundary,
|
|
852
|
+
* which on a tailnet nothing here can cross anyway, and a passkey is what will
|
|
853
|
+
* answer "who came" rather than "could they read a file". */
|
|
854
|
+
function entryPolicy(config: InstanceConfig, mesh: boolean): EntryPolicy {
|
|
855
|
+
const entry = config.entry;
|
|
856
|
+
if (entry === undefined) return {};
|
|
857
|
+
return {
|
|
858
|
+
allowRequest(request: Request, source: string | undefined): boolean {
|
|
859
|
+
const origin = request.headers.get("origin");
|
|
860
|
+
// A request carrying no `Origin` is not a browser's, and there is nothing
|
|
861
|
+
// to compare: it stands or falls on the address and the token below.
|
|
862
|
+
if (origin !== null && !entry.origins.includes(origin)) return false;
|
|
863
|
+
if (entry.source_ips.length === 0) return true;
|
|
864
|
+
// The address the server observed, not one a header claims: a forwarding
|
|
865
|
+
// header is written by whoever is in front of us, and anyone who can
|
|
866
|
+
// reach the port can write it.
|
|
867
|
+
return source !== undefined && entry.source_ips.includes(source);
|
|
868
|
+
},
|
|
869
|
+
allowUpgrade(request: Request): UpgradeDecision {
|
|
870
|
+
const offered = protocolsOf(request);
|
|
871
|
+
// A peer is let through unproven, and what it is is decided by the
|
|
872
|
+
// handshake — the only place a claim can actually be checked.
|
|
873
|
+
if (mesh && offered.includes(MESH_PROTOCOL)) {
|
|
874
|
+
return { ok: true, protocol: MESH_PROTOCOL, mesh: true };
|
|
875
|
+
}
|
|
876
|
+
// The handshake echoes a subprotocol only when one was offered: a browser
|
|
877
|
+
// fails a connection whose reply names none of what it asked for.
|
|
878
|
+
const selected = offered[0];
|
|
879
|
+
return selected === undefined ? { ok: true } : { ok: true, protocol: selected };
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function protocolsOf(request: Request): string[] {
|
|
885
|
+
const header = request.headers.get("sec-websocket-protocol");
|
|
886
|
+
if (header === null) return [];
|
|
887
|
+
return header
|
|
888
|
+
.split(",")
|
|
889
|
+
.map((name) => name.trim())
|
|
890
|
+
.filter((name) => name !== "");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function opOf(frame: unknown): string | undefined {
|
|
894
|
+
if (typeof frame !== "object" || frame === null) return undefined;
|
|
895
|
+
const op = (frame as Record<string, unknown>)["op"];
|
|
896
|
+
return typeof op === "string" ? op : undefined;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function requestIdOf(frame: unknown): string | undefined {
|
|
900
|
+
if (typeof frame !== "object" || frame === null) return undefined;
|
|
901
|
+
const id = (frame as Record<string, unknown>)["request_id"];
|
|
902
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function remove(file: string): void {
|
|
906
|
+
try {
|
|
907
|
+
unlinkSync(file);
|
|
908
|
+
} catch {
|
|
909
|
+
// Already gone.
|
|
910
|
+
}
|
|
911
|
+
}
|