@zgeoff/atc 2.12.0 → 2.14.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/README.md +12 -11
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +134 -4
- package/src/daemon/daemon.ts +306 -42
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +574 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/store/state-store.ts
CHANGED
|
@@ -24,6 +24,11 @@ import type { MessageRecord } from './message-record';
|
|
|
24
24
|
import { runMigrations } from './run-migrations';
|
|
25
25
|
import type { StateStoreSchema } from './run-migrations';
|
|
26
26
|
import type { TrailEntry } from './trail-entry';
|
|
27
|
+
import type {
|
|
28
|
+
MaterializationUpdate,
|
|
29
|
+
SessionWorkspace,
|
|
30
|
+
WorkspaceMaterialization,
|
|
31
|
+
} from './workspace-materialization';
|
|
27
32
|
|
|
28
33
|
// Spelled as the partial index's predicate so SQLite can match them.
|
|
29
34
|
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
@@ -58,6 +63,23 @@ export interface StoredEvent {
|
|
|
58
63
|
readonly label?: string;
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
/**
|
|
67
|
+
* One report as the trail holds it. A report recorded before the trail kept
|
|
68
|
+
* whole texts has only its preview, so its text is that preview and
|
|
69
|
+
* `complete` is false.
|
|
70
|
+
*/
|
|
71
|
+
export interface StoredReport {
|
|
72
|
+
readonly id: number;
|
|
73
|
+
|
|
74
|
+
// Epoch ms the report arrived.
|
|
75
|
+
readonly at: number;
|
|
76
|
+
readonly atcID: SessionID;
|
|
77
|
+
readonly agentSessionID: AgentSessionID | null;
|
|
78
|
+
readonly label: string;
|
|
79
|
+
readonly text: string;
|
|
80
|
+
readonly complete: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
/**
|
|
62
84
|
* Daemon state in one SQLite store: the restorable fleet, the event trail
|
|
63
85
|
* (hook events, message status changes, and reports) that events.read and
|
|
@@ -124,29 +146,43 @@ export class StateStore {
|
|
|
124
146
|
async loadFleet(): Promise<FleetEntry[]> {
|
|
125
147
|
const rows = await this.db
|
|
126
148
|
.selectFrom('fleet')
|
|
127
|
-
|
|
149
|
+
|
|
150
|
+
// A session's ready workspace comes back with its row.
|
|
151
|
+
.leftJoin('workspace_materialization as workspace', (join) =>
|
|
152
|
+
join
|
|
153
|
+
.onRef('workspace.session_id', '=', 'fleet.session_id')
|
|
154
|
+
.on('workspace.phase', '=', 'ready'),
|
|
155
|
+
)
|
|
156
|
+
.where('fleet.session_id', 'in', (eb) =>
|
|
128
157
|
eb.selectFrom('session_owner').select('session_id').where('daemon_id', '=', this.daemonID),
|
|
129
158
|
)
|
|
130
159
|
|
|
131
160
|
// Stored order, which a restore keeps for sessions with no recency.
|
|
132
|
-
.orderBy(sql`rowid`)
|
|
161
|
+
.orderBy(sql`fleet.rowid`)
|
|
133
162
|
.select([
|
|
134
|
-
'session_id',
|
|
135
|
-
'agent_session_id',
|
|
136
|
-
'name',
|
|
137
|
-
'cwd',
|
|
138
|
-
'pinned',
|
|
139
|
-
'last_attached',
|
|
140
|
-
'agent',
|
|
141
|
-
'exited',
|
|
142
|
-
'parent_session_id',
|
|
143
|
-
'prompt',
|
|
144
|
-
'result',
|
|
145
|
-
'transcript_path',
|
|
146
|
-
'model',
|
|
147
|
-
'effort',
|
|
148
|
-
'target',
|
|
149
|
-
'target_identity',
|
|
163
|
+
'fleet.session_id',
|
|
164
|
+
'fleet.agent_session_id',
|
|
165
|
+
'fleet.name',
|
|
166
|
+
'fleet.cwd',
|
|
167
|
+
'fleet.pinned',
|
|
168
|
+
'fleet.last_attached',
|
|
169
|
+
'fleet.agent',
|
|
170
|
+
'fleet.exited',
|
|
171
|
+
'fleet.parent_session_id',
|
|
172
|
+
'fleet.prompt',
|
|
173
|
+
'fleet.result',
|
|
174
|
+
'fleet.transcript_path',
|
|
175
|
+
'fleet.model',
|
|
176
|
+
'fleet.effort',
|
|
177
|
+
'fleet.target',
|
|
178
|
+
'fleet.target_identity',
|
|
179
|
+
'workspace.repo_url',
|
|
180
|
+
'workspace.sha',
|
|
181
|
+
'workspace.ref',
|
|
182
|
+
'workspace.materialized_at',
|
|
183
|
+
'workspace.withheld_env',
|
|
184
|
+
'fleet.desired',
|
|
185
|
+
'fleet.host_key',
|
|
150
186
|
])
|
|
151
187
|
.execute();
|
|
152
188
|
|
|
@@ -172,6 +208,10 @@ export class StateStore {
|
|
|
172
208
|
...(row.effort === null ? {} : { effort: row.effort }),
|
|
173
209
|
...(row.target === null ? {} : { target: row.target }),
|
|
174
210
|
...(row.target_identity === null ? {} : { targetIdentity: row.target_identity }),
|
|
211
|
+
...buildWorkspaceField(row),
|
|
212
|
+
...buildWithheldEnvField(row.withheld_env),
|
|
213
|
+
...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
|
|
214
|
+
...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
|
|
175
215
|
});
|
|
176
216
|
}
|
|
177
217
|
|
|
@@ -249,6 +289,8 @@ export class StateStore {
|
|
|
249
289
|
effort: entry.effort ?? null,
|
|
250
290
|
target: entry.target ?? null,
|
|
251
291
|
target_identity: entry.targetIdentity ?? null,
|
|
292
|
+
desired: entry.desired ?? null,
|
|
293
|
+
host_key: entry.hostKey ?? null,
|
|
252
294
|
})
|
|
253
295
|
.execute();
|
|
254
296
|
|
|
@@ -306,8 +348,10 @@ export class StateStore {
|
|
|
306
348
|
.execute();
|
|
307
349
|
}
|
|
308
350
|
|
|
309
|
-
|
|
310
|
-
|
|
351
|
+
// Returns whether the entry was written: a report whose id the trail
|
|
352
|
+
// already holds is left out.
|
|
353
|
+
async recordTrailEntry(entry: TrailEntry): Promise<boolean> {
|
|
354
|
+
const result = await this.db
|
|
311
355
|
.insertInto('events')
|
|
312
356
|
.values({
|
|
313
357
|
ts: new Date(entry.at).toISOString(),
|
|
@@ -319,8 +363,13 @@ export class StateStore {
|
|
|
319
363
|
session_id: entry.agentSessionID,
|
|
320
364
|
kind: entry.kind,
|
|
321
365
|
detail: entry.detail,
|
|
366
|
+
report_id: entry.kind === 'report' ? (entry.reportID ?? null) : null,
|
|
367
|
+
report_text: entry.kind === 'report' ? entry.text : null,
|
|
322
368
|
})
|
|
323
|
-
.
|
|
369
|
+
.onConflict((oc) => oc.column('report_id').doNothing())
|
|
370
|
+
.executeTakeFirst();
|
|
371
|
+
|
|
372
|
+
return result.numInsertedOrUpdatedRows !== 0n;
|
|
324
373
|
}
|
|
325
374
|
|
|
326
375
|
async collectEventsAfter(
|
|
@@ -341,6 +390,32 @@ export class StateStore {
|
|
|
341
390
|
return buildStoredEvents(rows);
|
|
342
391
|
}
|
|
343
392
|
|
|
393
|
+
// A row that is not a report, or that lies outside the scope, misses as a
|
|
394
|
+
// row the trail never held does.
|
|
395
|
+
async findReport(id: number, scope: EventScope | null = null): Promise<StoredReport | null> {
|
|
396
|
+
const row = await this.db
|
|
397
|
+
.selectFrom('events')
|
|
398
|
+
.select(['id', 'ts', 'atc_id', 'session_id', 'message', 'detail', 'report_text'])
|
|
399
|
+
.where('id', '=', id)
|
|
400
|
+
.where('kind', '=', 'report')
|
|
401
|
+
.where((eb) => buildScopeMatch(eb, scope))
|
|
402
|
+
.executeTakeFirst();
|
|
403
|
+
|
|
404
|
+
if (row === undefined) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
id: row.id,
|
|
410
|
+
at: Date.parse(row.ts),
|
|
411
|
+
atcID: toSessionID(row.atc_id),
|
|
412
|
+
agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
|
|
413
|
+
label: row.message ?? '',
|
|
414
|
+
text: row.report_text ?? row.detail ?? '',
|
|
415
|
+
complete: row.report_text !== null,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
344
419
|
async collectLatestEvents(
|
|
345
420
|
limit: number,
|
|
346
421
|
scope: EventScope | null = null,
|
|
@@ -710,6 +785,76 @@ export class StateStore {
|
|
|
710
785
|
.execute();
|
|
711
786
|
}
|
|
712
787
|
|
|
788
|
+
// Records a materialization as it starts, in the resolving phase.
|
|
789
|
+
async createMaterialization(
|
|
790
|
+
row: Pick<
|
|
791
|
+
WorkspaceMaterialization,
|
|
792
|
+
'sessionID' | 'target' | 'dir' | 'sourceKind' | 'withheldEnv'
|
|
793
|
+
>,
|
|
794
|
+
at: number,
|
|
795
|
+
): Promise<void> {
|
|
796
|
+
await this.db
|
|
797
|
+
.insertInto('workspace_materialization')
|
|
798
|
+
.values({
|
|
799
|
+
session_id: row.sessionID,
|
|
800
|
+
target: row.target,
|
|
801
|
+
dir: row.dir,
|
|
802
|
+
source_kind: row.sourceKind,
|
|
803
|
+
phase: 'resolving',
|
|
804
|
+
repo_url: null,
|
|
805
|
+
sha: null,
|
|
806
|
+
ref: null,
|
|
807
|
+
error_code: null,
|
|
808
|
+
started_at: at,
|
|
809
|
+
updated_at: at,
|
|
810
|
+
materialized_at: null,
|
|
811
|
+
withheld_env: JSON.stringify(row.withheldEnv),
|
|
812
|
+
})
|
|
813
|
+
.execute();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async updateMaterialization(
|
|
817
|
+
sessionID: SessionID,
|
|
818
|
+
fields: MaterializationUpdate,
|
|
819
|
+
at: number,
|
|
820
|
+
): Promise<void> {
|
|
821
|
+
await this.db
|
|
822
|
+
.updateTable('workspace_materialization')
|
|
823
|
+
.set({
|
|
824
|
+
phase: fields.phase,
|
|
825
|
+
...(fields.repoURL === undefined ? {} : { repo_url: fields.repoURL }),
|
|
826
|
+
...(fields.sha === undefined ? {} : { sha: fields.sha }),
|
|
827
|
+
...(fields.ref === undefined ? {} : { ref: fields.ref }),
|
|
828
|
+
...(fields.errorCode === undefined ? {} : { error_code: fields.errorCode }),
|
|
829
|
+
...(fields.materializedAt === undefined ? {} : { materialized_at: fields.materializedAt }),
|
|
830
|
+
updated_at: at,
|
|
831
|
+
})
|
|
832
|
+
.where('session_id', '=', sessionID)
|
|
833
|
+
.execute();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async findMaterialization(sessionID: SessionID): Promise<WorkspaceMaterialization | null> {
|
|
837
|
+
const row = await this.db
|
|
838
|
+
.selectFrom('workspace_materialization')
|
|
839
|
+
.selectAll()
|
|
840
|
+
.where('session_id', '=', sessionID)
|
|
841
|
+
.executeTakeFirst();
|
|
842
|
+
|
|
843
|
+
return row === undefined ? null : toWorkspaceMaterialization(row);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Runs once as a daemon starts, before it serves a request: a
|
|
847
|
+
// materialization still short of ready or failed belonged to a daemon that
|
|
848
|
+
// stopped partway through it, and its workspace was never verified, so it
|
|
849
|
+
// fails as interrupted.
|
|
850
|
+
async reconcileMaterializations(at: number): Promise<void> {
|
|
851
|
+
await this.db
|
|
852
|
+
.updateTable('workspace_materialization')
|
|
853
|
+
.set({ phase: 'failed', error_code: 'workspace_interrupted', updated_at: at })
|
|
854
|
+
.where('phase', 'not in', ['ready', 'failed'])
|
|
855
|
+
.execute();
|
|
856
|
+
}
|
|
857
|
+
|
|
713
858
|
async stop(): Promise<void> {
|
|
714
859
|
await this.db.destroy();
|
|
715
860
|
|
|
@@ -748,6 +893,30 @@ export class StateStore {
|
|
|
748
893
|
}
|
|
749
894
|
}
|
|
750
895
|
|
|
896
|
+
// A fleet row's ready workspace as its entry field, or nothing for a row
|
|
897
|
+
// that ran without one.
|
|
898
|
+
function buildWorkspaceField(
|
|
899
|
+
row: Readonly<{
|
|
900
|
+
repo_url: string | null;
|
|
901
|
+
sha: string | null;
|
|
902
|
+
ref: string | null;
|
|
903
|
+
materialized_at: number | null;
|
|
904
|
+
}>,
|
|
905
|
+
): { readonly workspace?: SessionWorkspace } {
|
|
906
|
+
if (row.repo_url === null || row.sha === null || row.materialized_at === null) {
|
|
907
|
+
return {};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
workspace: {
|
|
912
|
+
repoURL: row.repo_url,
|
|
913
|
+
sha: row.sha,
|
|
914
|
+
...(row.ref === null ? {} : { ref: row.ref }),
|
|
915
|
+
materializedAt: row.materialized_at,
|
|
916
|
+
},
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
751
920
|
// Two sessions share an agent session id when one resumes the other's agent
|
|
752
921
|
// session. The fleet keeps one row per agent session id, the entry written
|
|
753
922
|
// last, and relinks the survivors as a one-level hierarchy of rows it holds:
|
|
@@ -761,6 +930,22 @@ export class StateStore {
|
|
|
761
930
|
// row above it.
|
|
762
931
|
//
|
|
763
932
|
// Every walk takes at most one step per entry, so it ends on any input.
|
|
933
|
+
// A ready workspace's withheld variable names as their entry field, or
|
|
934
|
+
// nothing for a row that withholds none.
|
|
935
|
+
function buildWithheldEnvField(stored: string | null): { readonly withheldEnv?: string[] } {
|
|
936
|
+
const names = parseWithheldEnv(stored);
|
|
937
|
+
|
|
938
|
+
return names.length === 0 ? {} : { withheldEnv: names };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function parseWithheldEnv(stored: string | null): string[] {
|
|
942
|
+
const parsed: unknown = stored === null ? [] : JSON.parse(stored);
|
|
943
|
+
|
|
944
|
+
return Array.isArray(parsed)
|
|
945
|
+
? parsed.filter((name): name is string => typeof name === 'string')
|
|
946
|
+
: [];
|
|
947
|
+
}
|
|
948
|
+
|
|
764
949
|
function buildFleetWithoutReplacedRows(entries: readonly FleetEntry[]): FleetEntry[] {
|
|
765
950
|
const survivors = new Map<AgentSessionID, SessionID>();
|
|
766
951
|
|
|
@@ -1033,3 +1218,23 @@ function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRe
|
|
|
1033
1218
|
...(row.turn_id === null ? {} : { turn: row.turn_id }),
|
|
1034
1219
|
};
|
|
1035
1220
|
}
|
|
1221
|
+
|
|
1222
|
+
function toWorkspaceMaterialization(
|
|
1223
|
+
row: Readonly<StateStoreSchema['workspace_materialization']>,
|
|
1224
|
+
): WorkspaceMaterialization {
|
|
1225
|
+
return {
|
|
1226
|
+
sessionID: toSessionID(row.session_id),
|
|
1227
|
+
target: row.target,
|
|
1228
|
+
dir: row.dir,
|
|
1229
|
+
sourceKind: row.source_kind,
|
|
1230
|
+
phase: row.phase,
|
|
1231
|
+
repoURL: row.repo_url,
|
|
1232
|
+
sha: row.sha,
|
|
1233
|
+
ref: row.ref,
|
|
1234
|
+
errorCode: row.error_code,
|
|
1235
|
+
startedAt: row.started_at,
|
|
1236
|
+
updatedAt: row.updated_at,
|
|
1237
|
+
materializedAt: row.materialized_at,
|
|
1238
|
+
withheldEnv: parseWithheldEnv(row.withheld_env),
|
|
1239
|
+
};
|
|
1240
|
+
}
|
package/src/store/trail-entry.ts
CHANGED
|
@@ -18,6 +18,13 @@ interface MessageTrailEntry extends TrailEntryBase {
|
|
|
18
18
|
interface ReportTrailEntry extends TrailEntryBase {
|
|
19
19
|
readonly kind: 'report';
|
|
20
20
|
readonly label: string;
|
|
21
|
+
|
|
22
|
+
// The report's whole text, which the detail previews.
|
|
23
|
+
readonly text: string;
|
|
24
|
+
|
|
25
|
+
// The id a remote session's reporter gave the report, so a resent
|
|
26
|
+
// report is stored once; absent for a report that carries none.
|
|
27
|
+
readonly reportID?: string;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a spawn's workspace stands on its way to the execution target:
|
|
5
|
+
* resolving the source to a commit, cloning it on the daemon's host,
|
|
6
|
+
* transferring the archive to the target, and verifying the target's
|
|
7
|
+
* checkout, then `ready` or `failed`. A row a stopped daemon left in any
|
|
8
|
+
* other phase is reconciled to `failed` before the next daemon serves a
|
|
9
|
+
* request, so no workspace ever counts as ready without its verification.
|
|
10
|
+
*/
|
|
11
|
+
export type MaterializationPhase =
|
|
12
|
+
| 'resolving'
|
|
13
|
+
| 'cloning'
|
|
14
|
+
| 'transferring'
|
|
15
|
+
| 'verifying'
|
|
16
|
+
| 'ready'
|
|
17
|
+
| 'failed';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One spawn's workspace materialization, keyed by the session it is for.
|
|
21
|
+
* The repository URL is the token-free form, and the row never holds a
|
|
22
|
+
* credential or the reference to one.
|
|
23
|
+
*/
|
|
24
|
+
export interface WorkspaceMaterialization {
|
|
25
|
+
readonly sessionID: SessionID;
|
|
26
|
+
readonly target: string;
|
|
27
|
+
|
|
28
|
+
// The directory on the target the workspace is materialized into.
|
|
29
|
+
readonly dir: string;
|
|
30
|
+
readonly sourceKind: 'path' | 'git';
|
|
31
|
+
readonly phase: MaterializationPhase;
|
|
32
|
+
readonly repoURL: string | null;
|
|
33
|
+
readonly sha: string | null;
|
|
34
|
+
|
|
35
|
+
// The branch or tag the commit was resolved from; null for a pinned
|
|
36
|
+
// commit or a detached checkout.
|
|
37
|
+
readonly ref: string | null;
|
|
38
|
+
|
|
39
|
+
// The refusal that failed the materialization; null unless failed.
|
|
40
|
+
readonly errorCode: string | null;
|
|
41
|
+
readonly startedAt: number;
|
|
42
|
+
readonly updatedAt: number;
|
|
43
|
+
readonly materializedAt: number | null;
|
|
44
|
+
|
|
45
|
+
// The environment variable names every harness the session starts goes
|
|
46
|
+
// without: the workspace credential's variable and the askpass context.
|
|
47
|
+
readonly withheldEnv: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The fields a materialization rewrites as it moves through its phases.
|
|
51
|
+
export interface MaterializationUpdate {
|
|
52
|
+
readonly phase: MaterializationPhase;
|
|
53
|
+
readonly repoURL?: string;
|
|
54
|
+
readonly sha?: string;
|
|
55
|
+
readonly ref?: string | null;
|
|
56
|
+
readonly errorCode?: string;
|
|
57
|
+
readonly materializedAt?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a session's ready workspace was built from: the token-free
|
|
62
|
+
* repository URL, the commit checked out, the branch or tag it came from,
|
|
63
|
+
* and when it became ready.
|
|
64
|
+
*/
|
|
65
|
+
export interface SessionWorkspace {
|
|
66
|
+
readonly repoURL: string;
|
|
67
|
+
readonly sha: string;
|
|
68
|
+
readonly ref?: string;
|
|
69
|
+
readonly materializedAt: number;
|
|
70
|
+
}
|
package/src/tap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { DaemonClient } from './client/daemon-client';
|
|
3
3
|
import { DaemonError } from './protocol/daemon-error';
|
|
4
|
+
import { runBridgeTap } from './run-bridge-tap';
|
|
4
5
|
import { daemonSocketPath } from './shared/config';
|
|
5
6
|
import { getBuild } from './shared/get-build';
|
|
6
7
|
|
|
@@ -28,6 +29,16 @@ interface InboxMessage {
|
|
|
28
29
|
* another tap replaced it or the session was removed.
|
|
29
30
|
*/
|
|
30
31
|
export async function runTap(session: string): Promise<void> {
|
|
32
|
+
const bridge = process.env['ATC_SOCKET'];
|
|
33
|
+
|
|
34
|
+
// Inside a remote host the daemon's own socket is out of reach, and the
|
|
35
|
+
// session bridge serves the tap instead.
|
|
36
|
+
if (process.env['ATC_BRIDGE'] === '1' && bridge !== undefined && bridge !== '') {
|
|
37
|
+
await runBridgeTap(bridge, process.env['ATC_OUTBOX'] ?? '');
|
|
38
|
+
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
let client: DaemonClient;
|
|
32
43
|
|
|
33
44
|
try {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { runGit } from './run-git';
|
|
2
|
+
|
|
3
|
+
type URLCredentialFinding =
|
|
4
|
+
| { readonly ok: true }
|
|
5
|
+
| { readonly ok: false; readonly code: 'credential_in_url'; readonly message: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks that a repository URL reaches git without a credential in it, so
|
|
9
|
+
* a token can never surface in git's own messages or in the clone. The URL
|
|
10
|
+
* itself must carry none, and no `url.<base>.insteadOf` rewrite in the git
|
|
11
|
+
* config read from `cwd` may expand it into one that does. git expands the
|
|
12
|
+
* URL without touching the network. An http(s) URL carries a credential in
|
|
13
|
+
* its userinfo, query, or fragment; any other scheme URL only in a
|
|
14
|
+
* password, since its user is a login name such as `git`.
|
|
15
|
+
*/
|
|
16
|
+
export async function checkURLCredentials(url: string, cwd: string): Promise<URLCredentialFinding> {
|
|
17
|
+
// git prints the URL as it would fetch it, with every rewrite applied.
|
|
18
|
+
const expanded = await runGit(['ls-remote', '--get-url', url], { cwd });
|
|
19
|
+
|
|
20
|
+
const fetched = expanded.exitCode === 0 ? expanded.stdout.trim() : url;
|
|
21
|
+
|
|
22
|
+
if (!hasURLCredential(fetched)) {
|
|
23
|
+
return { ok: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
code: 'credential_in_url',
|
|
29
|
+
message: hasURLCredential(url)
|
|
30
|
+
? 'the repository URL carries a credential; pass it as a credentialRef instead'
|
|
31
|
+
: 'a url.<base>.insteadOf rewrite in the host git config puts a credential into the repository URL; remove the rewrite or pass the credential as a credentialRef',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//iu;
|
|
36
|
+
|
|
37
|
+
const HTTP_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
|
|
38
|
+
|
|
39
|
+
function hasURLCredential(url: string): boolean {
|
|
40
|
+
if (!SCHEME_PATTERN.test(url)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let parsed: URL;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(url);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (HTTP_PROTOCOLS.has(parsed.protocol)) {
|
|
53
|
+
return (
|
|
54
|
+
parsed.username !== '' || parsed.password !== '' || parsed.search !== '' || parsed.hash !== ''
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return parsed.password !== '';
|
|
59
|
+
}
|
|
@@ -40,6 +40,9 @@ type IncompleteCheckout = Exclude<
|
|
|
40
40
|
* objects rather than hard-linking them, takes no hook templates, and runs no
|
|
41
41
|
* hooks on checkout.
|
|
42
42
|
*
|
|
43
|
+
* A ref, or a full commit id, that the upstream does not have is refused as
|
|
44
|
+
* `ref_not_found`, and the directory is removed.
|
|
45
|
+
*
|
|
43
46
|
* The checked-out commit must be the whole workspace: one that uses
|
|
44
47
|
* submodules or tracks paths through Git LFS is refused and the directory is
|
|
45
48
|
* removed. The clone fetches without checking out, and the checkout then
|
|
@@ -163,9 +166,18 @@ async function createCloneAtRef(
|
|
|
163
166
|
);
|
|
164
167
|
|
|
165
168
|
if (checkout.exitCode !== 0) {
|
|
169
|
+
// A pinned commit that no upstream ref reaches never arrives in the
|
|
170
|
+
// clone, so its checkout fails on a commit the upstream does not have.
|
|
171
|
+
const present = await runGit(['cat-file', '-e', `${target.sha}^{commit}`], {
|
|
172
|
+
cwd: request.dir,
|
|
173
|
+
isolated: true,
|
|
174
|
+
});
|
|
175
|
+
|
|
166
176
|
await rm(request.dir, { recursive: true, force: true });
|
|
167
177
|
|
|
168
|
-
return
|
|
178
|
+
return target.branch === null && present.exitCode !== 0
|
|
179
|
+
? { ok: false, code: 'ref_not_found', message: `origin has no commit ${target.sha}` }
|
|
180
|
+
: { ok: false, code: 'clone_failed', message: checkout.stderr.trim() };
|
|
169
181
|
}
|
|
170
182
|
|
|
171
183
|
const complete = await checkWorkspaceCompleteness(request.dir, target.sha);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variables that pin git to a repository other than the one in its
|
|
3
|
+
* working directory, such as the `GIT_DIR` a git hook exports. A command
|
|
4
|
+
* that must act on its own directory runs without every one of them.
|
|
5
|
+
*/
|
|
6
|
+
export const REPOSITORY_ENV_VARS: ReadonlySet<string> = new Set([
|
|
7
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
8
|
+
'GIT_COMMON_DIR',
|
|
9
|
+
'GIT_CONFIG',
|
|
10
|
+
'GIT_CONFIG_COUNT',
|
|
11
|
+
'GIT_CONFIG_PARAMETERS',
|
|
12
|
+
'GIT_DIR',
|
|
13
|
+
'GIT_GRAFT_FILE',
|
|
14
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
15
|
+
'GIT_INDEX_FILE',
|
|
16
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
17
|
+
'GIT_OBJECT_DIRECTORY',
|
|
18
|
+
'GIT_PREFIX',
|
|
19
|
+
'GIT_REPLACE_REF_BASE',
|
|
20
|
+
'GIT_SHALLOW_FILE',
|
|
21
|
+
'GIT_WORK_TREE',
|
|
22
|
+
]);
|
package/src/workspace/run-git.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { REPOSITORY_ENV_VARS } from './repository-env-vars';
|
|
2
|
+
|
|
1
3
|
interface GitRunOptions {
|
|
2
4
|
readonly cwd?: string;
|
|
3
5
|
readonly env?: Readonly<Record<string, string>>;
|
|
@@ -73,24 +75,6 @@ export async function runGit(
|
|
|
73
75
|
return { exitCode, stdout, stderr };
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
const REPOSITORY_ENV_VARS: ReadonlySet<string> = new Set([
|
|
77
|
-
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
78
|
-
'GIT_COMMON_DIR',
|
|
79
|
-
'GIT_CONFIG',
|
|
80
|
-
'GIT_CONFIG_COUNT',
|
|
81
|
-
'GIT_CONFIG_PARAMETERS',
|
|
82
|
-
'GIT_DIR',
|
|
83
|
-
'GIT_GRAFT_FILE',
|
|
84
|
-
'GIT_IMPLICIT_WORK_TREE',
|
|
85
|
-
'GIT_INDEX_FILE',
|
|
86
|
-
'GIT_NO_REPLACE_OBJECTS',
|
|
87
|
-
'GIT_OBJECT_DIRECTORY',
|
|
88
|
-
'GIT_PREFIX',
|
|
89
|
-
'GIT_REPLACE_REF_BASE',
|
|
90
|
-
'GIT_SHALLOW_FILE',
|
|
91
|
-
'GIT_WORK_TREE',
|
|
92
|
-
]);
|
|
93
|
-
|
|
94
78
|
function collectHostEnv(): Record<string, string | undefined> {
|
|
95
79
|
return Object.fromEntries(
|
|
96
80
|
Object.entries(process.env).filter(
|
|
File without changes
|