@zgeoff/atc 2.15.1 → 2.16.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.15.1",
3
+ "version": "2.16.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
@@ -291,10 +291,16 @@ const main = defineCommand({
291
291
  description: 'Forward a hook event from a wrangled session to the atc socket',
292
292
  hidden: true,
293
293
  },
294
- async run() {
294
+
295
+ // No arg is required: a citty usage error exits nonzero, and
296
+ // reporters must always exit 0.
297
+ args: {
298
+ agent: { type: 'string', default: '' },
299
+ },
300
+ async run(ctx) {
295
301
  const reporter = await import('./hook-report');
296
302
 
297
- await reporter.runHookReport();
303
+ await reporter.runHookReport(ctx.args.agent);
298
304
  },
299
305
  }),
300
306
  tap: () =>
@@ -352,10 +358,15 @@ const main = defineCommand({
352
358
  description: 'Render the chained statusline for a wrangled session',
353
359
  hidden: true,
354
360
  },
355
- async run() {
361
+
362
+ // A statusline command must always exit 0 too, so no arg is required.
363
+ args: {
364
+ agent: { type: 'string', default: '' },
365
+ },
366
+ async run(ctx) {
356
367
  const statusline = await import('./statusline');
357
368
 
358
- await statusline.runStatusline();
369
+ await statusline.runStatusline(ctx.args.agent);
359
370
  },
360
371
  }),
361
372
  },
