@zgeoff/atc 2.15.2 → 2.17.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.
Files changed (60) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-hook-settings.ts +12 -3
  3. package/src/agents/print-codex-hook-file.ts +1 -1
  4. package/src/agents/print-grok-hook-file.ts +1 -1
  5. package/src/cli.ts +39 -5
  6. package/src/client/collect-target-picks.ts +53 -0
  7. package/src/client/dirs.ts +1 -1
  8. package/src/client/spawn-picker.ts +132 -6
  9. package/src/client/ui.ts +6 -1
  10. package/src/daemon/broker-authority-error.ts +41 -0
  11. package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
  12. package/src/daemon/daemon-connection.ts +129 -0
  13. package/src/daemon/daemon-context.ts +23 -0
  14. package/src/daemon/daemon.ts +46 -1
  15. package/src/daemon/imp-client-port.ts +75 -3
  16. package/src/daemon/imp-port.ts +60 -0
  17. package/src/daemon/is-imp-name-allowed.ts +38 -0
  18. package/src/daemon/is-own-hook-event.ts +19 -0
  19. package/src/daemon/materialize-workspace.ts +42 -18
  20. package/src/daemon/parse-hook-line.ts +3 -0
  21. package/src/daemon/require-git-transports.ts +23 -0
  22. package/src/daemon/session-runtime.ts +6 -0
  23. package/src/daemon/verify-broker-authority.ts +52 -0
  24. package/src/daemon/verify-cleanup-authority.ts +44 -0
  25. package/src/daemon/verify-token-imp-authority.ts +74 -0
  26. package/src/hook-report.ts +6 -4
  27. package/src/mcp/require-daemon-features.ts +2 -0
  28. package/src/protocol/daemon-features.ts +8 -0
  29. package/src/protocol/hook-event.ts +6 -2
  30. package/src/protocol/protocol.ts +2 -0
  31. package/src/protocol/request-param-schemas.ts +67 -15
  32. package/src/shared/collect-workspaces-config.ts +105 -0
  33. package/src/shared/config.ts +14 -0
  34. package/src/shared/default-git-transports.ts +5 -0
  35. package/src/shared/is-git-url.ts +11 -0
  36. package/src/sources/build-sources.ts +40 -0
  37. package/src/sources/collect-builtin-sources.ts +41 -0
  38. package/src/sources/dirs/build-dirs-source.ts +70 -0
  39. package/src/sources/git/build-git-source.ts +25 -0
  40. package/src/sources/github/build-github-source.ts +113 -0
  41. package/src/sources/github/collect-github-repos.ts +165 -0
  42. package/src/sources/github/find-github-alternate-url.ts +20 -0
  43. package/src/sources/github/read-git-protocol.ts +11 -0
  44. package/src/sources/github/run-gh.ts +66 -0
  45. package/src/sources/types.ts +70 -0
  46. package/src/statusline.ts +8 -6
  47. package/src/workspace/check-git-transport.ts +61 -0
  48. package/src/workspace/check-repository-access.ts +108 -0
  49. package/src/workspace/collect-remote-refs.ts +154 -0
  50. package/src/workspace/create-git-askpass.ts +84 -0
  51. package/src/workspace/create-workspace-clone.ts +29 -76
  52. package/src/workspace/expand-git-shorthand.ts +13 -0
  53. package/src/workspace/find-remote-ref.ts +34 -0
  54. package/src/workspace/normalize-git-url.ts +2 -7
  55. package/src/workspace/resolve-git-url.ts +46 -0
  56. package/src/workspace/resolve-path-source.ts +11 -4
  57. package/src/workspace/run-git.ts +52 -4
  58. package/src/workspace/workspace-source.ts +10 -2
  59. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  60. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.15.2",
3
+ "version": "2.17.0",
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",
@@ -43,7 +43,7 @@
43
43
  "@better-auth/oauth-provider": "1.7.6",
44
44
  "@xterm/addon-serialize": "0.14.0",
