@zgeoff/atc 2.19.0 → 2.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.19.0",
3
+ "version": "2.20.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",
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import type { DaemonError } from '../protocol/daemon-error';
2
3
  import type { HookEvent } from '../protocol/hook-event';
3
4
  import type { AgentID } from '../shared/agent-id';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
@@ -205,6 +206,10 @@ export interface AgentAdapter {
205
206
  // Absent: the agent runs there as a local spawn plans it, with no files.
206
207
  readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
207
208
 
209
+ // The refusal every start of this agent's harness gets, on any target,
210
+ // or null when it may start. Absent: no start is refused.
211
+ readonly findSpawnRefusal?: () => DaemonError | null;
212
+
208
213
  // The command that exits 0 inside a remote host when the agent there can
209
214
  // sign in without a person. Absent: atc runs no check.
210
215
  readonly planAuthCheck?: () => readonly string[];
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import { DaemonError } from '../protocol/daemon-error';
2
3
  import type { HookEvent } from '../protocol/hook-event';
3
4
  import type { AgentID } from '../shared/agent-id';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
@@ -39,7 +40,8 @@ export class GatewayAdapter implements AgentAdapter {
39
40
  readonly id: AgentID;
40
41
 
41
42
  // A headless turn carries the same settings file the terminal spawn does,
42
- // so it reaches this backend rather than the default one.
43
+ // so it reaches this backend rather than the default one. A gateway whose
44
+ // credential comes through impd's broker runs no headless turn.
43
45
  readonly headlessRunner: HeadlessRunner | null;
44
46
 
45
47
  // The CLI's hooks are authoritative; no screen heuristics needed.
@@ -90,7 +92,7 @@ export class GatewayAdapter implements AgentAdapter {
90
92
  this.claude = new ClaudeAdapter(config);
91
93
 
92
94
  this.headlessRunner =
93
- headlessRun === null
95
+ headlessRun === null || gateway.auth !== undefined
94
96
  ? null
95
97
  : makeClaudeHeadlessRunner(headlessRun, {
96
98
  claudeBin: gateway.bin,
@@ -100,6 +102,21 @@ export class GatewayAdapter implements AgentAdapter {
100
102
  });
101
103
  }
102
104
 
105
+ // A gateway whose credential comes through impd's broker never starts
106
+ // until the broker path exists: started without it, the CLI would send
107
+ // whatever credential it holds to the gateway's host.
108
+ findSpawnRefusal(): DaemonError | null {
109
+ if (this.gateway.auth === undefined) {
110
+ return null;
111
+ }
112
+
113
+ return new DaemonError(
114
+ 'auth_target_unsupported',
115
+ `gateway '${this.id}' takes its credential from impd's broker, and brokered credentials are not wired yet`,
116
+ { agent: this.id },
117
+ );
118
+ }
119
+
103
120
  planSpawn(opts: SpawnOptions): SpawnPlan {
104
121
  return {
105
122
  bin: this.gateway.bin,
@@ -142,7 +159,13 @@ export class GatewayAdapter implements AgentAdapter {
142
159
  // explicit flag, so that mode overrides the one the CLI would restore, and
143
160
  // the generated settings file, because
144
161
  // without it the CLI would resume the session against the default backend.
162
+ // A gateway whose credential comes through impd's broker has none, since
163
+ // outside atc the broker never reaches it.
145
164
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
165
+ if (this.gateway.auth !== undefined) {
166
+ return null;
167
+ }
168
+
146
169
  const args = [
147
170
  ...this.gateway.args,
148
171
  ...buildRestoreModeArgs(this.gateway.args, this.gateway.settings),
package/src/cli.ts CHANGED
@@ -182,7 +182,12 @@ const main = defineCommand({
182
182
  console.error(`atc daemon: config: ${line}`);
183
183
  }
184
184
 
185
- for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
185
+ for (const problem of [
186
+ ...cfg.principalErrors,
187
+ ...cfg.workspaceErrors,
188
+ ...cfg.authProfileErrors,
189
+ ...cfg.gatewayErrors,
190
+ ]) {
186
191
  console.error(`atc daemon: config: ${problem}`);
187
192
  }
188
193
 
@@ -9,7 +9,8 @@ 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. Resolution follows the rule a spawn
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
13
14
  * follows: a bare name comes off PATH, a name carrying a separator is taken
14
15
  * as a path, and either way it has to be executable.
15
16
  */
@@ -22,7 +23,9 @@ export function collectAgentPicks(config: Config): AgentPick[] {
22
23
  { agent: 'claude', label: 'Claude', bin: config.claudeBin },
23
24
  { agent: 'grok', label: 'Grok', bin: config.grokBin },
24
25
  { agent: 'codex', label: 'Codex', bin: config.codexBin },
25
- ...config.gateways.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
26
+ ...config.gateways
27
+ .filter((g) => g.auth === undefined)
28
+ .map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
26
29
  ];
27
30
 
28
31
  return candidates
@@ -1,7 +1,8 @@
1
1
  import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
2
2
 
3
3
  interface AgentCapabilities {
4
- // Only an installed agent can start a session.
4
+ // Only an installed agent whose starts are not all refused can start a
5
+ // session.
5
6
  readonly spawn: boolean;
6
7
  readonly readTranscript: boolean;
7
8
 
@@ -51,6 +52,7 @@ export function buildAgentList(
51
52
  return adapters.map((adapter) => {
52
53
  const profile = adapter.profile;
53
54
  const installed = profile === undefined ? false : isInstalled(profile.bin);
55
+ const spawnable = installed && (adapter.findSpawnRefusal?.() ?? null) === null;
54
56
 
55
57
  return {
56
58
  id: adapter.id,
@@ -58,7 +60,7 @@ export function buildAgentList(
58
60
  kind: profile?.kind ?? adapter.id,
59
61
  installed,
60
62
  capabilities: {
61
- spawn: installed,
63
+ spawn: spawnable,
62
64
  readTranscript: adapter.parseTranscriptLine !== undefined,
63
65
  message: adapter.takesMessages,
64
66
  attach: true,
@@ -67,8 +69,8 @@ export function buildAgentList(
67
69
  },
68
70
  models: profile?.models ?? null,
69
71
  spawnOptions: {
70
- model: buildSpawnOptionEntry(profile?.spawnOptions.model, installed),
71
- effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, installed),
72
+ model: buildSpawnOptionEntry(profile?.spawnOptions.model, spawnable),
73
+ effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, spawnable),
72
74
  },
73
75
  };
74
76
  });
@@ -86,9 +88,9 @@ const NO_SPAWN_OPTION: SpawnOptionSpec = {
86
88
 
87
89
  function buildSpawnOptionEntry(
88
90
  spec: SpawnOptionSpec | undefined,
89
- installed: boolean,
91
+ spawnable: boolean,
90
92
  ): SpawnOptionEntry {
91
93
  const resolved = spec ?? NO_SPAWN_OPTION;
92
94
 
93
- return { ...resolved, available: installed && resolved.supported };
95
+ return { ...resolved, available: spawnable && resolved.supported };
94
96
  }
@@ -0,0 +1,58 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AgentID } from '../shared/agent-id';
3
+ import type { AuthProfile } from '../shared/collect-auth-profiles';
4
+ import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
5
+ import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
6
+ import type { AuthProfileProblem, ResolvedAuthSecret } from '../shared/resolve-auth-profiles';
7
+ import { sortJSONKeys } from '../shared/sort-json-keys';
8
+
9
+ /**
10
+ * What a session's runtime is bound to through impd's broker: every
11
+ * profile its gateway's selection reaches, the secrets they need impd to
12
+ * hold with exactly these rules, the placeholder variables in place of a
13
+ * credential, and the endpoint. `hash` covers the secrets and their rules
14
+ * alone, so a change to a profile the binding does not reach leaves it as
15
+ * it was, and a change to one it reaches gives another hash.
16
+ */
17
+ export interface AuthBinding {
18
+ readonly agent: AgentID;
19
+ readonly baseURL: string;
20
+ readonly profiles: readonly string[];
21
+ readonly secrets: readonly ResolvedAuthSecret[];
22
+ readonly placeholderEnv: Readonly<Record<string, string>>;
23
+ readonly hash: string;
24
+ }
25
+
26
+ type AuthGateway = Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
27
+
28
+ /**
29
+ * Plans the binding a gateway's auth asks for against the current auth
30
+ * profiles, or the problem that refuses it. The hash is the SHA-256 of the
31
+ * resolved secrets as canonical JSON, with each list sorted, so the order
32
+ * of the selection or of the config never changes it.
33
+ */
34
+ export function buildAuthBinding(
35
+ gateway: AuthGateway,
36
+ profiles: ReadonlyMap<string, AuthProfile>,
37
+ ): { readonly binding: AuthBinding } | { readonly problem: AuthProfileProblem } {
38
+ const resolution = resolveAuthProfiles(profiles, gateway.auth.profiles);
39
+
40
+ if ('problem' in resolution) {
41
+ return resolution;
42
+ }
43
+
44
+ const secrets = resolution.resolved.secrets;
45
+
46
+ return {
47
+ binding: {
48
+ agent: gateway.id,
49
+ baseURL: gateway.baseURL,
50
+ profiles: resolution.resolved.profiles,
51
+ secrets,
52
+ placeholderEnv: gateway.auth.placeholderEnv,
53
+ hash: createHash('sha256')
54
+ .update(JSON.stringify(sortJSONKeys({ secrets })))
55
+ .digest('hex'),
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The imp a host key runs on: the target's imp name prefix and the first
3
+ * 20 letters and digits of the key, which is the atc session id of the
4
+ * session that owns the host.
5
+ */
6
+ export function buildImpName(impPrefix: string, hostKey: string): string {
7
+ return `${impPrefix}${hostKey
8
+ .toLowerCase()
9
+ .replaceAll(/[^a-z0-9]/g, '')
10
+ .slice(0, 20)}`;
11
+ }
@@ -17,8 +17,12 @@ export interface ImpProviderBuild {
17
17
  * so no credential sits in the config. `tokenEnv` holds the name of the
18
18
  * daemon's environment variable that carries the token, read once here.
19
19
  * `tokenFile` holds the path of a file that carries it, read here and again
20
- * before each impd call and connection. `image`, `memoryMib`, `guestDir`,
21
- * and `guestATC` pass through. No provider when `url` is missing, so the
20
+ * before each impd call and connection. `impPrefix` starts every imp name
21
+ * the target builds, `atc-` when absent, and must be a lowercase letter
22
+ * followed by up to 10 lowercase letters, digits or hyphens, so the name
23
+ * it builds with a host key's 20 characters stays within impd's 31. `image`,
24
+ * `memoryMib`, `guestDir`, and `guestATC` pass through. No provider when
25
+ * `url` is missing, so the
22
26
  * target lists and refuses every spawn. Both token options, either one not
23
27
  * a non-empty string, an unset or empty variable, or an empty or unreadable
24
28
  * file is a problem and leaves no provider; a target without either calls
@@ -41,7 +45,17 @@ export function buildImpProvider(
41
45
  return { provider: null, problem: source };
42
46
  }
43
47
 
48
+ const impPrefix = options['impPrefix'];
49
+
50
+ if (impPrefix !== undefined && !isImpPrefix(impPrefix)) {
51
+ return {
52
+ provider: null,
53
+ problem: `target ${JSON.stringify(id)} must give impPrefix as a lowercase letter followed by up to 10 lowercase letters, digits or hyphens, so every imp name it builds is one impd accepts`,
54
+ };
55
+ }
56
+
44
57
  const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
58
+ ...(impPrefix === undefined ? {} : { impPrefix }),
45
59
  ...pickString(options, 'image'),
46
60
  ...pickString(options, 'guestDir'),
47
61
  ...pickString(options, 'guestATC'),
@@ -105,6 +119,15 @@ function loadTokenSource(
105
119
  return { readToken: () => token };
106
120
  }
107
121
 
122
+ // impd's imp name rule, a lowercase letter then up to 30 lowercase letters,
123
+ // digits or hyphens, with room left for the 20 characters of a host key.
124
+ // It holds no `*`, so the prefix never reads as an imp pattern.
125
+ const IMP_PREFIX = /^[a-z][a-z0-9-]{0,10}$/;
126
+
127
+ function isImpPrefix(value: unknown): value is string {
128
+ return typeof value === 'string' && IMP_PREFIX.test(value);
129
+ }
130
+
108
131
  function pickString(
109
132
  options: Readonly<Record<string, unknown>>,
110
133
  key: 'image' | 'guestDir' | 'guestATC',
@@ -751,6 +751,13 @@ export class DaemonConnection {
751
751
  );
752
752
  }
753
753
 
754
+ // Checked before any workspace is materialized for the spawn.
755
+ const refusal = adapter.findSpawnRefusal?.() ?? null;
756
+
757
+ if (refusal !== null) {
758
+ throw refusal;
759
+ }
760
+
754
761
  const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
755
762
 
756
763
  if (!overrides.ok) {
@@ -19,8 +19,10 @@ export interface ImpPort {
19
19
  // The names of the secrets granted to an imp.
20
20
  readonly readGrants: (name: string) => Promise<readonly string[]>;
21
21
 
22
- // Grants a secret to an imp; granting one it already holds changes
23
- // nothing, and nothing in the result distinguishes the two.
22
+ // Grants a secret to an imp. impd's grant add is idempotent: granting one
23
+ // the imp already holds changes nothing, and impd returns no creation or
24
+ // ownership receipt, so nothing in the result tells a new grant from an
25
+ // existing one or shows who added it.
24
26
  readonly createGrant: (name: string, secret: string) => Promise<void>;
25
27
 
26
28
  // Revokes a secret from an imp, and reports whether impd held the grant.
@@ -1,6 +1,7 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
2
  import { LineDecoder } from '../protocol/line-decoder';
3
3
  import { isCompiledBinary } from '../shared/is-compiled-binary';
4
+ import { buildImpName } from './build-imp-name';
4
5
  import { buildTarArchive } from './build-tar-archive';
5
6
  import type {
6
7
  CommandResult,
@@ -20,10 +21,12 @@ import { ImpPortError } from './imp-port-error';
20
21
  /**
21
22
  * The options an `imp` target takes beside its provider: the image a new
22
23
  * imp boots, the memory it gets, the folder inside each imp that atc's
23
- * files go under, and the path of an atc binary already installed in the
24
- * image. None holds a credential.
24
+ * files go under, the path of an atc binary already installed in the
25
+ * image, and the prefix every imp name the target builds starts with.
26
+ * None holds a credential.
25
27
  */
26
28
  export interface ImpTargetOptions {
29
+ readonly impPrefix?: string;
27
30
  readonly image?: string;
28
31
  readonly memoryMib?: number;
29
32
  readonly guestDir?: string;
@@ -48,6 +51,9 @@ interface ImpProviderOptions {
48
51
  // none.
49
52
  const GUEST_DIR = '/tmp/atc';
50
53
 
54
+ // The start of every imp name when the target sets no prefix.
55
+ const IMP_PREFIX = 'atc-';
56
+
51
57
  // impd takes a lease of 10 to 3600 seconds; the daemon renews at a third of it.
52
58
  const LEASE_SECONDS = 600;
53
59
 
@@ -86,6 +92,10 @@ export class ImpProvider implements ExecutionProvider {
86
92
 
87
93
  readonly guest: GuestLayout;
88
94
 
95
+ // The literal start of every imp name this provider builds: the runtime
96
+ // namespace an impd token's imp patterns are checked against.
97
+ readonly impPrefix: string;
98
+
89
99
  private readonly port: ImpPort;
90
100
 
91
101
  private readonly target: ImpTargetOptions;
@@ -109,6 +119,7 @@ export class ImpProvider implements ExecutionProvider {
109
119
  constructor(port: ImpPort, target: ImpTargetOptions, options: ImpProviderOptions = {}) {
110
120
  this.port = port;
111
121
  this.target = target;
122
+ this.impPrefix = target.impPrefix ?? IMP_PREFIX;
112
123
  this.leaseSeconds = options.leaseSeconds ?? LEASE_SECONDS;
113
124
  this.reconnectDelaysMs = options.reconnectDelaysMs ?? RECONNECT_DELAYS_MS;
114
125
 
@@ -123,10 +134,15 @@ export class ImpProvider implements ExecutionProvider {
123
134
  };
124
135
  }
125
136
 
137
+ // The imp a host key runs on, under this provider's prefix.
138
+ getImpName(hostKey: string): string {
139
+ return buildImpName(this.impPrefix, hostKey);
140
+ }
141
+
126
142
  // Creates the host's imp when impd holds none, then takes the daemon's
127
143
  // lease, which boots or wakes the imp.
128
144
  readonly prepareHost = async (request: HostRequest): Promise<void> => {
129
- const name = buildImpName(request.host);
145
+ const name = this.getImpName(request.host);
130
146
  const label = `atc-${request.daemonID}`;
131
147
 
132
148
  this.label = label;
@@ -181,7 +197,7 @@ export class ImpProvider implements ExecutionProvider {
181
197
 
182
198
  readonly spawnHarness = (spec: HarnessSpec): HarnessHandle => {
183
199
  const host = this.hosts.get(spec.host) ?? {
184
- name: buildImpName(spec.host),
200
+ name: this.getImpName(spec.host),
185
201
  harnesses: 0,
186
202
  renewTimer: null,
187
203
  suspending: false,
@@ -257,7 +273,7 @@ export class ImpProvider implements ExecutionProvider {
257
273
  // carries what impd showed of the other leases.
258
274
  readonly suspendHost = async (hostKey: string): Promise<void> => {
259
275
  const host = this.hosts.get(hostKey) ?? {
260
- name: buildImpName(hostKey),
276
+ name: this.getImpName(hostKey),
261
277
  harnesses: 0,
262
278
  renewTimer: null,
263
279
  suspending: false,
@@ -290,7 +306,7 @@ export class ImpProvider implements ExecutionProvider {
290
306
  // A destroy ends every lease on the imp with it.
291
307
  readonly destroyHost = async (hostKey: string): Promise<void> => {
292
308
  const host = this.hosts.get(hostKey);
293
- const name = buildImpName(hostKey);
309
+ const name = this.getImpName(hostKey);
294
310
 
295
311
  if (host !== undefined) {
296
312
  this.stopRenewal(host);
@@ -405,9 +421,9 @@ export class ImpProvider implements ExecutionProvider {
405
421
  }
406
422
 
407
423
  try {
408
- return await this.port.runCommand(buildImpName(hostKey), command);
424
+ return await this.port.runCommand(this.getImpName(hostKey), command);
409
425
  } catch (error) {
410
- throw toHostRefusal(error, buildImpName(hostKey));
426
+ throw toHostRefusal(error, this.getImpName(hostKey));
411
427
  }
412
428
  }
413
429
 
@@ -496,17 +512,6 @@ interface ImpHost {
496
512
  suspending: boolean;
497
513
  }
498
514
 
499
- /**
500
- * The imp a host key runs on: `atc-` and the first 20 letters and digits of
501
- * the key, which is the atc session id of the session that owns the host.
502
- */
503
- function buildImpName(hostKey: string): string {
504
- return `atc-${hostKey
505
- .toLowerCase()
506
- .replaceAll(/[^a-z0-9]/g, '')
507
- .slice(0, 20)}`;
508
- }
509
-
510
515
  function buildImpSessionName(sessionID: string): string {
511
516
  return `atc-${sessionID
512
517
  .toLowerCase()
@@ -185,7 +185,7 @@ function isSameSession(s: Session, entry: FleetEntry): boolean {
185
185
 
186
186
  // The refusals that leave one session without a terminal: its target is
187
187
  // misconfigured, gone from the config, changed, has no provider here, or
188
- // cannot start a terminal.
188
+ // cannot start a terminal, or its agent refuses every start.
189
189
  const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
190
190
  'unsupported_operation',
191
191
  'unknown_target',
@@ -194,6 +194,7 @@ const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
194
194
  'target_config_invalid',
195
195
  'host_unavailable',
196
196
  'auth_not_configured',
197
+ 'auth_target_unsupported',
197
198
  ]);
198
199
 
199
200
  async function tryAdoptTerminal(
@@ -857,6 +857,12 @@ export class SessionManager {
857
857
  target: string,
858
858
  options: SpawnOptions,
859
859
  ): Promise<SpawnPlan> {
860
+ const refusal = adapter.findSpawnRefusal?.() ?? null;
861
+
862
+ if (refusal !== null) {
863
+ throw refusal;
864
+ }
865
+
860
866
  if (!provider.remote) {
861
867
  await provider.prepareHost({ host: hostKey, daemonID: this.store.daemonID });
862
868
 
@@ -48,6 +48,7 @@ const ERROR_CODES = [
48
48
  'github_unavailable',
49
49
  'host_unavailable',
50
50
  'auth_not_configured',
51
+ 'auth_target_unsupported',
51
52
  'host_leased',
52
53
  'confirmation_required',
53
54
  'confirm_token_invalid',
@@ -0,0 +1,122 @@
1
+ import { isRecord } from './report';
2
+
3
+ /**
4
+ * A named reference to a credential impd holds: the secret's name, never
5
+ * its value, and the rule impd applies when a request reaches the host,
6
+ * plus the profiles a session selecting this one needs beside it. Only
7
+ * the `custom` kind and the `bearer` scheme are bound.
8
+ */
9
+ export interface AuthProfile {
10
+ readonly name: string;
11
+ readonly secret: string;
12
+ readonly kind: 'custom';
13
+ readonly host: string;
14
+ readonly header: string;
15
+ readonly scheme: 'bearer';
16
+ readonly dependencies: readonly string[];
17
+ }
18
+
19
+ interface AuthProfiles {
20
+ readonly profiles: ReadonlyMap<string, AuthProfile>;
21
+ readonly errors: readonly string[];
22
+ }
23
+
24
+ /**
25
+ * Reads the `authProfiles` map under impd's own rules for secret names,
26
+ * broker hosts and header names. A profile that breaks a rule is left out
27
+ * with an error, so a gateway that selects it is refused rather than bound
28
+ * to a rule impd would reject or apply differently. Dependencies are
29
+ * names only here; whether they resolve is a property of each selection.
30
+ */
31
+ export function collectAuthProfiles(raw: unknown): AuthProfiles {
32
+ if (raw === undefined) {
33
+ return { profiles: new Map(), errors: [] };
34
+ }
35
+
36
+ if (!isRecord(raw) || Array.isArray(raw)) {
37
+ return { profiles: new Map(), errors: ['authProfiles must be an object of named profiles'] };
38
+ }
39
+
40
+ const profiles = new Map<string, AuthProfile>();
41
+
42
+ const errors: string[] = [];
43
+
44
+ for (const [name, entry] of Object.entries(raw)) {
45
+ const parsed = parseAuthProfile(name, entry);
46
+
47
+ if (typeof parsed === 'string') {
48
+ errors.push(`authProfiles.${name}: ${parsed}`);
49
+ } else {
50
+ profiles.set(name, parsed);
51
+ }
52
+ }
53
+
54
+ return { profiles, errors };
55
+ }
56
+
57
+ // impd's secret name rule: an imp name's form, so a secret name is never a
58
+ // path or a flag.
59
+ const SECRET_NAME = /^[a-z][a-z0-9-]{0,30}$/;
60
+
61
+ // impd's broker host rule: a lowercase hostname as a CONNECT carries it, with
62
+ // no port and no wildcard, whose last label starts with a letter so an IP
63
+ // address never matches.
64
+ const BROKER_HOST = /^(?:[a-z0-9][a-z0-9-]{0,62}\.)+[a-z][a-z0-9-]{0,62}$/;
65
+ const BROKER_HOST_MAX = 253;
66
+
67
+ // impd's header name rule.
68
+ const HEADER_NAME = /^[a-z0-9-]{1,64}$/;
69
+
70
+ // The profile an entry holds, or the first rule it breaks.
71
+ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
72
+ if (!isRecord(entry) || Array.isArray(entry)) {
73
+ return 'a profile must be an object';
74
+ }
75
+
76
+ const secret = entry['secret'];
77
+ const host = entry['host'];
78
+ const header = entry['header'];
79
+ const scheme = entry['scheme'];
80
+ const dependencies = entry['dependencies'];
81
+
82
+ if (entry['kind'] !== undefined && entry['kind'] !== 'custom') {
83
+ return 'kind must be custom, the one kind atc binds';
84
+ }
85
+
86
+ if (typeof secret !== 'string' || !SECRET_NAME.test(secret)) {
87
+ return 'secret must be a lowercase letter followed by up to 30 lowercase letters, digits or hyphens';
88
+ }
89
+
90
+ if (typeof host !== 'string' || host.length > BROKER_HOST_MAX || !BROKER_HOST.test(host)) {
91
+ return 'host must be a lowercase hostname such as api.example.com';
92
+ }
93
+
94
+ if (typeof header !== 'string' || !HEADER_NAME.test(header)) {
95
+ return 'header must be a lowercase header name such as authorization';
96
+ }
97
+
98
+ if (scheme !== 'bearer') {
99
+ return 'scheme must be bearer, the one scheme atc binds';
100
+ }
101
+
102
+ if (entry['user'] !== undefined) {
103
+ return 'user pairs only with the basic scheme, which atc does not bind';
104
+ }
105
+
106
+ if (
107
+ dependencies !== undefined &&
108
+ (!Array.isArray(dependencies) || !dependencies.every((dep) => typeof dep === 'string'))
109
+ ) {
110
+ return 'dependencies must be an array of profile names';
111
+ }
112
+
113
+ return {
114
+ name,
115
+ secret,
116
+ kind: 'custom',
117
+ host,
118
+ header,
119
+ scheme,
120
+ dependencies: dependencies ?? [],
121
+ };
122
+ }