@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
@@ -19,6 +19,7 @@ import type { AgentID } from '../shared/agent-id';
19
19
  import { isRecord } from '../shared/report';
20
20
  import type { SessionID } from '../shared/session-id';
21
21
  import { toSessionID } from '../shared/to-session-id';
22
+ import type { SourceProvider, SourceRequest } from '../sources/types';
22
23
  import { buildPayloadHash } from './build-payload-hash';
23
24
  import { buildScopedContext } from './build-scoped-context';
24
25
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
@@ -43,6 +44,18 @@ interface ViewUpdate {
43
44
  readonly withdrawn: readonly SessionID[];
44
45
  }
45
46
 
47
+ // What a source request holds: the source's id and the target it is for.
48
+ interface SourceParams {
49
+ readonly source: string;
50
+ readonly target?: string | undefined;
51
+ }
52
+
53
+ // The source a request asks for, and the request the source serves.
54
+ interface ResolvedSourceRequest {
55
+ readonly source: SourceProvider;
56
+ readonly request: SourceRequest;
57
+ }
58
+
46
59
  interface PeerSocket extends SocketWriter {
47
60
  readonly end: () => void;
48
61
  }
@@ -409,6 +422,21 @@ export class DaemonConnection {
409
422
 
410
423
  return;
411
424
  }