45
45
  "@xterm/headless": "6.0.0",
46
- "@zgeoff/imp-client": "0.24.0",
46
+ "@zgeoff/imp-client": "0.27.0",
47
47
  "better-auth": "1.7.6",
48
48
  "bun-pty": "0.4.10",
49
49
  "citty": "0.2.2",
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { AgentID } from '../shared/agent-id';
3
+ import { toShellArg } from '../shared/to-shell-arg';
3
4
  import { buildCLICommand } from './build-cli-command';
4
5
 
5
6
  /**
@@ -20,7 +21,9 @@ export interface HookSettingsProfile {
20
21
  * The settings object injected into wrangled sessions via
21
22
  * `claude --settings`. The user's own settings are untouched; these hooks
22
23
  * only exist in sessions atc spawns, and identify themselves via
23
- * ATC_SESSION_ID in the env.
24
+ * ATC_SESSION_ID in the env and the agent id on their command line. A
25
+ * nested harness inherits the env but not the command line, so the agent id
26
+ * keeps its reports from passing as the session's own.
24
27
  *
25
28
  * A settings-file env block outranks a shell export of the same variable, so
26
29
  * a session's backend is decided here rather than by whatever the terminal
@@ -37,7 +40,13 @@ export function buildHookSettings(
37
40
  ): Record<string, unknown> {
38
41
  const entry = [
39
42
  {
40
- hooks: [{ type: 'command', command: buildCLICommand('hook-report', cliArgv), timeout: 5 }],
43
+ hooks: [
44
+ {
45
+ type: 'command',
46
+ command: buildCLICommand(`hook-report --agent ${toShellArg(profile.id)}`, cliArgv),
47
+ timeout: 5,
48
+ },
49
+ ],
41
50
  },
42
51
  ];
43
52
 
@@ -61,7 +70,7 @@ export function buildHookSettings(
61
70
  // padding.
62
71
  statusLine: {
63
72
  type: 'command',
64
- command: buildCLICommand('statusline', cliArgv),
73
+ command: buildCLICommand(`statusline --agent ${toShellArg(profile.id)}`, cliArgv),
65
74
  padding: statuslinePadding,
66
75
  },
67
76
  ...(profile.env === undefined || Object.keys(profile.env).length === 0
@@ -17,7 +17,7 @@ export function printCodexHookFile(): void {
17
17
  }
18
18
 
19
19
  function buildCodexHookFile(): string {
20
- const cmd = buildCLICommand('hook-report');
20
+ const cmd = buildCLICommand('hook-report --agent codex');
21
21
  const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
22
22
  const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
23
23
 
@@ -19,7 +19,7 @@ export function printGrokHookFile(): void {
19
19
  }
20
20
 
21
21
  function buildGrokHookFile(): string {
22
- const cmd = buildCLICommand('hook-report');
22
+ const cmd = buildCLICommand('hook-report --agent grok');
23
23
  const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
24
24
  const hooks = Object.fromEntries(GROK_HOOK_EVENTS.map((event) => [event, entry]));
25
25
 
package/src/cli.ts CHANGED
@@ -161,6 +161,10 @@ const main = defineCommand({
161
161
  const gateway = await import('./agents/gateway-adapter');
162
162
  const headless = await import('./agents/start-claude-headless-run');
163
163
  const targets = await import('./daemon/build-execution-targets');
164
+ const sourceOrder = await import('./sources/build-sources');
165
+ const builtinSources = await import('./sources/collect-builtin-sources');
166
+ const zoxide = await import('./shared/collect-zoxide-dirs');
167
+ const home = await import('./shared/resolve-home-dir');
164
168
 
165
169
  // Test harnesses shrink the outbound queue to force overflow
166
170
  // deterministically; unset means the production default.
@@ -178,10 +182,27 @@ const main = defineCommand({
178
182
  console.error(`atc daemon: config: ${line}`);
179
183
  }
180
184
 
181
- for (const problem of cfg.principalErrors) {
185
+ for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
182
186
  console.error(`atc daemon: config: ${problem}`);
183
187
  }
184
188
 
189
+ const sources = sourceOrder.buildSources(
190
+ builtinSources.collectBuiltinSources({
191
+ roots: cfg.dirs.roots,
192
+ githubOwner: cfg.workspaces.githubOwner,
193
+ ghBin: 'gh',
194
+ homeDir: home.resolveHomeDir(),
195
+ collectZoxideDirs: zoxide.collectZoxideDirs,
196
+ }),
197
+ cfg.workspaces.sources,
198
+ );
199
+
200
+ for (const id of sources.missing) {
201
+ console.error(
202
+ `atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
203
+ );
204
+ }
205
+
185
206
  // Cap on how long a fleet restore waits for one revived session to
186
207
  // report it has booted before moving to the next. Tests pin it to
187
208
  // keep timing deterministic; unset means the production default.
@@ -220,6 +241,8 @@ const main = defineCommand({
220
241
  defaultTarget: cfg.defaultTarget,
221
242
  targetErrors,
222
243
  principals: cfg.principals,
244
+ sources: sources.sources,
245
+ gitTransports: cfg.workspaces.gitTransports,
223
246
  restoreBootTimeoutMs,
224
247
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
225
248
  ? { tapGraceMs: graceOverride }
@@ -291,10 +314,16 @@ const main = defineCommand({
291
314
  description: 'Forward a hook event from a wrangled session to the atc socket',
292
315
  hidden: true,
293
316
  },
294
- async run() {
317
+
318
+ // No arg is required: a citty usage error exits nonzero, and
319
+ // reporters must always exit 0.
320
+ args: {
321
+ agent: { type: 'string', default: '' },
322
+ },
323
+ async run(ctx) {
295
324
  const reporter = await import('./hook-report');
296
325
 
297
- await reporter.runHookReport();
326
+ await reporter.runHookReport(ctx.args.agent);
298
327
  },
299
328
  }),
300
329
  tap: () =>
@@ -352,10 +381,15 @@ const main = defineCommand({
352
381
  description: 'Render the chained statusline for a wrangled session',
353
382
  hidden: true,
354
383
  },
355
- async run() {
384
+
385
+ // A statusline command must always exit 0 too, so no arg is required.
386
+ args: {
387
+ agent: { type: 'string', default: '' },
388
+ },
389
+ async run(ctx) {
356
390
  const statusline = await import('./statusline');
357
391
 
358
- await statusline.runStatusline();
392
+ await statusline.runStatusline(ctx.args.agent);
359
393
  },
360
394
  }),
361
395
  },
@@ -0,0 +1,53 @@
1
+ import { isRecord } from '../shared/report';
2
+
3
+ export interface TargetPick {
4
+ readonly id: string;
5
+ readonly provider: string;
6
+ readonly available: boolean;
7
+ readonly isDefault: boolean;
8
+
9
+ // Whether a workspace can be materialized there: the daemon has the
10
+ // provider, and it can both transfer an archive and run a command.
11
+ readonly takesWorkspace: boolean;
12
+
13
+ // Whether a session there runs on the daemon's own machine, where a
14
+ // local directory runs in place.
15
+ readonly inPlace: boolean;
16
+ }
17
+
18
+ /**
19
+ * The execution targets an `agents.list` answer holds, in config order. An
20
+ * answer from a daemon without targets, or a malformed entry, yields none.
21
+ */
22
+ export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): TargetPick[] {
23
+ const targets = answer['targets'];
24
+
25
+ if (!Array.isArray(targets)) {
26
+ return [];
27
+ }
28
+
29
+ return targets.flatMap((entry: unknown): TargetPick[] => {
30
+ if (
31
+ !isRecord(entry) ||
32
+ typeof entry['id'] !== 'string' ||
33
+ typeof entry['provider'] !== 'string'
34
+ ) {
35
+ return [];
36
+ }
37
+
38
+ const capabilities = isRecord(entry['capabilities']) ? entry['capabilities'] : {};
39
+ const available = entry['available'] === true;
40
+
41
+ return [
42
+ {
43
+ id: entry['id'],
44
+ provider: entry['provider'],
45
+ available,
46
+ isDefault: entry['default'] === true,
47
+ takesWorkspace:
48
+ available && capabilities['transfer'] === true && capabilities['run'] === true,
49
+ inPlace: entry['provider'] === 'local-pty',
50
+ },
51
+ ];
52
+ });
53
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename } from 'node:path';
4
- import { collectRootDirs } from './collect-root-dirs';
4
+ import { collectRootDirs } from '../shared/collect-root-dirs';
5
5
 
