@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.
- package/package.json +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +39 -5
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/spawn-picker.ts +132 -6
- package/src/client/ui.ts +6 -1
- package/src/daemon/broker-authority-error.ts +41 -0
- package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +46 -1
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/hook-event.ts +6 -2
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +105 -0
- package/src/shared/config.ts +14 -0
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/statusline.ts +8 -6
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /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
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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 (
|
package/src/daemon/imp-port.ts
CHANGED
|
@@ -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
|
+
}
|