425
+ case 'sources.list': {
426
+ await this.applySourcesList(req, ctx);
427
+
428
+ return;
429
+ }
430
+ case 'sources.interpret': {
431
+ await this.applySourcesInterpret(req, ctx);
432
+
433
+ return;
434
+ }
435
+ case 'git.probe': {
436
+ await this.applyGitProbe(req, ctx);
437
+
438
+ return;
439
+ }
412
440
  case 'session.spawn': {
413
441
  await this.applySpawn(req, ctx);
414
442
 
@@ -794,6 +822,107 @@ export class DaemonConnection {
794
822
  this.sendOk(req.id, spawned);
795
823
  }
796
824
 
825
+ // Lists one source's candidates for a spawn to the request's target.
826
+ private async applySourcesList(req: RequestMsg, ctx: DaemonContext): Promise<void> {
827
+ const parsed = parseRequestParams('sources.list', req.p);
828
+
829
+ if (!parsed.ok) {
830
+ this.sendErr(req.id, 'bad_args', parsed.message);
831
+
832
+ return;
833
+ }
834
+
835
+ const resolved = this.resolveSourceRequest(parsed.data, ctx);
836
+ const source = resolved.source;
837
+
838
+ const listed = await source.list(
839
+ {
840
+ ...(parsed.data.scope === undefined ? {} : { scope: parsed.data.scope }),
841
+ ...(parsed.data.text === undefined ? {} : { text: parsed.data.text }),
842
+ },
843
+ resolved.request,
844
+ );
845
+
846
+ this.sendOk(req.id, { source: source.id, scope: listed.scope, candidates: listed.candidates });
847
+ }
848
+
849
+ // Reads typed input through one source for a spawn to the request's
850
+ // target.
851
+ private async applySourcesInterpret(req: RequestMsg, ctx: DaemonContext): Promise<void> {
852
+ const parsed = parseRequestParams('sources.interpret', req.p);
853
+
854
+ if (!parsed.ok) {
855
+ this.sendErr(req.id, 'bad_args', parsed.message);
856
+
857
+ return;
858
+ }
859
+
860
+ const resolved = this.resolveSourceRequest(parsed.data, ctx);
861
+
862
+ const interpretation = await resolved.source.interpret(parsed.data.input, resolved.request);
863
+
864
+ this.sendOk(req.id, { ...interpretation });
865
+ }
866
+
867
+ // The source a request asks for and the request it serves, so a principal
868
+ // reaches a source only for a target it may spawn on, and a git source
869
+ // only for a target it may spawn a workspace on.
870
+ private resolveSourceRequest(params: SourceParams, ctx: DaemonContext): ResolvedSourceRequest {
871
+ const source = ctx.findSource(params.source);
872
+
873
+ if (source === null) {
874
+ throw new DaemonError('unsupported', `this daemon offers no source '${params.source}'`);
875
+ }
876
+
877
+ const target = ctx.resolveSpawnTarget(params.target);
878
+
879
+ if (source.kind === 'git') {
880
+ this.ctx.requireWorkspaceTarget(target);
881
+ }
882
+
883
+ // The spawn history is read through the request's own view, so a
884
+ // principal lists only directories spawned on targets it may use.
885
+ return { source, request: { target, collectSpawnDirs: () => ctx.collectSpawnDirs(null) } };
886
+ }
887
+
888
+ // Probes a git workspace source for a spawn to the request's target, so
889
+ // a principal probes only for a target it may spawn a workspace on. A
890
+ // refusal holds the repository's other URLs that the offered sources know.
891
+ private async applyGitProbe(req: RequestMsg, ctx: DaemonContext): Promise<void> {
892
+ const parsed = parseRequestParams('git.probe', req.p);
893
+
894
+ if (!parsed.ok) {
895
+ this.sendErr(req.id, 'bad_args', parsed.message);
896
+
897
+ return;
898
+ }
899
+
900
+ const data = parsed.data;
901
+
902
+ this.ctx.requireWorkspaceTarget(ctx.resolveSpawnTarget(data.target));
903
+
904
+ const access = await ctx.checkRepositoryAccess({
905
+ url: data.url,
906
+ ref: data.ref,
907
+ sha: data.sha,
908
+ credential: data.credentialRef,
909
+ });
910
+
911
+ if (!access.ok) {
912
+ const alternates = ctx.collectAlternateGitURLs(data.url);
913
+ const detail = alternates.length === 0 ? undefined : { alternates };
914
+
915
+ throw new DaemonError(access.code, access.message, detail);
916
+ }
917
+
918
+ this.sendOk(req.id, {
919
+ url: access.url,
920
+ head: access.head,
921
+ refs: access.refs,
922
+ resolved: access.resolved,
923
+ });
924
+ }
925
+
797
926
  private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
798
927
  const parsed = parseRequestParams('session.attach', req.p);
799
928
 
@@ -6,9 +6,11 @@ import type { TargetConfigError } from '../shared/collect-targets';
6
6
  import type { DaemonID } from '../shared/daemon-id';
7
7
  import type { MessageID } from '../shared/message-id';
8
8
  import type { SessionID } from '../shared/session-id';
9
+ import type { SourceKind, SourceProvider } from '../sources/types';
9
10
  import type { FleetEntry } from '../store/fleet-entry';
10
11
  import type { MessageRecord } from '../store/message-record';
11
12
  import type { TurnSibling } from '../store/state-store';
13
+ import type { checkRepositoryAccess } from '../workspace/check-repository-access';
12
14
  import type { Dims } from './attach-registry';
13
15
  import type { AgentEntry } from './build-agent-list';
14
16
  import type { FleetEvent } from './build-fleet-events';
@@ -79,6 +81,13 @@ interface AgentList {
79
81
  readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
80
82
  readonly configRevision: string;
81
83
  readonly targetErrors: readonly TargetConfigError[];
84
+
85
+ // The sources the spawn picker offers, in order.
86
+ readonly sources: readonly {
87
+ readonly id: string;
88
+ readonly label: string;
89
+ readonly kind: SourceKind;
90
+ }[];
82
91
  }
83
92
 
84
93
  // The slice of a connection the attach bookkeeping needs: identity plus the
@@ -163,6 +172,20 @@ export interface DaemonContext {
163
172
  // one whose provider cannot both transfer an archive and run a command.
164
173
  readonly requireWorkspaceTarget: (target: string) => void;
165
174
 
175
+ // The source the daemon offers the spawn picker under an id, or null when
176
+ // it offers none under it.
177
+ readonly findSource: (id: string) => SourceProvider | null;
178
+
179
+ // The other URLs the offered sources know for a git URL the daemon's host
180
+ // could not read, each once.
181
+ readonly collectAlternateGitURLs: (url: string) => readonly string[];
182
+
183
+ // Checks that the daemon's host can read a git workspace source, and
184
+ // resolves its ref, the way a workspace spawn from it would.
185
+ readonly checkRepositoryAccess: (
186
+ request: Omit<Parameters<typeof checkRepositoryAccess>[0], 'transports'>,
187
+ ) => ReturnType<typeof checkRepositoryAccess>;
188
+
166
189
  // Runs the plan, which throws the refusal for a spawn it refuses, then
167
190
  // spawns. Answers with the `session.spawn` ok payload, which a keyed
168
191
  // retry replays as the first spawn answered it while the access, when
@@ -13,12 +13,15 @@ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
13
  import type { SessionState } from '../protocol/session-state';
14
14
  import type { HooksConfig } from '../shared/collect-hooks';
15
15
  import type { TargetConfigError } from '../shared/collect-targets';
16
+ import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
17
+ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
16
18
  import { findDaemonRecord } from '../shared/find-daemon-record';
17
19
  import type { MessageID } from '../shared/message-id';
18
20
  import { isRecord } from '../shared/report';
19
21
  import type { SessionID } from '../shared/session-id';
20
22
  import { toMessageID } from '../shared/to-message-id';
21
23
  import { truncateToBytes } from '../shared/truncate-to-bytes';
24
+ import type { SourceProvider } from '../sources/types';
22
25
  import type { IdempotencyRecord } from '../store/idempotency-record';
23
26
  import type { MessageOwner } from '../store/message-owner';
24
27
  import type { MessageRecord, MessageStatus } from '../store/message-record';
@@ -26,6 +29,7 @@ import { StateStore } from '../store/state-store';
26
29
  import type { EventScope } from '../store/state-store';
27
30
  import type { TrailEntry } from '../store/trail-entry';
28
31
  import type { SessionWorkspace } from '../store/workspace-materialization';
32
+ import { checkRepositoryAccess } from '../workspace/check-repository-access';
29
33
  import { ANSWER_BYTE_CAP } from './answer-byte-cap';
30
34
  import { AttachRegistry } from './attach-registry';
31
35
  import { buildAgentList } from './build-agent-list';
@@ -58,6 +62,7 @@ import { EffectRemainsError } from './effect-remains-error';
58
62
  import { EventSignal } from './event-signal';
59
63
  import { startHookServer } from './hooks';
60
64
  import { IdempotencyLedger } from './idempotency-ledger';
65
+ import { isOwnHookEvent } from './is-own-hook-event';
61
66
  import { isTreeInReach } from './is-tree-in-reach';
62
67
  import { loadTranscriptPage } from './load-transcript-page';
63
68
  import { makeHookRunner } from './make-hook-runner';
@@ -67,6 +72,7 @@ import { mintMessageID } from './mint-message-id';
67
72
  import { mintSessionID } from './mint-session-id';
68
73
  import { parseReport } from './parse-report';
69
74
  import { PermissionRegistry } from './permission-registry';
75
+ import { requireGitTransports } from './require-git-transports';
70
76
  import { restoreFleet } from './restore-fleet';
71
77
  import { runEjectHandoff } from './run-eject-handoff';
72
78
  import { ScreenModel } from './screen-model';
@@ -152,6 +158,15 @@ export interface DaemonOptions {
152
158
  // Where background failures are reported, one line at a time; stderr
153
159
  // when unset.
154
160
  readonly log?: (line: string) => void;
161
+
162
+ // The sources the spawn picker offers, in order, each built with the
163
+ // services it uses; none when unset.
164
+ readonly sources?: readonly SourceProvider[];
165
+
166
+ // The transports a git workspace source may use and git may fetch over,
167
+ // or the invalid list the config holds, which refuses every git
168
+ // operation; https and ssh when unset.
169
+ readonly gitTransports?: readonly string[] | InvalidGitTransports;
155
170
  }
156
171
 
157
172
  export interface DaemonHandle {
@@ -235,6 +250,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
235
250
 
236
251
  const targetErrors = opts.targetErrors ?? [];
237
252
  const principals = opts.principals ?? null;
253
+ const gitTransports = opts.gitTransports ?? DEFAULT_GIT_TRANSPORTS;
238
254
 
239
255
  const targetsByID = new Map(targets.map((target) => [target.id, target]));
240
256
 
@@ -765,6 +781,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
765
781
  }
766
782
 
767
783
  const before = mgr.sessions.find((s) => s.id === e.atcId);
784
+ const runtime = runtimes.get(e.atcId);
785
+
786
+ // A harness nested inside a session inherits its environment and reports
787
+ // under its id; only the harness atc started may change the session.
788
+ if (
789
+ before !== undefined &&
790
+ runtime !== undefined &&
791
+ !isOwnHookEvent(e.agent, before.agent, runtime.hasAgentHookLines)
792
+ ) {
793
+ return;
794
+ }
795
+
796
+ if (runtime !== undefined && e.agent !== undefined) {
797
+ runtime.hasAgentHookLines = true;
798
+ }
799
+
768
800
  const previousAgentSessionID = before?.agentSessionID;
769
801
  const ev = mgr.applyHook(e);
770
802
 
@@ -777,7 +809,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
777
809
  }
778
810
 
779
811
  const kind = ev?.kind ?? null;
780
- const runtime = runtimes.get(e.atcId);
781
812
  const currentAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
782
813
 
783
814
  if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
@@ -895,6 +926,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
895
926
  mgr.log(line);
896
927
  },