6
6
  /**
7
7
  * Every source the picker lists, merged in the order the reader expects
@@ -1,17 +1,19 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { DaemonError } from '../protocol/daemon-error';
3
3
  import type { AgentID } from '../shared/agent-id';
4
+ import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
4
5
  import { loadConfig } from '../shared/config';
5
6
  import { collectAgentPicks } from './collect-agent-picks';
6
7
  import type { AgentPick } from './collect-agent-picks';
7
8
  import { collectPathCompletions } from './collect-path-completions';
8
- import { collectZoxideDirs } from './collect-zoxide-dirs';
9
+ import { collectTargetPicks } from './collect-target-picks';
10
+ import type { TargetPick } from './collect-target-picks';
9
11
  import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
10
12
  import { planTextEdit } from './keys';
11
13
  import { resolvePathInput } from './resolve-path-input';
12
14
  import { ansi, cols, drawPicker } from './ui';
13
15
 
14
- type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
16
+ type PickerStep = 'agent' | 'dir' | 'target' | 'name' | 'prompt';
15
17
 
16
18
  // What the flow borrows from the client that owns the screen, the daemon
17
19
  // connection and the fleet mirror.
@@ -31,8 +33,9 @@ export interface SpawnPickerDeps<TMirror> {
31
33
  }
32
34
 
33
35
  /**
34
- * The modal flow behind n and r: agent, then directory, then name, then an
35
- * optional first prompt. Every path out of it either attaches the new
36
+ * The modal flow behind n and r: agent, then directory, then execution
37
+ * target when the daemon has more than one, then name, then an optional
38
+ * first prompt. Every path out of it either attaches the new
36
39
  * session or returns the client to the screen it came from. A spawn the
37
40
  * daemon refuses is no path out: the flow stays open and shows why.
38
41
  */