@@ -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
+ }
@@ -18,18 +18,24 @@ export interface FleetEvent {
18
18
  readonly label?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Builds the events from the sessions that may name them. Rows written
23
+ * before atc session ids stayed stable across restores carry an earlier atc
24
+ * id, so a session of `aliases` that holds a row's agent session id names
25
+ * it when no session holds the atc id; every session that may name a row
26
+ * may alias it unless the caller gives fewer.
27
+ */
21
28
  export function buildFleetEvents(
22
29
  rows: readonly StoredEvent[],
23
30
  sessions: readonly SessionDescriptor[],
31
+ aliases: readonly SessionDescriptor[] = sessions,
24
32
  ): FleetEvent[] {
25
33
  return rows.map((row) => {
26
- // Rows written before atc session ids stayed stable across restores
27
- // carry an earlier atc id, so the agent session id links them.
28
34
  const live =
35
+ sessions.find((s) => s.id === row.atcID) ??
29
36
  (row.agentSessionID === null
30
37
  ? undefined
31
- : sessions.find((s) => s.agentSessionID === row.agentSessionID)) ??
32
- sessions.find((s) => s.id === row.atcID);
38
+ : aliases.find((s) => s.agentSessionID === row.agentSessionID));
33
39
 
34
40
  return {
35
41
  cursor: encodeCursor({ kind: 'events', id: row.id }),
@@ -0,0 +1,14 @@
1
+ import type { FleetEntry } from '../store/fleet-entry';
2
+ import { buildTargetIdentity } from './build-target-identity';
3
+ import type { TargetGrant } from './target-access';
4
+
5
+ /**
6
+ * The target a fleet row's session is bound to. A row that stores no
7
+ * target ran on the implicit `local` target, under its identity.
8
+ */
9
+ export function buildGrantFromFleetEntry(entry: FleetEntry): TargetGrant {
10
+ return {
11
+ target: entry.target ?? 'local',
12
+ targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
13
+ };
14
+ }
@@ -17,17 +17,23 @@ export interface ReportView {
17
17
  readonly complete: boolean;
18
18
  }
19
19
 
20
+ /**
21
+ * Builds the view from the sessions that may name it. A row written before
22
+ * atc session ids stayed stable across restores carries an earlier atc id,
23
+ * so a session of `aliases` that holds its agent session id names it when
24
+ * no session holds the atc id. The alias only names the report: whoever
25
+ * checks who may read it checks the row's own atc id.
26
+ */
20
27
  export function buildReportView(
21
28
  stored: StoredReport,
22
29
  sessions: readonly SessionDescriptor[],
30
+ aliases: readonly SessionDescriptor[],
23
31
  ): ReportView {
24
- // A row written before atc session ids stayed stable across restores
25
- // carries an earlier atc id, so the agent session id links it.
26
32
  const live =
33
+ sessions.find((s) => s.id === stored.atcID) ??
27
34
  (stored.agentSessionID === null
28
35
  ? undefined
29
- : sessions.find((s) => s.agentSessionID === stored.agentSessionID)) ??
30
- sessions.find((s) => s.id === stored.atcID);
36
+ : aliases.find((s) => s.agentSessionID === stored.agentSessionID));
31
37
 
32
38
  return {
33
39
  report: encodeCursor({ kind: 'events', id: stored.id }),
@@ -1,8 +1,8 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
2
  import type { SessionID } from '../shared/session-id';
3
3
  import { toSessionID } from '../shared/to-session-id';
4
+ import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
4
5
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
5
- import { buildTargetIdentity } from './build-target-identity';
6
6
  import type { DaemonContext } from './daemon-context';
7
7
  import type { KeyedRequest } from './idempotency-ledger';
8
8
  import type { TargetAccess, TargetGrant } from './target-access';
@@ -10,14 +10,16 @@ import type { TargetAccess, TargetGrant } from './target-access';
10
10
  /**
11
11
  * The daemon as a principal with the given access sees it. Its keyed
12
12
  * spawns and messages hold their keys in the given namespace, apart from
13
- * every other namespace's. A session on a target outside the access does
14
- * not exist here: every lookup of it answers as a lookup of an unknown
15
- * session does, so the request's own handling refuses it with the words
16
- * and data it gives a session that never existed. Lists leave such
17
- * sessions out, and a spawn may use only a target the access holds, a
18
- * spawn's replayed answer included. A kill, or a pin, of a session whose
19
- * sub-sessions reach past the access is refused whole, before anything
20
- * changes.
13
+ * every other namespace's. A session exists here only when the access
14
+ * reaches every session in its tree: its top-level session and each
15
+ * sub-session of that one. Every lookup of any other session answers as a
16
+ * lookup of an unknown session does, so the request's own handling refuses
17
+ * it with the words and data it gives a session that never existed. Lists
18
+ * leave such sessions out, the directory list leaves out directories
19
+ * spawned only on targets outside the access, and a spawn may use only a
20
+ * target the access holds, a spawn's replayed answer included. A kill, a
21
+ * forget, or a change checks the tree in the same synchronous step that
22
+ * starts it, so a sub-session added after the check is never part of it.
21
23
  */
22
24
  export function buildScopedContext(
23
25
  ctx: DaemonContext,
@@ -28,11 +30,11 @@ export function buildScopedContext(
28
30
  const buildPrincipalKey = (keyed: KeyedRequest | null): KeyedRequest | null =>
29
31
  keyed === null ? null : { ...keyed, principal: keyNamespace };
30
32
 
31
- const canSee = (id: SessionID): boolean => {
32
- const grant = ctx.findSessionGrant(id);
33
+ const canSee = (id: SessionID): boolean => ctx.canSeeSession(id, access);
33
34
 
34
- return grant !== null && access.canUse(grant);
35
- };
35
+ // A request's own access narrowed to this one.
36
+ const mergeAccess = (outer: TargetAccess | null): TargetAccess =>
37
+ outer === null ? access : outer.merge(access);
36
38
 
37
39
  const canUseTarget = (target: string): boolean => {
38
40
  const targetIdentity = ctx.findTargetIdentity(target);
@@ -57,10 +59,7 @@ export function buildScopedContext(
57
59
  return null;
58
60
  }
59
61
 
60
- return {
61
- target: entry.target ?? 'local',
62
- targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
63
- };
62
+ return buildGrantFromFleetEntry(entry);
64
63
  };
65
64
 
66
65
  // Throws the refusal a fresh spawn to the session's target gets when a
@@ -73,24 +72,9 @@ export function buildScopedContext(
73
72
  }
74
73
  };
75
74
 
76
- // Throws the refusal of a change to a session whose sub-sessions the
77
- // change would reach too when any of them is out of reach. The refusal
78
- // holds only the session the caller sent, never a sub-session's id or
79
- // how many there are.
80
- const requireTreeInReach = (id: SessionID, action: string): void => {
81
- if (ctx.collectChildIDs(id).every((child) => canSee(child))) {
82
- return;
83
- }
84
-
85
- throw new DaemonError(
86
- 'target_forbidden',
87
- `this client may not ${action} session '${id}': it has a sub-session on a target this client may not use`,
88
- { session: id },
89
- );
90
- };
91
-
92
75
  return {
93
76
  ...ctx,
77
+ isSessionVisible: canSee,
94
78
  collectSessions: () => ctx.collectSessions().filter((session) => canSee(session.id)),
95
79
  collectAgents: () => {
96
80
  const list = ctx.collectAgents();
@@ -115,11 +99,12 @@ export function buildScopedContext(
115
99
  }),
116
100
  };
117
101
  },
118
- collectFleet: async () => {
119
- const fleet = await ctx.collectFleet();
102
+ collectSpawnDirs: (outer) => {
103
+ const merged = outer === null ? access : outer.merge(access);
120
104
 
121
- return fleet.filter((entry) => canSee(entry.sessionID));
105
+ return ctx.collectSpawnDirs(merged);
122
106
  },
107
+ resolveSpawnParent: (id) => (canSee(id) ? ctx.resolveSpawnParent(id) : 'missing'),
123
108
  resolveSpawnTarget: (requested) => {
124
109
  if (requested !== undefined) {
125
110
  requireTarget(canUseTarget, requested);
@@ -132,12 +117,10 @@ export function buildScopedContext(
132
117
  return target;
133
118
  },
134
119
  spawnSession: async (plan, keyed) => {
135
- let answer: Readonly<Record<string, unknown>>;
136
-
137
120
  // A held key's replay is checked against the target its session was
138
121
  // bound to, inside the spawn, before its answer leaves the daemon.
139
122
  try {
140
- answer = await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
123
+ return await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
141
124
  } catch (error) {
142
125
  // A held key's refusal may carry the session its spawn made.
143
126
  if (error instanceof DaemonError && typeof error.data?.['effectRef'] === 'string') {
@@ -146,29 +129,17 @@ export function buildScopedContext(
146
129
 
147
130
  throw error;
148
131
  }
149
-
150
- return answer;
151
132
  },
152
- killSession: (id) => {
153
- if (!canSee(id)) {
154
- return Promise.resolve(false);
155
- }
156
133
 
157
- requireTreeInReach(id, 'kill');
158
-
159
- return ctx.killSession(id);
160
- },
161
- updateSession: (id, name, pinned) => {
162
- if (!canSee(id)) {
163
- return false;
164
- }
134
+ // The daemon's kill takes the session's sub-sessions before its first
135
+ // await, so the set it kills is the set this check saw.
136
+ killSession: (id) => (canSee(id) ? ctx.killSession(id) : Promise.resolve(false)),
165
137
 
166
- if (pinned !== undefined) {
167
- requireTreeInReach(id, 'pin');
168
- }
169
-
170
- return ctx.updateSession(id, name, pinned);
171
- },
138
+ // A session out of reach answers before any confirm token is handed
139
+ // out or taken, so its host is never touched.
140
+ forgetSession: (id, confirmToken) =>
141
+ canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
142
+ updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
172
143
  ackSession: (id) => canSee(id) && ctx.ackSession(id),
173
144
  buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
174
145
  readSessionScreen: (id) =>
@@ -190,12 +161,16 @@ export function buildScopedContext(
190
161
  writeSessionLine: (sessionID, text) =>
191
162
  canSee(sessionID) ? ctx.writeSessionLine(sessionID, text) : 'missing',
192
163
  ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
193
- adoptSession: (id, cols, rows) =>
194
- canSee(id) ? ctx.adoptSession(id, cols, rows) : Promise.resolve('missing' as const),
164
+ adoptSession: (id, cols, rows, outer) =>
165
+ canSee(id)
166
+ ? ctx.adoptSession(id, cols, rows, mergeAccess(outer))
167
+ : Promise.resolve('missing' as const),
195
168
  resizeSession: (client, sessionID, dims) =>
196
169
  canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
197
- readSessionRecord: (id) =>
198
- canSee(id) ? ctx.readSessionRecord(id) : Promise.resolve('missing' as const),
170
+ readSessionRecord: (id, outer) =>
171
+ canSee(id)
172
+ ? ctx.readSessionRecord(id, mergeAccess(outer))
173
+ : Promise.resolve('missing' as const),
199
174
  loadSessionTranscript: (id, from, limit) =>
200
175
  canSee(id) ? ctx.loadSessionTranscript(id, from, limit) : Promise.resolve('missing' as const),
201
176
  readEvents: (afterID, limit, waitMs, sessionID, outer) => {
@@ -203,31 +178,43 @@ export function buildScopedContext(
203
178
 
204
179
  return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
205
180
  },
206
- readReport: (id, outer) => {
207
- const merged = outer === null ? access : outer.merge(access);
208
-
209
- return ctx.readReport(id, merged);
210
- },
211
- writeSessionMessage: (sessionID, from, text, keyed) =>
181
+ readReport: (id, outer) => ctx.readReport(id, mergeAccess(outer)),
182
+ writeSessionMessage: (sessionID, from, text, keyed, outer) =>
212
183
  canSee(sessionID)
213
- ? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
184
+ ? ctx.writeSessionMessage(
185
+ sessionID,
186
+ from,
187
+ text,
188
+ buildPrincipalKey(keyed),
189
+ mergeAccess(outer),
190
+ )
214
191
  : Promise.resolve('missing' as const),
215
192
  readMessage: async (messageID, waitMs) => {
216
193
  // The owner is checked before any wait, so a message outside the
217
194
  // access answers at once, as an unknown message does.
218
195
  const view = await ctx.readMessage(messageID, 0);
219
196
 
220
- if (view === null || !canSee(view.session)) {
197
+ // Only the session the message was sent to owns it here, never one
198
+ // that shares its agent session id.
199
+ if (view === null || view.session !== view.record.atcID || !canSee(view.session)) {
221
200
  return null;
222
201
  }
223
202
 
224
- return waitMs === 0 ? view : ctx.readMessage(messageID, waitMs);
203
+ if (waitMs === 0) {
204
+ return view;
205
+ }
206
+
207
+ // The message may move to another session that holds its agent
208
+ // session id while the read waits.
209
+ const waited = await ctx.readMessage(messageID, waitMs);
210
+
211
+ return waited !== null && waited.session === waited.record.atcID ? waited : null;
225
212
  },
226
- attachTap: (client, sessionID) =>
227
- canSee(sessionID) ? ctx.attachTap(client, sessionID) : 'missing',
228
- ackMessage: (client, sessionID, messageID) =>
213
+ attachTap: (client, sessionID, outer) =>
214
+ canSee(sessionID) ? ctx.attachTap(client, sessionID, mergeAccess(outer)) : 'missing',
215
+ ackMessage: (client, sessionID, messageID, outer) =>
229
216
  canSee(sessionID)
230
- ? ctx.ackMessage(client, sessionID, messageID)
217
+ ? ctx.ackMessage(client, sessionID, messageID, mergeAccess(outer))
231
218
  : Promise.resolve('unknown' as const),
232
219
  };
233
220
  }
@@ -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
+ }