897
928
  stagingRoot: tmpdir(),
929
+ gitTransports,
898
930
  },
899
931
  );
900
932
  };
@@ -1176,6 +1208,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1176
1208
  }
1177
1209
  };
1178
1210
 
1211
+ const sources = opts.sources ?? [];
1212
+
1179
1213
  const ctx: DaemonContext = {
1180
1214
  build: opts.build,
1181
1215
  daemonID: store.daemonID,
@@ -1199,6 +1233,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1199
1233
  spawnDefaults: { agent: 'claude', target: defaultTarget },
1200
1234
  configRevision,
1201
1235
  targetErrors,
1236
+ sources: sources.map((source) => ({
1237
+ id: source.id,
1238
+ label: source.label,
1239
+ kind: source.kind,
1240
+ })),
1202
1241
  }),
1203
1242
  collectFleet: () => store.loadFleet(),
1204
1243
  loadLastUsedAgent: () => store.loadLastUsedAgent(),
@@ -1240,6 +1279,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1240
1279
  mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
1241
1280
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1242
1281
  },
1282
+ findSource: (id) => sources.find((source) => source.id === id) ?? null,
1283
+ collectAlternateGitURLs: (url) => [
1284
+ ...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
1285
+ ],
1286
+ checkRepositoryAccess: (request) =>
1287
+ checkRepositoryAccess({ ...request, transports: requireGitTransports(gitTransports) }),
1243
1288
  spawnSession: (plan, keyed, access) => {
1244
1289
  // Under an access, a spawn under a parent whose tree leaves the access
1245
1290
  // before the harness starts is refused as a spawn under an unknown
@@ -1,14 +1,17 @@
1
1
  import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
2
2
  import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
3
+ import { isRecord } from '../shared/report';
3
4
  import type {
4
5
  ImpCommand,
5
6
  ImpCommandResult,
6
7
  ImpCreateSpec,
7
8
  ImpFeatures,
9
+ ImpIdentity,
8
10
  ImpLease,
9
11
  ImpPort,
10
12
  ImpRelayConnection,
11
13
  ImpReverseForward,
14
+ ImpSecret,
12
15
  ImpSessionConnection,
13
16
  ImpSessionHandlers,
14
17
  ImpSessionOutcome,
@@ -48,11 +51,79 @@ export class ImpClientPort implements ImpPort {
48
51
  this.readToken = options.readToken;
49
52
  }
50
53
 
51
- // An impd from before the flags has neither.
54
+ // A flag counts only as a literal true: an impd from before a flag has
55
+ // it false, and so does one that sends anything else in its place.
52
56
  readonly readFeatures = async (): Promise<ImpFeatures> => {
53
57
  const info = await this.tryCall((client) => client.system.info());
54
58
 
55
- return info.features ?? { sessionOffsets: false, leases: false };
59
+ const features: Readonly<Record<string, unknown>> | undefined = info.features;
60
+
61
+ return {
62
+ sessionOffsets: features?.['sessionOffsets'] === true,
63
+ leases: features?.['leases'] === true,
64
+ grantableTokens: features?.['grantableTokens'] === true,
65
+ secretRebind: features?.['secretRebind'] === true,
66
+ };
67
+ };
68
+
69
+ // An impd from before grantable lists sends none, which grants nothing.
70
+ readonly readIdentity = async (): Promise<ImpIdentity> => {
71
+ const identity = await this.tryCall((client) => client.tokens.whoami());
72
+
73
+ return {
74
+ kind: identity.kind,
75
+ name: identity.name,
76
+ scope: identity.scope,
77
+ imps: identity.imps === null ? null : [...identity.imps],
78
+ grantable: [...(identity.grantable ?? [])],
79
+ };
80
+ };
81
+
82
+ readonly readSecrets = async (): Promise<readonly ImpSecret[]> => {
83
+ const secrets = await this.tryCall((client) => client.secrets.list());
84
+
85
+ return secrets.map((secret) => ({
86
+ name: secret.name,
87
+ kind: secret.kind,
88
+ rules: secret.rules.map((rule) => ({
89
+ host: rule.host,
90
+ header: rule.header,
91
+ scheme: rule.scheme,
92
+ ...(rule.user === undefined ? {} : { user: rule.user }),
93
+ })),
94
+ imps: [...secret.imps],
95
+ }));
96
+ };
97
+
98
+ readonly readGrants = async (name: string): Promise<readonly string[]> => {
99
+ const grants = await this.tryCall((client) => client.grants.list({ name }));
100
+
101
+ return [...grants];
102
+ };
103
+
104
+ readonly createGrant = async (name: string, secret: string): Promise<void> => {
105
+ await this.tryCall((client) => client.grants.add({ name, secret }));
106
+ };
107
+
108
+ // A grant impd no longer holds is no grant to revoke; a missing imp or
109
+ // secret still rejects.
110
+ readonly removeGrant = async (name: string, secret: string): Promise<boolean> => {
111
+ try {
112
+ await this.tryCall((client) => client.grants.delete({ name, secret }));
113
+
114
+ return true;
115
+ } catch (error) {
116
+ if (
117
+ error instanceof ImpPortError &&
118
+ error.code === 'NOT_FOUND' &&
119
+ isRecord(error.data) &&
120
+ error.data['kind'] === 'grant'
121
+ ) {
122
+ return false;
123
+ }
124
+
125
+ throw error;
126
+ }
56
127
  };
57
128
 
58
129
  readonly readImp = async (name: string): Promise<ImpView | null> => {
@@ -60,6 +131,7 @@ export class ImpClientPort implements ImpPort {
60
131
  const imp = await this.tryCall((client) => client.imps.get({ name }));
61
132
 
62
133
  return {
134
+ id: imp.id,
63
135
  name: imp.name,
64
136
  state: imp.state,
65
137
  leases: (imp.leases?.leases ?? []).map((lease) => toLease(lease)),
@@ -83,7 +155,7 @@ export class ImpClientPort implements ImpPort {
83
155
  }),
84
156
  );
85
157
 
86
- return { name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
158
+ return { id: imp.id, name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
87
159
  };
88
160
 
89
161
  readonly acquireLease = async (
@@ -9,6 +9,23 @@ export interface ImpPort {
9
9
  // impd's capability flags; an impd without them has neither.
10
10
  readonly readFeatures: () => Promise<ImpFeatures>;
11
11
 
12
+ // The caller's own identity: its scope, the imps it may reach, and the
13
+ // secrets it may grant.
14
+ readonly readIdentity: () => Promise<ImpIdentity>;
15
+
16
+ // Every secret impd holds, with its rules and never its value.
17
+ readonly readSecrets: () => Promise<readonly ImpSecret[]>;
18
+
19
+ // The names of the secrets granted to an imp.
20
+ readonly readGrants: (name: string) => Promise<readonly string[]>;
21
+
22
+ // Grants a secret to an imp; granting one it already holds changes
23
+ // nothing, and nothing in the result distinguishes the two.
24
+ readonly createGrant: (name: string, secret: string) => Promise<void>;
25
+
26
+ // Revokes a secret from an imp, and reports whether impd held the grant.
27
+ readonly removeGrant: (name: string, secret: string) => Promise<boolean>;
28
+
12
29
  // The imp under a name, or null when impd holds none.
13
30
  readonly readImp: (name: string) => Promise<ImpView | null>;
14
31
  readonly createImp: (spec: ImpCreateSpec) => Promise<ImpView>;
@@ -51,11 +68,54 @@ export interface ImpPort {
51
68
  export interface ImpFeatures {
52
69
  readonly sessionOffsets: boolean;
53
70
  readonly leases: boolean;
71
+
72
+ // Tokens limited to some imps may grant a list of secrets to them.
73
+ readonly grantableTokens: boolean;
74
+
75
+ // A rebound or recreated secret drops its grants and leaves a token's
76
+ // list of grantable secrets behind.
77
+ readonly secretRebind: boolean;
78
+ }
79
+
80
+ type ImpScope = 'read' | 'exec' | 'manage';
81
+
82
+ export interface ImpIdentity {
83
+ readonly kind: 'token' | 'ssh' | 'tailnet' | 'dashboard';
84
+ readonly name: string;
85
+ readonly scope: ImpScope;
86
+
87
+ // Imp name patterns with `*` for any run of characters; null reaches
88
+ // every imp on the host.
89
+ readonly imps: readonly string[] | null;
90
+
91
+ // The secrets the caller may grant to the imps it reaches and revoke
92
+ // from them.
93
+ readonly grantable: readonly string[];
94
+ }
95
+
96
+ export interface ImpSecret {
97
+ readonly name: string;
98
+ readonly kind: 'anthropic' | 'custom' | 'github' | 'npm';
99
+ readonly rules: readonly ImpSecretRule[];
100
+
101
+ // The imps holding a grant of the secret.
102
+ readonly imps: readonly string[];
103
+ }
104
+
105
+ // How impd adds a secret to requests for one host.
106
+ export interface ImpSecretRule {
107
+ readonly host: string;
108
+ readonly header: string;
109
+ readonly scheme: 'basic' | 'bearer' | 'raw';
110
+ readonly user?: string;
54
111
  }
55
112
 
56
113
  export type ImpState = 'creating' | 'running' | 'sleeping' | 'stopped' | 'error';
57
114
 
58
115
  export interface ImpView {
116
+ // impd's id for this imp, which a new imp made under the same name never
117
+ // shares.
118
+ readonly id: string;
59
119
  readonly name: string;
60
120
  readonly state: ImpState;
61
121
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Whether an imp name matches one of a token's imp patterns, as impd
3
+ * matches them: each pattern is an imp name with `*` for any run of
4
+ * characters, including none, so `dev-*` matches `dev-` and `dev-a1`, and
5
+ * a pattern without `*` matches only its own name.
6
+ */
7
+ export function isImpNameAllowed(patterns: readonly string[], name: string): boolean {
8
+ return patterns.some((pattern) => isPatternMatch(pattern, name));
9
+ }
10
+
11
+ function isPatternMatch(pattern: string, name: string): boolean {
12
+ const [first = '', ...rest] = pattern.split('*');
13
+ const last = rest.pop();
14
+
15
+ if (last === undefined) {
16
+ return name === first;
17
+ }
18
+
19
+ if (name.length < first.length + last.length || !name.startsWith(first) || !name.endsWith(last)) {
20
+ return false;
21
+ }
22
+
23
+ // Each middle part in order, between the prefix and the suffix.
24
+ let at = first.length;
25
+ const end = name.length - last.length;
26
+
27
+ for (const part of rest) {
28
+ const found = name.indexOf(part, at);
29
+
30
+ if (found === -1 || found + part.length > end) {
31
+ return false;
32
+ }
33
+
34
+ at = found + part.length;
35
+ }
36
+
37
+ return true;
38
+ }
@@ -0,0 +1,19 @@
1
+ import type { AgentID } from '../shared/agent-id';
2
+
3
+ /**
4
+ * Whether a hook line, judged by the agent it carries, comes from the
5
+ * harness atc started for its session rather than from a harness nested
6
+ * inside it, which inherits the session's environment and so reports under
7
+ * the same session. A line carrying an agent is the session's own when that
8
+ * agent is the session's. A line without one comes from a hook command that
9
+ * carries no agent flag, and stays the session's own until a line carrying
10
+ * the session's agent arrives from the same terminal: from then on the
11
+ * session's own hooks are known to carry it.
12
+ */
13
+ export function isOwnHookEvent(
14
+ lineAgent: AgentID | undefined,
15
+ sessionAgent: AgentID,
16
+ hasAgentHookLines: boolean,
17
+ ): boolean {
18
+ return lineAgent === undefined ? !hasAgentHookLines : lineAgent === sessionAgent;
19
+ }