@@ -56,6 +59,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
56
59
 
57
60
  private dir = '';
58
61
 
62
+ // The daemon's execution targets, read when the directory step opens,
63
+ // and the one chosen; null spawns on the daemon's default target.
64
+ private targets: TargetPick[] = [];
65
+
66
+ private target: TargetPick | null = null;
67
+
59
68
  private name = '';
60
69
 
61
70
  private resume = false;
@@ -72,6 +81,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
72
81
 
73
82
  open(resume = false) {
74
83
  this.resume = resume;
84
+ this.target = null;
75
85
 
76
86
  const config = loadConfig();
77
87
 
@@ -99,7 +109,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
99
109
 
100
110
  const edit = planTextEdit(buf, this.input, {
101
111
  isLeaderKey: this.deps.isLeaderKey,
102
- moves: this.step === 'agent' || this.step === 'dir',
112
+ moves: this.step === 'agent' || this.step === 'dir' || this.step === 'target',
103
113
  });
104
114
 
105
115
  switch (edit.kind) {
@@ -131,6 +141,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
131
141
  return;
132
142
  }
133
143
  case 'input': {
144
+ // The target step takes no text: its list is short and fixed.
145
+ if (this.step === 'target') {
146
+ return;
147
+ }
148
+
134
149
  this.input = edit.value;
135
150
 
136
151
  this.render();
@@ -170,6 +185,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
170
185
  input: this.input,
171
186
  hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
172
187
  });
