@zgeoff/atc 2.25.0 → 2.26.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.25.0",
3
+ "version": "2.26.1",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -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 starts only in a
110
- // guest that reaches the broker, with guest settings the adapter plans
111
- // for it, and it plans none: started without them, the CLI would send
112
- // whatever credential it holds to the gateway's host.
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
- if (this.gateway.auth === undefined) {
122
+ const auth = this.gateway.auth;
123
+
124
+ if (auth === undefined) {
115
125
  return null;
116
126
  }
117
127
 
118
- return new DaemonError(
119
- 'auth_target_unsupported',
120
- `gateway '${this.id}' takes its credential from impd's broker, and atc plans no guest settings for it on any target`,
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
- // The credential helper runs on the daemon's machine, so a gateway
158
- // session never runs on a remote host.
159
- planGuestSpawn(): null {
160
- return null;
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. A gateway with auth is left out too, since
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, sent before one passed, as a
78
- // failed handshake from the peer's address.
79
- readonly recordFailure: () => void;
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',
@@ -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
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  ImpExecRequirement,
11
11
  ImpPort,
12
12
  ImpSessionConnection,
13
+ ImpSessionHandlers,
13
14
  ImpSessionOutcome,
14
15
  ImpSessionRequest,
15
16
  ImpSessionStarted,
@@ -22,7 +23,8 @@ interface ImpHarnessHost {
22
23
  // connection the sleep ends is the sleep's and not the harness's end.
23
24
  readonly isSuspending: () => boolean;
24
25
 
25
- // Called once, when the harness ends or the daemon lets go of it.
26
+ // Called once, when the harness ends or the daemon lets go of it; for an
27
+ // end, after every exit listener has run.
26
28
  readonly onDone: () => void;
27
29
 
28
30
  // Whether impd carries output offsets, so a reconnect can resume.
@@ -273,6 +275,7 @@ export class ImpHarness implements HarnessHandle {
273
275
 
274
276
  this.stopFollowing();
275
277
  connection?.close();
278
+ this.host.onDone();
276
279
  };
277
280
 
278
281
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
@@ -423,22 +426,29 @@ export class ImpHarness implements HarnessHandle {
423
426
  private openConnection(request: ImpSessionRequest, ticket?: LaunchTicket): void {
424
427
  const gate = ticket === undefined ? undefined : () => this.checkTicket(ticket, request.kind);
425
428
 
426
- const connection = this.port.openSession(
427
- request,
428
- {
429
- onStarted: (started) => {
430
- if (this.connection === connection) {
431
- this.applyStarted(connection, started);
432
- }
433
- },
434
- onOutput: (data) => {
435
- if (this.connection === connection) {
436
- this.applyOutput(data);
437
- }
438
- },
429
+ const handlers: ImpSessionHandlers = {
430
+ onStarted: (started) => {
431
+ if (this.connection === connection) {
432
+ this.applyStarted(connection, started);
433
+ }
439
434
  },
440
- gate,
441
- );
435
+ onOutput: (data) => {
436
+ if (this.connection === connection) {
437
+ this.applyOutput(data);
438
+ }
439
+ },
440
+ };
441
+
442
+ let connection: ImpSessionConnection;
443
+
444
+ // The ticket goes back however the connection ends, a throw from
445
+ // opening it included.
446
+ try {
447
+ connection = this.port.openSession(request, handlers, gate);
448
+ } catch (error) {
449
+ ticket?.release();
450
+ throw error;
451
+ }
442
452
 
443
453
  this.connection = connection;
444
454
  this.connectionTicket = ticket ?? null;
@@ -794,8 +804,14 @@ export class ImpHarness implements HarnessHandle {
794
804
 
795
805
  this.stopFollowing();
796
806
 
797
- for (const listener of listeners) {
798
- listener(exit);
807
+ // The host hears the harness is done only after every exit listener
808
+ // has run, so its owner no longer counts the harness as running then.
809
+ try {
810
+ for (const listener of listeners) {
811
+ listener(exit);
812
+ }
813
+ } finally {
814
+ this.host.onDone();
799
815
  }
800
816
  }
801
817
 
@@ -852,7 +868,6 @@ export class ImpHarness implements HarnessHandle {
852
868
  this.dataListeners.clear();
853
869
  this.exitListeners.clear();
854
870
  this.attachmentListeners.clear();
855
- this.host.onDone();
856
871
  }
857
872
  }
858
873
 
@@ -535,16 +535,17 @@ export class ImpProvider implements ExecutionProvider {
535
535
  }
536
536
 
537
537
  // Runs a readying, a sleep, or a lease return of an imp after the one
538
- // before it there. A turn never starts in its caller's tick, so a harness
539
- // end the daemon still applies counts before the turn checks the host.
538
+ // before it there.
540
539
  private async withHostTurn<T>(name: string, run: () => Promise<T>): Promise<T> {
541
- const before = this.turns.get(name) ?? Promise.resolve();
540
+ const before = this.turns.get(name);
542
541
  const turn = Promise.withResolvers<void>();
543
542
 
544
543
  this.turns.set(name, turn.promise);
545
544
 
546
545
  try {
547
- await before;
546
+ if (before !== undefined) {
547
+ await before;
548
+ }
548
549
 
549
550
  return await run();
550
551
  } finally {
@@ -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
+ }
@@ -209,6 +209,7 @@ const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
209
209
  'auth_blocked',
210
210
  'auth_binding_invalid',
211
211
  'broker_not_ready',
212
+ 'auth_placeholder_unsupported',
212
213
  ]);
213
214
 
214
215
  async function tryAdoptTerminal(
@@ -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
  }
@@ -65,6 +65,7 @@ const ERROR_CODES = [
65
65
  'auth_binding_invalid',
66
66
  'auth_revocation_pending',
67
67
  'broker_not_ready',
68
+ 'auth_placeholder_unsupported',
68
69
  'host_leased',
69
70
  'confirmation_required',
70
71
  'confirm_token_invalid',