@zgeoff/atc 2.25.0 → 2.26.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/package.json +1 -1
- package/src/agents/gateway-adapter.ts +255 -26
- package/src/client/collect-agent-picks.ts +2 -5
- package/src/daemon/daemon-connection.ts +14 -5
- package/src/daemon/daemon.ts +26 -0
- package/src/daemon/format-log-field.ts +38 -0
- package/src/daemon/make-non-blocking-log.ts +91 -0
- package/src/daemon/refusal-log.ts +163 -0
- package/src/daemon/restore-fleet.ts +1 -0
- package/src/daemon/start-tcp-listener.ts +47 -1
- package/src/protocol/protocol.ts +1 -0
package/package.json
CHANGED
|
@@ -5,11 +5,16 @@ import type { AgentID } from '../shared/agent-id';
|
|
|
5
5
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
6
|
import type { GatewayConfig } from '../shared/collect-gateways';
|
|
7
7
|
import type { Config } from '../shared/config';
|
|
8
|
+
import { isBrokerVariable } from '../shared/is-broker-variable';
|
|
9
|
+
import { isRecord } from '../shared/report';
|
|
10
|
+
import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
|
|
8
11
|
import { toShellArg } from '../shared/to-shell-arg';
|
|
9
12
|
import type {
|
|
10
13
|
AgentAdapter,
|
|
11
14
|
AgentProfile,
|
|
12
15
|
AuthSelection,
|
|
16
|
+
GuestPaths,
|
|
17
|
+
GuestSpawnPlan,
|
|
13
18
|
HeadlessRunner,
|
|
14
19
|
NameUpdate,
|
|
15
20
|
ResumeCheck,
|
|
@@ -17,10 +22,13 @@ import type {
|
|
|
17
22
|
SpawnOptions,
|
|
18
23
|
SpawnPlan,
|
|
19
24
|
} from './agent-adapter';
|
|
25
|
+
import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
20
26
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
27
|
+
import { buildHookSettings } from './build-hook-settings';
|
|
21
28
|
import { buildRestoreModeArgs } from './build-restore-mode-args';
|
|
22
29
|
import { ClaudeAdapter } from './claude-adapter';
|
|
23
30
|
import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
|
|
31
|
+
import { findClaudePermissionMode } from './find-claude-permission-mode';
|
|
24
32
|
import { findFlagValue } from './find-flag-value';
|
|
25
33
|
import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
|
|
26
34
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
@@ -106,19 +114,20 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
106
114
|
});
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
// A gateway whose credential comes through impd's broker
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
117
|
+
// A gateway whose credential comes through impd's broker is refused
|
|
118
|
+
// before anything is prepared for it when its placeholders cannot pair
|
|
119
|
+
// with the broker's header, or when its settings env would route the CLI
|
|
120
|
+
// around the broker.
|
|
113
121
|
findSpawnRefusal(): DaemonError | null {
|
|
114
|
-
|
|
122
|
+
const auth = this.gateway.auth;
|
|
123
|
+
|
|
124
|
+
if (auth === undefined) {
|
|
115
125
|
return null;
|
|
116
126
|
}
|
|
117
127
|
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{ agent: this.id },
|
|
128
|
+
return (
|
|
129
|
+
this.findPlaceholderRefusal(auth.placeholderEnv, auth.profiles) ??
|
|
130
|
+
this.findSettingsEnvConflict()
|
|
122
131
|
);
|
|
123
132
|
}
|
|
124
133
|
|
|
@@ -135,29 +144,101 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
135
144
|
};
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
// A gateway whose credential comes through impd's broker never starts on
|
|
148
|
+
// the daemon's machine: started without the broker, the CLI would send
|
|
149
|
+
// whatever credential it holds to the gateway's host.
|
|
138
150
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
151
|
+
if (this.gateway.auth !== undefined) {
|
|
152
|
+
throw this.buildBrokerRefusal('which only an imp target can give it');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const modeArgs =
|
|
156
|
+
opts.resume === false ? [] : buildRestoreModeArgs(this.gateway.args, this.gateway.settings);
|
|
157
|
+
|
|
139
158
|
return {
|
|
140
159
|
bin: this.gateway.bin,
|
|
141
|
-
args:
|
|
142
|
-
...buildClaudeOverrideArgs(this.gateway.args, opts),
|
|
143
|
-
...(opts.resume === false
|
|
144
|
-
? []
|
|
145
|
-
: buildRestoreModeArgs(this.gateway.args, this.gateway.settings)),
|
|
146
|
-
'--settings',
|
|
147
|
-
this.writeSettings(),
|
|
148
|
-
'--plugin-dir',
|
|
149
|
-
this.writeBridge(),
|
|
150
|
-
...(opts.resume === true ? ['--resume'] : []),
|
|
151
|
-
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
152
|
-
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
153
|
-
],
|
|
160
|
+
args: this.buildArgs(opts, modeArgs, this.writeSettings(), this.writeBridge()),
|
|
154
161
|
};
|
|
155
162
|
}
|
|
156
163
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
// A gateway whose credential comes through impd's broker runs on a
|
|
165
|
+
// remote host with a settings file of the session's own per binding
|
|
166
|
+
// revision, a Claude config folder of its own that holds no account,
|
|
167
|
+
// and placeholders in place of the credential, which the broker swaps
|
|
168
|
+
// for the real one on the host's side. A shell seeds the config folder
|
|
169
|
+
// before it runs the CLI, since a transferred file would replace the
|
|
170
|
+
// state an earlier run left. Any other gateway's credential
|
|
171
|
+
// helper runs on the daemon's machine, so it never runs remotely.
|
|
172
|
+
planGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
|
|
173
|
+
if (this.gateway.auth === undefined) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (guest.auth === undefined) {
|
|
178
|
+
throw this.buildBrokerRefusal('and this session has no broker binding');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (guest.atc === null) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const refusal =
|
|
186
|
+
this.findPlaceholderRefusal(guest.auth.env, this.gateway.auth.profiles) ??
|
|
187
|
+
this.findSettingsEnvConflict();
|
|
188
|
+
|
|
189
|
+
if (refusal !== null) {
|
|
190
|
+
throw refusal;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const argv = [guest.atc];
|
|
194
|
+
const settingsPath = `auth-r${guest.auth.revision}/settings.json`;
|
|
195
|
+
|
|
196
|
+
const bridge = Object.entries(buildATCBridgeFiles(argv)).map(
|
|
197
|
+
([path, content]): [string, string] => [`atc-bridge/${path}`, content],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const settings = buildHookSettings(
|
|
201
|
+
{
|
|
202
|
+
id: this.id,
|
|
203
|
+
env: {
|
|
204
|
+
...this.gateway.env,
|
|
205
|
+
ANTHROPIC_BASE_URL: this.gateway.baseURL,
|
|
206
|
+
...guest.auth.env,
|
|
207
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
208
|
+
},
|
|
209
|
+
...(this.gateway.settings === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { settings: buildSettingsWithoutHelper(this.gateway.settings) }),
|
|
212
|
+
},
|
|
213
|
+
0,
|
|
214
|
+
argv,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const configDir = `${guest.dir}/${CLAUDE_CONFIG_FOLDER}`;
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
bin: 'sh',
|
|
221
|
+
args: [
|
|
222
|
+
'-c',
|
|
223
|
+
SEED_CONFIG_SCRIPT,
|
|
224
|
+
'sh',
|
|
225
|
+
configDir,
|
|
226
|
+
`${guest.dir}/${CONFIG_SEED_FILE}`,
|
|
227
|
+
this.gateway.bin,
|
|
228
|
+
...this.buildArgs(
|
|
229
|
+
opts,
|
|
230
|
+
this.buildGuestModeArgs(),
|
|
231
|
+
`${guest.dir}/${settingsPath}`,
|
|
232
|
+
`${guest.dir}/atc-bridge`,
|
|
233
|
+
),
|
|
234
|
+
],
|
|
235
|
+
files: {
|
|
236
|
+
[settingsPath]: JSON.stringify(settings, null, 2),
|
|
237
|
+
[CONFIG_SEED_FILE]: JSON.stringify(ONBOARDED_CONFIG, null, 2),
|
|
238
|
+
...Object.fromEntries(bridge),
|
|
239
|
+
},
|
|
240
|
+
env: { CLAUDE_CONFIG_DIR: configDir, ...guest.auth.env },
|
|
241
|
+
};
|
|
161
242
|
}
|
|
162
243
|
|
|
163
244
|
normalizeHook(e: HookEvent): AdapterEvent {
|
|
@@ -197,6 +278,117 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
197
278
|
return `cd ${toShellArg(cwd)} && ${this.gateway.bin}${args} --settings ${settings} --resume${resume}`;
|
|
198
279
|
}
|
|
199
280
|
|
|
281
|
+
private buildArgs(
|
|
282
|
+
opts: SpawnOptions,
|
|
283
|
+
modeArgs: readonly string[],
|
|
284
|
+
settings: string,
|
|
285
|
+
pluginDir: string,
|
|
286
|
+
): string[] {
|
|
287
|
+
return [
|
|
288
|
+
...buildClaudeOverrideArgs(this.gateway.args, opts),
|
|
289
|
+
...modeArgs,
|
|
290
|
+
'--settings',
|
|
291
|
+
settings,
|
|
292
|
+
'--plugin-dir',
|
|
293
|
+
pluginDir,
|
|
294
|
+
...(opts.resume === true ? ['--resume'] : []),
|
|
295
|
+
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
296
|
+
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A brokered session's Claude config is fresh, so the CLI's own default
|
|
301
|
+
// mode would apply rather than the one the owner's settings set. Every
|
|
302
|
+
// start therefore names its mode: the one the gateway's arguments or
|
|
303
|
+
// settings set, else the CLI's manual mode, which asks before each
|
|
304
|
+
// action.
|
|
305
|
+
private buildGuestModeArgs(): string[] {
|
|
306
|
+
if (findFlagValue(this.gateway.args, ['--permission-mode']) !== null) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return [
|
|
311
|
+
'--permission-mode',
|
|
312
|
+
findClaudePermissionMode([], this.gateway.settings) ?? MANUAL_PERMISSION_MODE,
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private buildBrokerRefusal(reason: string): DaemonError {
|
|
317
|
+
return new DaemonError(
|
|
318
|
+
'auth_target_unsupported',
|
|
319
|
+
`gateway '${this.id}' takes its credential from impd's broker, ${reason}`,
|
|
320
|
+
{ agent: this.id },
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The Claude CLI sends ANTHROPIC_AUTH_TOKEN as a bearer authorization
|
|
325
|
+
// header, the one pairing atc binds, so the placeholders must be that
|
|
326
|
+
// variable alone, holding the placeholder, for a profile whose rule on
|
|
327
|
+
// the base URL's host sets that header. Any other variable would put the
|
|
328
|
+
// placeholder in a header the broker never fills.
|
|
329
|
+
private findPlaceholderRefusal(
|
|
330
|
+
env: Readonly<Record<string, string>>,
|
|
331
|
+
profiles: readonly string[],
|
|
332
|
+
): DaemonError | null {
|
|
333
|
+
const keys = Object.keys(env);
|
|
334
|
+
|
|
335
|
+
if (keys.length !== 1 || keys[0] !== BEARER_VARIABLE) {
|
|
336
|
+
return this.buildPlaceholderRefusal(
|
|
337
|
+
`needs ${BEARER_VARIABLE} as its only placeholder variable, which the broker fills as a bearer authorization header; it has ${keys.length === 0 ? 'none' : keys.join(', ')}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (env[BEARER_VARIABLE] !== PLACEHOLDER) {
|
|
342
|
+
return this.buildPlaceholderRefusal(`needs ${BEARER_VARIABLE} to hold ${PLACEHOLDER}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const resolution = resolveAuthProfiles(this.config.authProfiles, profiles);
|
|
346
|
+
|
|
347
|
+
const host = new URL(this.gateway.baseURL).hostname;
|
|
348
|
+
|
|
349
|
+
const rule =
|
|
350
|
+
'resolved' in resolution
|
|
351
|
+
? resolution.resolved.secrets.flatMap((s) => s.rules).find((r) => r.host === host)
|
|
352
|
+
: undefined;
|
|
353
|
+
|
|
354
|
+
if (rule?.header !== 'authorization' || rule.scheme !== 'bearer') {
|
|
355
|
+
return this.buildPlaceholderRefusal(
|
|
356
|
+
`needs a profile that sets a bearer authorization header for ${host}, the header ${BEARER_VARIABLE} fills`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// The settings file's env block reaches the CLI's own process, so a proxy
|
|
364
|
+
// or CA variable there would route the CLI around the broker.
|
|
365
|
+
private findSettingsEnvConflict(): DaemonError | null {
|
|
366
|
+
const settingsEnv = this.gateway.settings?.['env'];
|
|
367
|
+
|
|
368
|
+
const keys = [
|
|
369
|
+
...Object.keys(this.gateway.env),
|
|
370
|
+
...(isRecord(settingsEnv) ? Object.keys(settingsEnv) : []),
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
const variable = keys.find((key) => isBrokerVariable(key));
|
|
374
|
+
|
|
375
|
+
if (variable === undefined) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return new DaemonError(
|
|
380
|
+
'auth_target_unsupported',
|
|
381
|
+
`gateway '${this.id}' sets ${variable} in its settings env, which would route around impd's broker`,
|
|
382
|
+
{ agent: this.id, problem: 'guest_env_conflict', variable },
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private buildPlaceholderRefusal(problem: string): DaemonError {
|
|
387
|
+
return new DaemonError('auth_placeholder_unsupported', `gateway '${this.id}' ${problem}`, {
|
|
388
|
+
agent: this.id,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
200
392
|
private writeSettings(): string {
|
|
201
393
|
this.settingsFile ??= writeHookSettings({
|
|
202
394
|
id: this.id,
|
|
@@ -217,6 +409,43 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
217
409
|
}
|
|
218
410
|
}
|
|
219
411
|
|
|
412
|
+
// The variable the Claude CLI sends as a bearer authorization header, and
|
|
413
|
+
// the value impd's broker replaces with the credential on the host's side.
|
|
414
|
+
const BEARER_VARIABLE = 'ANTHROPIC_AUTH_TOKEN';
|
|
415
|
+
const PLACEHOLDER = 'imp-broker-placeholder';
|
|
416
|
+
|
|
417
|
+
// The Claude CLI's mode that asks a person before each action.
|
|
418
|
+
const MANUAL_PERMISSION_MODE = 'default';
|
|
419
|
+
|
|
420
|
+
// The Claude config folder a brokered session gets inside its guest
|
|
421
|
+
// folder, so no account or setting of the host's image reaches it.
|
|
422
|
+
const CLAUDE_CONFIG_FOLDER = 'claude-config';
|
|
423
|
+
|
|
424
|
+
// The state the Claude CLI reads from its config folder's `.claude.json`
|
|
425
|
+
// to start without its first-run onboarding. It holds no account: the
|
|
426
|
+
// placeholder credential is what keeps the CLI from asking for a login.
|
|
427
|
+
// It holds no folder trust either, so the CLI asks a person to trust the
|
|
428
|
+
// workspace on its first start.
|
|
429
|
+
const ONBOARDED_CONFIG = { hasCompletedOnboarding: true };
|
|
430
|
+
|
|
431
|
+
// Where that state travels, beside the config folder rather than in it.
|
|
432
|
+
const CONFIG_SEED_FILE = 'claude-config-seed.json';
|
|
433
|
+
|
|
434
|
+
// Copies the seed into the config folder only when the folder holds no
|
|
435
|
+
// `.claude.json` yet, then runs the CLI. A resumed session keeps the state
|
|
436
|
+
// the CLI wrote, a folder trust a person accepted included. The arguments
|
|
437
|
+
// are the config folder, the seed, and the CLI's own command line.
|
|
438
|
+
const SEED_CONFIG_SCRIPT =
|
|
439
|
+
'mkdir -p "$1" && { [ -e "$1/.claude.json" ] || cp "$2" "$1/.claude.json"; } && shift 2 && exec "$@"';
|
|
440
|
+
|
|
441
|
+
// The gateway's settings without a credential helper, which a brokered
|
|
442
|
+
// session never runs.
|
|
443
|
+
function buildSettingsWithoutHelper(
|
|
444
|
+
settings: Readonly<Record<string, unknown>>,
|
|
445
|
+
): Readonly<Record<string, unknown>> {
|
|
446
|
+
return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== 'apiKeyHelper'));
|
|
447
|
+
}
|
|
448
|
+
|
|
220
449
|
// The model names a gateway's env sets explicitly, keyed by role:
|
|
221
450
|
// `ANTHROPIC_MODEL` is `default`, and `ANTHROPIC_DEFAULT_<TIER>_MODEL` is the
|
|
222
451
|
// tier in lower case. No other env value leaves the adapter.
|
|
@@ -9,8 +9,7 @@ export interface AgentPick {
|
|
|
9
9
|
/**
|
|
10
10
|
* The agent choices the spawn and adopt flows offer, in menu order. An agent
|
|
11
11
|
* whose configured binary does not resolve is left out, so every row in the
|
|
12
|
-
* menu is a session that can start.
|
|
13
|
-
* every start of one is refused. Resolution follows the rule a spawn
|
|
12
|
+
* menu is a session that can start. Resolution follows the rule a spawn
|
|
14
13
|
* follows: a bare name comes off PATH, a name carrying a separator is taken
|
|
15
14
|
* as a path, and either way it has to be executable.
|
|
16
15
|
*/
|
|
@@ -23,9 +22,7 @@ export function collectAgentPicks(config: Config): AgentPick[] {
|
|
|
23
22
|
{ agent: 'claude', label: 'Claude', bin: config.claudeBin },
|
|
24
23
|
{ agent: 'grok', label: 'Grok', bin: config.grokBin },
|
|
25
24
|
{ agent: 'codex', label: 'Codex', bin: config.codexBin },
|
|
26
|
-
...config.gateways
|
|
27
|
-
.filter((g) => g.auth === undefined)
|
|
28
|
-
.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
|
|
25
|
+
...config.gateways.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
|
|
29
26
|
];
|
|
30
27
|
|
|
31
28
|
return candidates
|
|
@@ -74,9 +74,15 @@ interface PeerSocket extends SocketWriter {
|
|
|
74
74
|
export interface TCPPeer {
|
|
75
75
|
readonly verifyHandshake: (presented: string | null) => Promise<string | null>;
|
|
76
76
|
|
|
77
|
-
// Counts a line other than a handshake,
|
|
78
|
-
// failed handshake from the peer's
|
|
79
|
-
|
|
77
|
+
// Counts a line other than a handshake, or one over the size cap, sent
|
|
78
|
+
// before a handshake passed, as a failed handshake from the peer's
|
|
79
|
+
// address.
|
|
80
|
+
readonly recordFailure: (reason: 'unexpected_line' | 'line_too_long') => void;
|
|
81
|
+
|
|
82
|
+
// Records a handshake or a request refused for a principal the config
|
|
83
|
+
// does not list. The principal itself is never logged: an unlisted one
|
|
84
|
+
// is whatever the peer sent, a credential included.
|
|
85
|
+
readonly recordRefusedPrincipal: () => void;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
export class DaemonConnection {
|
|
@@ -220,7 +226,7 @@ export class DaemonConnection {
|
|
|
220
226
|
const oversized = this.lines.pendingLength + chunk.length > MAX_LINE;
|
|
221
227
|
|
|
222
228
|
if (oversized && this.isUnauthenticatedTCP()) {
|
|
223
|
-
this.tcp?.recordFailure();
|
|
229
|
+
this.tcp?.recordFailure('line_too_long');
|
|
224
230
|
this.peer.end();
|
|
225
231
|
|
|
226
232
|
return;
|
|
@@ -337,7 +343,7 @@ export class DaemonConnection {
|
|
|
337
343
|
const isHello = decoded.kind === 'request' && decoded.msg.m === 'daemon.hello';
|
|
338
344
|
|
|
339
345
|
if (this.tcpHelloSent || !isHello) {
|
|
340
|
-
this.tcp?.recordFailure();
|
|
346
|
+
this.tcp?.recordFailure('unexpected_line');
|
|
341
347
|
|
|
342
348
|
return 'close';
|
|
343
349
|
}
|
|
@@ -386,6 +392,7 @@ export class DaemonConnection {
|
|
|
386
392
|
}
|
|
387
393
|
|
|
388
394
|
if (!this.ctx.hasListedPrincipal(req.as)) {
|
|
395
|
+
this.tcp?.recordRefusedPrincipal();
|
|
389
396
|
this.sendErr(req.id, 'unauthorized', `principal '${req.as}' is not listed in principals`);
|
|
390
397
|
|
|
391
398
|
return;
|
|
@@ -1669,6 +1676,8 @@ export class DaemonConnection {
|
|
|
1669
1676
|
const principal = parsedHello.data.principal ?? null;
|
|
1670
1677
|
|
|
1671
1678
|
if (principal !== null && !this.ctx.hasListedPrincipal(principal)) {
|
|
1679
|
+
tcp.recordRefusedPrincipal();
|
|
1680
|
+
|
|
1672
1681
|
this.sendErr(
|
|
1673
1682
|
req.id,
|
|
1674
1683
|
'unauthorized',
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -69,6 +69,7 @@ import { loadListenerTokens } from './load-listener-tokens';
|
|
|
69
69
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
70
70
|
import { makeHookRunner } from './make-hook-runner';
|
|
71
71
|
import type { HookScope } from './make-hook-runner';
|
|
72
|
+
import { makeNonBlockingLog } from './make-non-blocking-log';
|
|
72
73
|
import { materializeWorkspace } from './materialize-workspace';
|
|
73
74
|
import { mintMessageID } from './mint-message-id';
|
|
74
75
|
import { mintSessionID } from './mint-session-id';
|
|
@@ -192,6 +193,16 @@ interface ListenOptions {
|
|
|
192
193
|
// How many delayed handshakes may wait at once across every address; 64
|
|
193
194
|
// when unset.
|
|
194
195
|
readonly maxDelayedHandshakes?: number;
|
|
196
|
+
|
|
197
|
+
// The clock the refusal log reads, Date.now when unset, and how long it
|
|
198
|
+
// folds repeated refusals from one peer into one line; a minute when
|
|
199
|
+
// unset.
|
|
200
|
+
readonly now?: () => number;
|
|
201
|
+
readonly refusalLogIntervalMs?: number;
|
|
202
|
+
|
|
203
|
+
// How many peers and kinds of refusal the refusal log tracks at once;
|
|
204
|
+
// 1024 when unset.
|
|
205
|
+
readonly maxRefusalWindows?: number;
|
|
195
206
|
}
|
|
196
207
|
|
|
197
208
|
export interface DaemonHandle {
|
|
@@ -231,6 +242,17 @@ const HANDSHAKE_FAILURE_DELAY_MS = 10_000;
|
|
|
231
242
|
// refused at once.
|
|
232
243
|
const MAX_DELAYED_HANDSHAKES = 64;
|
|
233
244
|
|
|
245
|
+
// How long the TCP listener folds repeated refusals from one peer into one
|
|
246
|
+
// log line.
|
|
247
|
+
const REFUSAL_LOG_INTERVAL_MS = 60_000;
|
|
248
|
+
|
|
249
|
+
// How many peers and kinds of refusal the TCP listener's refusal log tracks
|
|
250
|
+
// at once.
|
|
251
|
+
const MAX_REFUSAL_WINDOWS = 1024;
|
|
252
|
+
|
|
253
|
+
// Where the TCP listener logs when the daemon is given no log.
|
|
254
|
+
const STDERR_FD = 2;
|
|
255
|
+
|
|
234
256
|
// How long startup waits for a daemon that is shutting down to release the
|
|
235
257
|
// state lock before refusing to start.
|
|
236
258
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -2037,6 +2059,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2037
2059
|
return connection;
|
|
2038
2060
|
},
|
|
2039
2061
|
closeConnection: detachConnection,
|
|
2062
|
+
log: opts.log ?? makeNonBlockingLog(STDERR_FD),
|
|
2063
|
+
now: opts.listen.now ?? Date.now,
|
|
2064
|
+
refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
|
|
2065
|
+
maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
|
|
2040
2066
|
});
|
|
2041
2067
|
} catch (error) {
|
|
2042
2068
|
await releaseResources();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The most characters of a field a log line keeps; a longer one is cut and
|
|
2
|
+
// ends in `...`.
|
|
3
|
+
const MAX_FIELD_CHARS = 64;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Formats a value for one `key=value` field of a log line. Every character
|
|
7
|
+
* outside printable ASCII, and a space, `"`, `=`, or `\`, is written as a
|
|
8
|
+
* `\u{hex}` escape, so a value from the network can neither move the
|
|
9
|
+
* terminal, start a new line, nor forge another field. The value is cut to
|
|
10
|
+
* 64 characters before it is escaped.
|
|
11
|
+
*/
|
|
12
|
+
export function formatLogField(value: string): string {
|
|
13
|
+
let formatted = '';
|
|
14
|
+
let kept = 0;
|
|
15
|
+
|
|
16
|
+
// Iterating a string steps by code point, so a character outside the
|
|
17
|
+
// basic plane counts and escapes as one.
|
|
18
|
+
for (const char of value) {
|
|
19
|
+
if (kept === MAX_FIELD_CHARS) {
|
|
20
|
+
return `${formatted}...`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
formatted += isPlainChar(char) ? char : formatEscape(char);
|
|
24
|
+
kept++;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return formatted;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainChar(char: string): boolean {
|
|
31
|
+
const code = char.codePointAt(0) ?? 0;
|
|
32
|
+
|
|
33
|
+
return code > 0x20 && code < 0x7f && char !== '"' && char !== '=' && char !== '\\';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatEscape(char: string): string {
|
|
37
|
+
return `\\u{${(char.codePointAt(0) ?? 0).toString(16)}}`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { write } from 'node:fs';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
// The most bytes of lines that wait behind the write in flight; a line past
|
|
5
|
+
// it is dropped and counted.
|
|
6
|
+
const MAX_QUEUED_BYTES = 64 * 1024;
|
|
7
|
+
|
|
8
|
+
// The node:fs write without a callback, which Bun runs on its thread pool.
|
|
9
|
+
const writeAsync = promisify(write);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Makes a log that writes each line to a file descriptor without ever
|
|
13
|
+
* blocking the event loop, so a reader that stops reading stalls nothing
|
|
14
|
+
* but the log. Each write goes through the asynchronous node:fs write,
|
|
15
|
+
* which Bun runs on its thread pool, one write at a time. Lines that
|
|
16
|
+
* arrive meanwhile queue up to 64 KiB; a line past that is dropped, and the
|
|
17
|
+
* next write after the drops starts with one `atc log dropped=N` line. A
|
|
18
|
+
* failed write drops its lines and counts them the same way, and the next
|
|
19
|
+
* line logged writes again.
|
|
20
|
+
*/
|
|
21
|
+
export function makeNonBlockingLog(fd: number): (line: string) => void {
|
|
22
|
+
let queue: string[] = [];
|
|
23
|
+
let queuedBytes = 0;
|
|
24
|
+
let dropped = 0;
|
|
25
|
+
let writing = false;
|
|
26
|
+
|
|
27
|
+
const recordLine = (line: string): boolean => {
|
|
28
|
+
const text = `${line}\n`;
|
|
29
|
+
const bytes = Buffer.byteLength(text);
|
|
30
|
+
|
|
31
|
+
if (queuedBytes + bytes > MAX_QUEUED_BYTES) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
queue.push(text);
|
|
36
|
+
|
|
37
|
+
queuedBytes += bytes;
|
|
38
|
+
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const writeNext = (): void => {
|
|
43
|
+
if (queue.length === 0 && dropped === 0) {
|
|
44
|
+
writing = false;
|
|
45
|
+
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
writing = true;
|
|
50
|
+
|
|
51
|
+
const lines = dropped > 0 ? [`atc log dropped=${String(dropped)}\n`, ...queue] : queue;
|
|
52
|
+
|
|
53
|
+
// The lines this write stands for, the ones the dropped line counts
|
|
54
|
+
// included, which a failed write drops again.
|
|
55
|
+
const count = dropped + queue.length;
|
|
56
|
+
|
|
57
|
+
queue = [];
|
|
58
|
+
queuedBytes = 0;
|
|
59
|
+
dropped = 0;
|
|
60
|
+
void writeChunk(Buffer.from(lines.join('')), count);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const writeChunk = async (chunk: Buffer, lineCount: number): Promise<void> => {
|
|
64
|
+
let rest = chunk;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
while (rest.length > 0) {
|
|
68
|
+
const result = await writeAsync(fd, rest);
|
|
69
|
+
|
|
70
|
+
rest = rest.subarray(result.bytesWritten);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
dropped += lineCount;
|
|
74
|
+
writing = false;
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
writeNext();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (line) => {
|
|
83
|
+
if (!recordLine(line)) {
|
|
84
|
+
dropped++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!writing) {
|
|
88
|
+
writeNext();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { formatLogField } from './format-log-field';
|
|
2
|
+
|
|
3
|
+
// Why the TCP listener refused a handshake: a missing token, a wrong one, a
|
|
4
|
+
// handshake that would wait while the cap of delayed handshakes is full, a
|
|
5
|
+
// socket that closed while its handshake waited, a line other than one
|
|
6
|
+
// handshake before the handshake passed, or a line over the size cap
|
|
7
|
+
// before it passed.
|
|
8
|
+
type HandshakeRefusalReason =
|
|
9
|
+
| 'missing_token'
|
|
10
|
+
| 'unauthorized'
|
|
11
|
+
| 'delay_cap_full'
|
|
12
|
+
| 'closed_during_delay'
|
|
13
|
+
| 'unexpected_line'
|
|
14
|
+
| 'line_too_long';
|
|
15
|
+
|
|
16
|
+
export type Refusal =
|
|
17
|
+
| {
|
|
18
|
+
readonly event: 'handshake_refused';
|
|
19
|
+
readonly peer: string;
|
|
20
|
+
readonly reason: HandshakeRefusalReason;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
readonly event: 'principal_refused';
|
|
24
|
+
readonly peer: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface RefusalLogOptions {
|
|
28
|
+
readonly log: (line: string) => void;
|
|
29
|
+
readonly now: () => number;
|
|
30
|
+
|
|
31
|
+
// How long a window of repeated refusals lasts before the next one is
|
|
32
|
+
// logged again.
|
|
33
|
+
readonly intervalMs: number;
|
|
34
|
+
|
|
35
|
+
// How many windows are tracked at once; a refusal that would start one
|
|
36
|
+
// past the cap counts toward one overflow window instead.
|
|
37
|
+
readonly maxWindows: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RefusalWindow {
|
|
41
|
+
readonly startedAt: number;
|
|
42
|
+
|
|
43
|
+
// The refusals in the window after the first, which no line holds yet.
|
|
44
|
+
pending: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The line of the window that counts refusals past the cap of windows.
|
|
48
|
+
const OVERFLOW_LINE = 'atc tcp event=refused peer=overflow';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Logs TCP listener refusals as `key=value` lines, at most one per window
|
|
52
|
+
* for each peer and kind of refusal: a handshake refusal per reason, and a
|
|
53
|
+
* principal refusal, which never holds the principal it gave. The first
|
|
54
|
+
* refusal of a window logs at once with `count=1`; later ones in the window
|
|
55
|
+
* are counted, and a line with their `count` follows once the window ends,
|
|
56
|
+
* so the counts of every line sum to every refusal. While the cap of
|
|
57
|
+
* windows is full, a refusal that would start a new window counts toward
|
|
58
|
+
* one overflow window with `peer=overflow` instead, so a flood across many
|
|
59
|
+
* peers logs no more lines than the cap allows. Ended windows are logged on
|
|
60
|
+
* the next refusal from any peer and on a drain.
|
|
61
|
+
*/
|
|
62
|
+
export class RefusalLog {
|
|
63
|
+
private readonly opts: RefusalLogOptions;
|
|
64
|
+
|
|
65
|
+
// Keyed by the line without its count, in the order the windows started.
|
|
66
|
+
private readonly windows = new Map<string, RefusalWindow>();
|
|
67
|
+
|
|
68
|
+
private overflow: RefusalWindow | null = null;
|
|
69
|
+
|
|
70
|
+
constructor(opts: RefusalLogOptions) {
|
|
71
|
+
this.opts = opts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
record(refusal: Refusal): void {
|
|
75
|
+
const now = this.opts.now();
|
|
76
|
+
|
|
77
|
+
this.drainEnded(now);
|
|
78
|
+
|
|
79
|
+
const line = formatRefusal(refusal);
|
|
80
|
+
const window = this.windows.get(line);
|
|
81
|
+
|
|
82
|
+
if (window !== undefined) {
|
|
83
|
+
window.pending++;
|
|
84
|
+
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (this.windows.size >= this.opts.maxWindows) {
|
|
89
|
+
this.recordOverflow(now);
|
|
90
|
+
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.windows.set(line, { startedAt: now, pending: 0 });
|
|
95
|
+
this.opts.log(`${line} count=1`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Logs the pending count of every window and forgets them all.
|
|
100
|
+
*/
|
|
101
|
+
drain(): void {
|
|
102
|
+
for (const [line, window] of this.windows) {
|
|
103
|
+
this.logPending(line, window);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.windows.clear();
|
|
107
|
+
|
|
108
|
+
if (this.overflow !== null) {
|
|
109
|
+
this.logPending(OVERFLOW_LINE, this.overflow);
|
|
110
|
+
|
|
111
|
+
this.overflow = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Windows start in map order and all last the same interval, so the
|
|
116
|
+
// ended ones lead the map.
|
|
117
|
+
private drainEnded(now: number): void {
|
|
118
|
+
if (this.overflow !== null && this.hasEnded(this.overflow, now)) {
|
|
119
|
+
this.logPending(OVERFLOW_LINE, this.overflow);
|
|
120
|
+
|
|
121
|
+
this.overflow = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [line, window] of this.windows) {
|
|
125
|
+
if (!this.hasEnded(window, now)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.windows.delete(line);
|
|
130
|
+
this.logPending(line, window);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private hasEnded(window: Readonly<RefusalWindow>, now: number): boolean {
|
|
135
|
+
return now - window.startedAt >= this.opts.intervalMs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private logPending(line: string, window: Readonly<RefusalWindow>): void {
|
|
139
|
+
if (window.pending > 0) {
|
|
140
|
+
this.opts.log(`${line} count=${window.pending}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private recordOverflow(now: number): void {
|
|
145
|
+
if (this.overflow !== null) {
|
|
146
|
+
this.overflow.pending++;
|
|
147
|
+
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.overflow = { startedAt: now, pending: 0 };
|
|
152
|
+
|
|
153
|
+
this.opts.log(`${OVERFLOW_LINE} count=1`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatRefusal(refusal: Refusal): string {
|
|
158
|
+
const peer = formatLogField(refusal.peer);
|
|
159
|
+
|
|
160
|
+
return refusal.event === 'handshake_refused'
|
|
161
|
+
? `atc tcp event=handshake_refused peer=${peer} reason=${refusal.reason}`
|
|
162
|
+
: `atc tcp event=principal_refused peer=${peer} principal=unlisted`;
|
|
163
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { DaemonConnection, TCPPeer } from './daemon-connection';
|
|
2
2
|
import { findTokenFingerprint } from './find-token-fingerprint';
|
|
3
|
+
import { formatLogField } from './format-log-field';
|
|
3
4
|
import { HandshakeThrottle } from './handshake-throttle';
|
|
5
|
+
import { RefusalLog } from './refusal-log';
|
|
4
6
|
|
|
5
7
|
// The slice of an accepted socket a protocol connection writes to and ends.
|
|
6
8
|
interface ListenerSocket {
|
|
@@ -26,6 +28,16 @@ interface TCPListenerOptions {
|
|
|
26
28
|
|
|
27
29
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a connection is a live object the daemon releases
|
|
28
30
|
readonly closeConnection: (connection: DaemonConnection) => void;
|
|
31
|
+
|
|
32
|
+
// Where the listener start and each refusal are logged, one line at a
|
|
33
|
+
// time, with the clock and the window that summarize repeated refusals
|
|
34
|
+
// from one peer.
|
|
35
|
+
readonly log: (line: string) => void;
|
|
36
|
+
readonly now: () => number;
|
|
37
|
+
readonly refusalLogIntervalMs: number;
|
|
38
|
+
|
|
39
|
+
// How many peers and kinds of refusal the refusal log tracks at once.
|
|
40
|
+
readonly maxRefusalWindows: number;
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export interface TCPListener {
|
|
@@ -55,6 +67,13 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
55
67
|
const throttle = new HandshakeThrottle(opts.failureDelayMs);
|
|
56
68
|
const connections = new Set<DaemonConnection>();
|
|
57
69
|
|
|
70
|
+
const refusals = new RefusalLog({
|
|
71
|
+
log: opts.log,
|
|
72
|
+
now: opts.now,
|
|
73
|
+
intervalMs: opts.refusalLogIntervalMs,
|
|
74
|
+
maxWindows: opts.maxRefusalWindows,
|
|
75
|
+
});
|
|
76
|
+
|
|
58
77
|
let delayed = 0;
|
|
59
78
|
|
|
60
79
|
// Ends the delayed handshake of each open socket once it closes.
|
|
@@ -77,6 +96,12 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
77
96
|
if (delay > 0 && delayed >= opts.maxDelayedHandshakes) {
|
|
78
97
|
throttle.recordFailure(address, Date.now());
|
|
79
98
|
|
|
99
|
+
refusals.record({
|
|
100
|
+
event: 'handshake_refused',
|
|
101
|
+
peer: address,
|
|
102
|
+
reason: 'delay_cap_full',
|
|
103
|
+
});
|
|
104
|
+
|
|
80
105
|
return null;
|
|
81
106
|
}
|
|
82
107
|
|
|
@@ -96,6 +121,12 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
96
121
|
delayed--;
|
|
97
122
|
|
|
98
123
|
if (waited === 'closed') {
|
|
124
|
+
refusals.record({
|
|
125
|
+
event: 'handshake_refused',
|
|
126
|
+
peer: address,
|
|
127
|
+
reason: 'closed_during_delay',
|
|
128
|
+
});
|
|
129
|
+
|
|
99
130
|
return null;
|
|
100
131
|
}
|
|
101
132
|
}
|
|
@@ -107,12 +138,22 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
107
138
|
|
|
108
139
|
if (fingerprint === null) {
|
|
109
140
|
throttle.recordFailure(address, Date.now());
|
|
141
|
+
|
|
142
|
+
refusals.record({
|
|
143
|
+
event: 'handshake_refused',
|
|
144
|
+
peer: address,
|
|
145
|
+
reason: presented === null ? 'missing_token' : 'unauthorized',
|
|
146
|
+
});
|
|
110
147
|
}
|
|
111
148
|
|
|
112
149
|
return fingerprint;
|
|
113
150
|
},
|
|
114
|
-
recordFailure: () => {
|
|
151
|
+
recordFailure: (reason) => {
|
|
115
152
|
throttle.recordFailure(address, Date.now());
|
|
153
|
+
refusals.record({ event: 'handshake_refused', peer: address, reason });
|
|
154
|
+
},
|
|
155
|
+
recordRefusedPrincipal: () => {
|
|
156
|
+
refusals.record({ event: 'principal_refused', peer: address });
|
|
116
157
|
},
|
|
117
158
|
};
|
|
118
159
|
|
|
@@ -136,6 +177,10 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
136
177
|
},
|
|
137
178
|
});
|
|
138
179
|
|
|
180
|
+
opts.log(
|
|
181
|
+
`atc tcp event=listening host=${formatLogField(server.hostname)} port=${String(server.port)}`,
|
|
182
|
+
);
|
|
183
|
+
|
|
139
184
|
return {
|
|
140
185
|
port: server.port,
|
|
141
186
|
setTokens(next) {
|
|
@@ -160,6 +205,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
160
205
|
}
|
|
161
206
|
|
|
162
207
|
server.stop(true);
|
|
208
|
+
refusals.drain();
|
|
163
209
|
},
|
|
164
210
|
};
|
|
165
211
|
}
|