188
+ } else if (this.step === 'target') {
189
+ this.selected = Math.min(this.selected, this.targets.length - 1);
190
+
191
+ drawPicker({
192
+ title: `${verb}: target`,
193
+ items: this.targets.map((t) => formatTargetPick(t)),
194
+ selected: this.selected,
195
+ input: '',
196
+ hint: this.refusal ?? `where ${formatDir(this.dir)} runs · ↑↓ move · ⏎ select · esc back`,
197
+ dimmed: new Set(
198
+ this.targets.flatMap((t, i) =>
199
+ t.takesWorkspace || (t.available && t.inPlace) ? [] : [i],
200
+ ),
201
+ ),
202
+ });
173
203
  } else if (this.step === 'name') {
174
204
  drawPicker({
175
205
  title: `${verb}: name`,
@@ -209,8 +239,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
209
239
  );
210
240
 
211
241
  this.step = 'agent';
212
- } else if (this.step === 'name') {
242
+ } else if (this.step === 'target') {
213
243
  this.step = 'dir';
244
+ } else if (this.step === 'name') {
245
+ this.openTargetOrDir('dir');
214
246
  } else {
215
247
  this.step = 'name';
216
248
  }
@@ -246,6 +278,27 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
246
278
 
247
279
  this.dir = chosen;
248
280
  this.input = '';
281
+
282
+ this.openTargetOrDir('name');
283
+ } else if (this.step === 'target') {
284
+ const pick = this.targets[this.selected];
285
+
286
+ if (pick === undefined) {
287
+ return;
288
+ }
289
+
290
+ const refusal = findTargetRefusal(pick);
291
+
292
+ if (refusal !== null) {
293
+ this.refusal = refusal;
294
+
295
+ this.render();
296
+
297
+ return;
298
+ }
299
+
300
+ this.target = pick;
301
+ this.input = '';
249
302
  this.step = 'name';
250
303
  } else if (this.step === 'name') {
251
304
  this.name = this.input.trim();
@@ -280,9 +333,44 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
280
333
  : collectPathCompletions(this.input, process.cwd(), homedir());
281
334
  }
282
335
 
336
+ // Opens the target step when the daemon has more than one target, with
337
+ // the chosen or default target selected, and the fallback step otherwise.
338
+ private openTargetOrDir(fallback: 'dir' | 'name') {
339
+ if (this.targets.length < 2) {
340
+ // The one target is the spawn's, default or not, so the spawn names it
341
+ // and its provider decides whether a directory runs in place.
342
+ this.target = this.targets[0] ?? null;
343
+ this.step = fallback;
344
+
345
+ return;
346
+ }
347
+
348
+ const preferred = this.target?.id;
349
+
350
+ this.selected = Math.max(
351
+ 0,
352
+ this.targets.findIndex((t) => (preferred === undefined ? t.isDefault : t.id === preferred)),
353
+ );
354
+
355
+ this.step = 'target';
356
+ }
357
+
283
358
  private async openDirStep() {
284
359
  let recent: string[] = [];
285
360
 
361
+ try {
362
+ const listed = await this.deps.sendRequest('agents.list');
363
+
364
+ const picks = collectTargetPicks(listed);
365
+
366
+ // An adopt resumes a session from its history on this host, which a
367
+ // fresh checkout elsewhere does not hold, so it is offered only the
368
+ // targets that run here.
369
+ this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
370
+ } catch {
371
+ this.targets = [];
372
+ }
373
+
286
374
  try {
287
375
  const answer = await this.deps.sendRequest('dirs.list');
288
376
 
@@ -318,6 +406,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
318
406
  rows: this.deps.ptyRows(),
319
407
  ...(this.resume ? { resume: true } : {}),
320
408
  agent: this.agent,
409
+ ...this.buildTargetParams(),
321
410
  });
322
411
 
323
412
  const spawned = this.deps.toMirrorSession(ok['session']);
@@ -347,4 +436,41 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
347
436
 
348
437
  this.deps.toBase();
349
438
  }
439
+
440
+ // A chosen target goes with the spawn. A local directory runs in place on
441
+ // a target on the daemon's own machine, and is materialized at the same
442
+ // path from its pushed HEAD on any other.
443
+ private buildTargetParams(): Readonly<Record<string, unknown>> {
444
+ if (this.target === null) {
445
+ return {};
446
+ }
447
+
448
+ return this.target.inPlace
449
+ ? { target: this.target.id }
450
+ : { target: this.target.id, workspace: { kind: 'path', path: this.dir } };
451
+ }
452
+ }
453
+
454
+ function formatTargetPick(pick: TargetPick): string {
455
+ const notes = [
456
+ pick.provider,
457
+ ...(pick.isDefault ? ['default'] : []),
458
+ ...(pick.available ? [] : ['unavailable']),
459
+ ...(pick.available && !pick.takesWorkspace ? ['no workspace'] : []),
460
+ ];
461
+
462
+ return `${pick.id} ${notes.join(' · ')}`;
463
+ }
464
+
465
+ // Why a local directory cannot run on a target, or null when it can.
466
+ function findTargetRefusal(pick: TargetPick): string | null {
467
+ if (!pick.available) {
468
+ return `target '${pick.id}' is unavailable on this daemon · esc back`;
469
+ }
470
+
471
+ if (!pick.inPlace && !pick.takesWorkspace) {
472
+ return `target '${pick.id}' cannot take a workspace · esc back`;
473
+ }
474
+
475
+ return null;
350
476
  }
package/src/client/ui.ts CHANGED
@@ -387,6 +387,9 @@ export interface PickerView {
387
387
  input: string;
388
388
  placeholder?: string;
389
389
  hint: string;
390
+
391
+ // The indexes of items drawn dim: listed, but not a fit for the flow.
392
+ dimmed?: ReadonlySet<number>;
390
393
  }
391
394
 
392
395
  export function drawPicker(view: PickerView) {
@@ -397,7 +400,9 @@ export function drawPicker(view: PickerView) {
397
400
  for (const [i, item] of shown.entries()) {
398
401
  const sel = i === view.selected;
399
402
  const t = truncate(item, width - 4);
400
- const styled = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
403
+ const dim = view.dimmed?.has(i) === true;
404
+ const inverse = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
405
+ const styled = dim ? `${ESC}[90m${inverse}${ESC}[0m` : inverse;
401
406
  const plainLen = sel ? width - 4 : t.length;
402
407
 
403
408
  rowsList.push(boxRow(width, styled, plainLen));
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Why impd's credential broker may not be used for a session, or why atc
3
+ * may not clean up after one:
4
+ *
5
+ * - `auth_impd_too_old`: impd lacks grantable tokens or secret rebinds.
6
+ * - `auth_token_scope`: the token's scope is below `manage`.
7
+ * - `auth_token_too_broad`: the token can reach imps outside atc's
8
+ * namespace, through no imp patterns or a pattern whose literal text
9
+ * before its first `*` does not start with the namespace prefix.
10
+ * - `auth_imp_out_of_scope`: an imp the call touches is outside the
11
+ * token's patterns.
12
+ * - `auth_secret_not_grantable`: a bound secret is not on the token's
13
+ * grantable list.
14
+ * - `auth_runtime_mismatch`: the imp under the recorded name has another
15
+ * id than the one recorded.
16
+ */
17
+ export type BrokerAuthorityCode =
18
+ | 'auth_impd_too_old'
19
+ | 'auth_token_scope'
20
+ | 'auth_token_too_broad'
21
+ | 'auth_imp_out_of_scope'
22
+ | 'auth_secret_not_grantable'
23
+ | 'auth_runtime_mismatch';
24
+
25
+ /**
26
+ * A refusal to provision through impd's credential broker or to clean up
27
+ * after it, carrying its code and the detail the code defines.
28
+ */
29
+ export class BrokerAuthorityError extends Error {
30
+ readonly code: BrokerAuthorityCode;
31
+
32
+ readonly data: Readonly<Record<string, unknown>>;
33
+
34
+ constructor(code: BrokerAuthorityCode, message: string, data: Readonly<Record<string, unknown>>) {
35
+ super(message);
36
+
37
+ this.code = code;
38
+ this.data = data;
39
+ this.name = 'BrokerAuthorityError';
40
+ }
41
+ }
@@ -0,0 +1,83 @@
1
+ import type { ImpSecret, ImpSecretRule } from './imp-port';
2
+
3
+ /**
4
+ * One secret as a resolved binding expects impd to hold it: its kind and
5
+ * the complete set of rules, one per host.
6
+ */
7
+ export interface ExpectedSecret {
8
+ readonly secret: string;
9
+ readonly kind: ImpSecret['kind'];
10
+ readonly rules: readonly ImpSecretRule[];
11
+ }
12
+
13
+ export type SecretRuleMismatch =
14
+ | { readonly secret: string; readonly reason: 'missing' }
15
+ | {
16
+ readonly secret: string;
17
+ readonly reason: 'kind';
18
+ readonly expected: ImpSecret['kind'];
19
+ readonly actual: ImpSecret['kind'];
20
+ }
21
+ | {
22
+ readonly secret: string;
23
+ readonly reason: 'rules';
24
+ readonly expected: readonly ImpSecretRule[];
25
+ readonly actual: readonly ImpSecretRule[];
26
+ };
27
+
28
+ /**
29
+ * Compares the secrets a resolved binding expects with the secrets impd
30
+ * lists, secret by secret. Each expected secret must exist with the same
31
+ * kind and exactly the same set of rules, in any order: a rule impd holds
32
+ * that the binding lacks is a mismatch as much as a missing one, since it
33
+ * sends the credential to a host the binding never approved. An empty
34
+ * result means every expected secret matches; secrets the binding does not
35
+ * expect are ignored.
36
+ */
37
+ export function collectSecretRuleMismatches(
38
+ expected: readonly ExpectedSecret[],
39
+ secrets: readonly ImpSecret[],
40
+ ): SecretRuleMismatch[] {
41
+ const held = new Map(secrets.map((secret) => [secret.name, secret]));
42
+
43
+ const mismatches: SecretRuleMismatch[] = [];
44
+
45
+ for (const want of expected) {
46
+ const actual = held.get(want.secret);
47
+
48
+ if (actual === undefined) {
49
+ mismatches.push({ secret: want.secret, reason: 'missing' });
50
+ } else if (actual.kind !== want.kind) {
51
+ mismatches.push({
52
+ secret: want.secret,
53
+ reason: 'kind',
54
+ expected: want.kind,
55
+ actual: actual.kind,
56
+ });
57
+ } else if (!hasSameRules(want.rules, actual.rules)) {
58
+ mismatches.push({
59
+ secret: want.secret,
60
+ reason: 'rules',
61
+ expected: want.rules,
62
+ actual: actual.rules,
63
+ });
64
+ }
65
+ }
66
+
67
+ return mismatches;
68
+ }
69
+
70
+ function hasSameRules(left: readonly ImpSecretRule[], right: readonly ImpSecretRule[]): boolean {
71
+ const leftKeys = left.map((rule) => toRuleKey(rule)).toSorted();
72
+ const rightKeys = right.map((rule) => toRuleKey(rule)).toSorted();
73
+
74
+ return (
75
+ leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
76
+ );
77
+ }
78
+
79
+ // A rule as one comparable string; JSON keeps a missing user apart from
80
+ // any user a basic rule can hold.
81
+ function toRuleKey(rule: ImpSecretRule): string {
82
+ return JSON.stringify([rule.host, rule.header, rule.scheme, rule.user ?? null]);
83
+ }