@runuai/host 0.9.167 → 0.9.168
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/lib/agent-cli.ts +789 -21
- package/lib/agents/cursor.ts +16 -10
- package/lib/agents/durable-proc.ts +12 -5
- package/lib/agents/machine-durable.ts +12 -5
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/transport.ts +5 -0
- package/lib/assignment-evidence.ts +93 -0
- package/lib/cloud-credential-leases.ts +23 -6
- package/lib/cloud-credential-runtime.ts +10 -1
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/engine-accounts.ts +4 -6
- package/lib/host-env.ts +2 -27
- package/lib/orchestrator.ts +97 -16
- package/lib/project-env-loader.ts +5 -0
- package/lib/secrets-proxy/policy.ts +12 -3
- package/lib/task-env-source.ts +2 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1052 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-token.ts +3 -0
- package/package.json +14 -2
- package/src/cloud-credentials.ts +41 -4
- package/src/engine-account-paths.ts +11 -0
- package/src/index.ts +66 -3
- package/src/main.ts +125 -0
- package/src/protocol.ts +185 -2
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/workspace-path-policy.ts +41 -0
package/lib/agents/cursor.ts
CHANGED
|
@@ -269,21 +269,25 @@ export class CursorSession implements AgentSession {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
/**
|
|
272
|
-
* One dashboard probe for a leased account
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
272
|
+
* One dashboard probe for a leased account. The read runs after the CLI
|
|
273
|
+
* child has exited: the CLI rewrites ~/.config/cursor/auth.json at
|
|
274
|
+
* startup, and a probe overlapped with the turn reads the expired access
|
|
275
|
+
* token. A missing lease, a probe still inside the five-minute gate, or
|
|
276
|
+
* any failure sends nothing. The token never leaves this method except
|
|
277
|
+
* as the bearer of the two dashboard calls.
|
|
276
278
|
*
|
|
277
279
|
* The credential is marked before the first await. Two sessions that start
|
|
278
|
-
* together must not both pass the gate.
|
|
280
|
+
* together must not both pass the gate. The returned function is the read;
|
|
281
|
+
* call it once the child has exited, still before turn_complete.
|
|
279
282
|
*/
|
|
280
|
-
private probePeriodUsage(): Promise<void> {
|
|
283
|
+
private probePeriodUsage(): () => Promise<void> {
|
|
284
|
+
const skip = (): Promise<void> => Promise.resolve();
|
|
281
285
|
const credentialId = this.agentEnv[ENGINE_ACCOUNT_ENV];
|
|
282
|
-
if (!credentialId || this.closed) return
|
|
286
|
+
if (!credentialId || this.closed) return skip;
|
|
283
287
|
const now = Date.now();
|
|
284
|
-
if (!cursorPeriodProbeDue(credentialId, now)) return
|
|
288
|
+
if (!cursorPeriodProbeDue(credentialId, now)) return skip;
|
|
285
289
|
markCursorPeriodProbed(credentialId, now);
|
|
286
|
-
return this.readPeriodUsage();
|
|
290
|
+
return () => this.readPeriodUsage();
|
|
287
291
|
}
|
|
288
292
|
|
|
289
293
|
/** Auth read and both dashboard calls share one deadline. On expiry the
|
|
@@ -388,6 +392,8 @@ export class CursorSession implements AgentSession {
|
|
|
388
392
|
|
|
389
393
|
const child = this.spawner(this.buildRequest(prompt));
|
|
390
394
|
this.current = child;
|
|
395
|
+
// Mark the gate now, before the child-exit await. The read itself waits
|
|
396
|
+
// until the CLI has rewritten auth.json.
|
|
391
397
|
const periodUsage = this.probePeriodUsage();
|
|
392
398
|
|
|
393
399
|
let acc = "";
|
|
@@ -520,7 +526,7 @@ export class CursorSession implements AgentSession {
|
|
|
520
526
|
child.on("exit", (code) => finish(code));
|
|
521
527
|
child.on("error", (err) => finish(null, err.message));
|
|
522
528
|
});
|
|
523
|
-
await periodUsage;
|
|
529
|
+
await periodUsage();
|
|
524
530
|
if (!this.closed) this.emit({ type: "turn_complete", usage: turnUsage });
|
|
525
531
|
}
|
|
526
532
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
import { agentOutputSource } from "./output-source";
|
|
1
3
|
/**
|
|
2
4
|
* DurableProcess (ADR-061) — LineProcess's durable twin. Instead of holding
|
|
3
5
|
* the agent CLI's pipes, it talks to the in-container runner
|
|
@@ -84,6 +86,8 @@ export class DurableProcess {
|
|
|
84
86
|
private readonly inbox: readonly string[];
|
|
85
87
|
private readonly heartbeat: readonly string[];
|
|
86
88
|
|
|
89
|
+
private readonly decoder = new StringDecoder("utf8");
|
|
90
|
+
private lineOffset = 0;
|
|
87
91
|
private readonly lineHandlers = new Set<LineHandler>();
|
|
88
92
|
private readonly exitHandlers = new Set<ExitHandler>();
|
|
89
93
|
|
|
@@ -108,6 +112,7 @@ export class DurableProcess {
|
|
|
108
112
|
this.inbox = [...opts.sessionFiles.parts, "inbox.jsonl"];
|
|
109
113
|
this.heartbeat = [...opts.sessionFiles.parts, "heartbeat"];
|
|
110
114
|
this.offset = opts.initialOutboxOffset ?? 0;
|
|
115
|
+
this.lineOffset = this.offset;
|
|
111
116
|
this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
|
|
112
117
|
this.onCloseRequested = opts.onCloseRequested ?? null;
|
|
113
118
|
if (opts.ready) {
|
|
@@ -156,7 +161,7 @@ export class DurableProcess {
|
|
|
156
161
|
if (read === null) break; // outbox not created yet
|
|
157
162
|
if (read.bytes.length === 0) break;
|
|
158
163
|
this.offset += read.bytes.length;
|
|
159
|
-
this.lineBuf += read.bytes
|
|
164
|
+
this.lineBuf += this.decoder.write(read.bytes);
|
|
160
165
|
this.drainLines();
|
|
161
166
|
if (this.offset >= read.size) break;
|
|
162
167
|
}
|
|
@@ -171,7 +176,7 @@ export class DurableProcess {
|
|
|
171
176
|
}
|
|
172
177
|
if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
|
|
173
178
|
try {
|
|
174
|
-
this.onOffsetAdvance(this.
|
|
179
|
+
this.onOffsetAdvance(this.lineOffset);
|
|
175
180
|
} catch {
|
|
176
181
|
// Persistence hiccup — the next batch retries with a larger offset.
|
|
177
182
|
}
|
|
@@ -182,7 +187,9 @@ export class DurableProcess {
|
|
|
182
187
|
private drainLines(): void {
|
|
183
188
|
let nl: number;
|
|
184
189
|
while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
|
|
185
|
-
const
|
|
190
|
+
const rawLine = this.lineBuf.slice(0, nl);
|
|
191
|
+
this.lineOffset += Buffer.byteLength(rawLine) + 1;
|
|
192
|
+
const line = rawLine.trim();
|
|
186
193
|
this.lineBuf = this.lineBuf.slice(nl + 1);
|
|
187
194
|
if (line.length === 0) continue;
|
|
188
195
|
if (line.startsWith('{"__uai"')) {
|
|
@@ -192,7 +199,7 @@ export class DurableProcess {
|
|
|
192
199
|
if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
|
|
193
200
|
for (const h of this.lineHandlers) {
|
|
194
201
|
try {
|
|
195
|
-
h(line);
|
|
202
|
+
agentOutputSource.run(this.durableSessionDir + ":" + this.lineOffset, () => h(line));
|
|
196
203
|
} catch {
|
|
197
204
|
// A broken handler must not wedge the tail loop.
|
|
198
205
|
}
|
|
@@ -293,7 +300,7 @@ export class DurableProcess {
|
|
|
293
300
|
|
|
294
301
|
/** Consumed-outbox byte offset — persist this to survive host restarts. */
|
|
295
302
|
get outboxOffset(): number {
|
|
296
|
-
return this.
|
|
303
|
+
return this.lineOffset;
|
|
297
304
|
}
|
|
298
305
|
|
|
299
306
|
/**
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
import { agentOutputSource } from "./output-source";
|
|
1
3
|
/**
|
|
2
4
|
* ADR-121 + ADR-061: durable agent sessions for MACHINE-backed tasks.
|
|
3
5
|
*
|
|
@@ -139,6 +141,8 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
139
141
|
readonly #lineHandlers = new Set<LineHandler>();
|
|
140
142
|
readonly #exitHandlers = new Set<ExitHandler>();
|
|
141
143
|
|
|
144
|
+
readonly #decoder = new StringDecoder("utf8");
|
|
145
|
+
#lineOffset = 0;
|
|
142
146
|
#offset: number;
|
|
143
147
|
#lineBuf = "";
|
|
144
148
|
#stderrBuf = "";
|
|
@@ -166,6 +170,7 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
166
170
|
this.#outboxPath = posix.join(opts.sessionDir, "outbox.jsonl");
|
|
167
171
|
this.#inboxPath = posix.join(opts.sessionDir, "inbox.jsonl");
|
|
168
172
|
this.#offset = opts.initialOutboxOffset ?? 0;
|
|
173
|
+
this.#lineOffset = this.#offset;
|
|
169
174
|
this.#onOffsetAdvance = opts.onOffsetAdvance ?? null;
|
|
170
175
|
this.#onCloseRequested = opts.onCloseRequested ?? null;
|
|
171
176
|
this.#debug =
|
|
@@ -248,11 +253,11 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
248
253
|
if (generation !== this.#tailGeneration) break;
|
|
249
254
|
const text = Buffer.from(chunk);
|
|
250
255
|
this.#offset += text.byteLength;
|
|
251
|
-
this.#lineBuf +=
|
|
256
|
+
this.#lineBuf += this.#decoder.write(text);
|
|
252
257
|
this.#drainLines();
|
|
253
258
|
if (this.#onOffsetAdvance && !this.#closed) {
|
|
254
259
|
try {
|
|
255
|
-
this.#onOffsetAdvance(this.#
|
|
260
|
+
this.#onOffsetAdvance(this.#lineOffset);
|
|
256
261
|
} catch {
|
|
257
262
|
// Persistence hiccup — the next batch retries larger.
|
|
258
263
|
}
|
|
@@ -282,7 +287,9 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
282
287
|
#drainLines(): void {
|
|
283
288
|
let nl: number;
|
|
284
289
|
while ((nl = this.#lineBuf.indexOf("\n")) >= 0) {
|
|
285
|
-
const
|
|
290
|
+
const rawLine = this.#lineBuf.slice(0, nl);
|
|
291
|
+
this.#lineOffset += Buffer.byteLength(rawLine) + 1;
|
|
292
|
+
const line = rawLine.trim();
|
|
286
293
|
this.#lineBuf = this.#lineBuf.slice(nl + 1);
|
|
287
294
|
if (line.length === 0) continue;
|
|
288
295
|
if (line.startsWith('{"__uai"')) {
|
|
@@ -292,7 +299,7 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
292
299
|
if (this.#debug) this.#log(`<- ${line.slice(0, 1000)}`);
|
|
293
300
|
for (const handler of this.#lineHandlers) {
|
|
294
301
|
try {
|
|
295
|
-
handler(line);
|
|
302
|
+
agentOutputSource.run(this.durableSessionDir + ":" + this.#lineOffset, () => handler(line));
|
|
296
303
|
} catch {
|
|
297
304
|
// A broken handler must not wedge the tail loop.
|
|
298
305
|
}
|
|
@@ -464,7 +471,7 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
464
471
|
}
|
|
465
472
|
|
|
466
473
|
get outboxOffset(): number {
|
|
467
|
-
return this.#
|
|
474
|
+
return this.#lineOffset;
|
|
468
475
|
}
|
|
469
476
|
|
|
470
477
|
/** Stop consuming WITHOUT touching the runner (host restart in miniature). */
|
package/lib/agents/transport.ts
CHANGED
|
@@ -118,6 +118,11 @@ interface DetachableTail extends LineTransport {
|
|
|
118
118
|
|
|
119
119
|
const liveTails = new Map<string, DetachableTail>();
|
|
120
120
|
|
|
121
|
+
export function durableAgentSessionId(taskId: string, agentId: string): string | undefined {
|
|
122
|
+
const tail = liveTails.get(`${taskId}:${agentId}`);
|
|
123
|
+
return tail && !tail.isClosed ? tail.durableSessionDir : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
121
126
|
function claimTail<T extends DetachableTail>(key: string, proc: T): T {
|
|
122
127
|
liveTails.get(key)?.detach();
|
|
123
128
|
liveTails.set(key, proc);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Host-owned durable correlation, independent of a CLI's mutable history. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import type { AssignmentDelivery, AssignmentTurnEvidence } from "../src/protocol";
|
|
6
|
+
|
|
7
|
+
interface State {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
current: AssignmentDelivery | null;
|
|
10
|
+
active: AssignmentTurnEvidence[];
|
|
11
|
+
deliveries: string[];
|
|
12
|
+
observations: Record<string, AssignmentTurnEvidence>;
|
|
13
|
+
}
|
|
14
|
+
/** One journal per task/agent, in runner-private task data, never /workspace. */
|
|
15
|
+
export class AssignmentEvidenceJournal {
|
|
16
|
+
constructor(private readonly path: string) {}
|
|
17
|
+
private read(sessionId: string): State {
|
|
18
|
+
let state: State;
|
|
19
|
+
try { state = JSON.parse(readFileSync(this.path, "utf8")) as State; }
|
|
20
|
+
catch (error) {
|
|
21
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
22
|
+
return { sessionId, current: null, active: [], deliveries: [], observations: {} };
|
|
23
|
+
}
|
|
24
|
+
// A replacement CLI is a new execution generation; never attach the
|
|
25
|
+
// predecessor's turn to it. Explicit redispatch can restore the assignment.
|
|
26
|
+
return state.sessionId === sessionId ? state : { sessionId, current: state.current, active: [], deliveries: [], observations: {} };
|
|
27
|
+
}
|
|
28
|
+
private write(state: State) {
|
|
29
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
30
|
+
const temp = this.path + "." + randomUUID();
|
|
31
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
32
|
+
try { writeFileSync(fd, JSON.stringify(state)); fsyncSync(fd); } finally { closeSync(fd); }
|
|
33
|
+
renameSync(temp, this.path);
|
|
34
|
+
const dir = openSync(dirname(this.path), "r");
|
|
35
|
+
try { fsyncSync(dir); } finally { closeSync(dir); }
|
|
36
|
+
}
|
|
37
|
+
begin(input: { sessionId: string; engine: string; runEpoch: number; runtimeVersion: string | null; recipientGeneration: string | null; assignment?: AssignmentDelivery | null; replay?: boolean; leadRunId?: string | null }): (AssignmentTurnEvidence & { assignment: AssignmentDelivery | null }) | null {
|
|
38
|
+
const state = this.read(input.sessionId);
|
|
39
|
+
if (state.current && (state.current.runEpoch !== input.runEpoch || state.current.recipientGeneration !== input.recipientGeneration)) {
|
|
40
|
+
state.current = null;
|
|
41
|
+
state.active = [];
|
|
42
|
+
state.deliveries = [];
|
|
43
|
+
}
|
|
44
|
+
// Assignment id, not attempt id: a lost cloud ack must not send the same
|
|
45
|
+
// instruction twice. Old attempts retain their original provenance.
|
|
46
|
+
if (!input.replay && input.assignment && state.deliveries.includes(input.assignment.assignmentId)) return null;
|
|
47
|
+
if (input.assignment) {
|
|
48
|
+
// A replay carries its original identity without retargeting subsequent
|
|
49
|
+
// new inputs to an older assignment. Deduplication applies to cloud
|
|
50
|
+
// deliveries; an internally retained prompt is an execution retry.
|
|
51
|
+
if (!input.replay) state.current = input.assignment;
|
|
52
|
+
if (!state.deliveries.includes(input.assignment.assignmentId)) state.deliveries.push(input.assignment.assignmentId);
|
|
53
|
+
}
|
|
54
|
+
const current = input.assignment === null ? null : input.assignment ?? state.current;
|
|
55
|
+
const evidence: AssignmentTurnEvidence = {
|
|
56
|
+
turnId: randomUUID(), sessionId: input.sessionId, runEpoch: input.runEpoch,
|
|
57
|
+
assignmentId: current?.assignmentId ?? null, deliveryId: current?.deliveryId ?? null,
|
|
58
|
+
recipientGeneration: current?.recipientGeneration ?? (input.leadRunId ? input.recipientGeneration : null),
|
|
59
|
+
...(input.leadRunId ? { leadRunId: input.leadRunId } : {}),
|
|
60
|
+
correlation: input.assignment || input.leadRunId ? "explicit" : current ? "followup" : "unknown",
|
|
61
|
+
acceptedAt: Date.now(), engine: input.engine, runtimeVersion: input.runtimeVersion,
|
|
62
|
+
};
|
|
63
|
+
// Concurrent prompts aren't assumed to execute FIFO. Without a provider
|
|
64
|
+
// turn/request join, every overlapping turn is explicitly unallocated.
|
|
65
|
+
if (state.active.length) {
|
|
66
|
+
for (const turn of state.active) { turn.assignmentId = null; turn.deliveryId = null; turn.leadRunId = null; turn.correlation = "ambiguous"; }
|
|
67
|
+
evidence.assignmentId = null; evidence.deliveryId = null; evidence.leadRunId = null; evidence.correlation = "ambiguous";
|
|
68
|
+
}
|
|
69
|
+
state.active.push(evidence);
|
|
70
|
+
this.write(state);
|
|
71
|
+
return { ...evidence, assignment: current };
|
|
72
|
+
}
|
|
73
|
+
observe(sessionId: string, sourceId: string | undefined, complete: boolean): AssignmentTurnEvidence | undefined {
|
|
74
|
+
const state = this.read(sessionId);
|
|
75
|
+
if (sourceId && state.observations[sourceId]) return state.observations[sourceId];
|
|
76
|
+
const active = state.active[0];
|
|
77
|
+
if (!active) return undefined;
|
|
78
|
+
const evidence = { ...active, ...(complete ? { endedAt: Date.now() } : {}) };
|
|
79
|
+
if (sourceId) state.observations[sourceId] = evidence;
|
|
80
|
+
if (complete) state.active.shift();
|
|
81
|
+
this.write(state);
|
|
82
|
+
return evidence;
|
|
83
|
+
}
|
|
84
|
+
failed(sessionId: string, turnId: string): void {
|
|
85
|
+
const state = this.read(sessionId);
|
|
86
|
+
const turn = state.active.find(t => t.turnId === turnId);
|
|
87
|
+
state.active = state.active.filter(t => t.turnId !== turnId);
|
|
88
|
+
// Failure before acceptance allows retry. Ambiguous sends stay unknown;
|
|
89
|
+
// never manufacture a successful execution from delivery alone.
|
|
90
|
+
if (turn?.assignmentId) state.deliveries = state.deliveries.filter(id => id !== turn.assignmentId);
|
|
91
|
+
this.write(state);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseTaskSecretMaterial, type TaskSecretManifestEntry, type TaskSecretMaterial } from "../src/task-secrets";
|
|
1
2
|
/**
|
|
2
3
|
* ADR-154 task-scoped credential leases.
|
|
3
4
|
*
|
|
@@ -86,7 +87,7 @@ export type LeaseGateCloseReason =
|
|
|
86
87
|
export type CredentialCleanupReason = LeaseGateCloseReason | "lease-replaced";
|
|
87
88
|
|
|
88
89
|
export interface CredentialCleanupIdentity {
|
|
89
|
-
class: "engine" | "mcp" | "github" | "ssh";
|
|
90
|
+
class: "engine" | "mcp" | "github" | "ssh" | "task-secret";
|
|
90
91
|
credentialId: string;
|
|
91
92
|
revision: string | null;
|
|
92
93
|
}
|
|
@@ -136,6 +137,7 @@ export interface TaskStaticCredential<T extends LeasedValue> {
|
|
|
136
137
|
|
|
137
138
|
/** A task-facing view. It contains material, but never STS credentials. */
|
|
138
139
|
export interface TaskCredentialView {
|
|
140
|
+
taskSecrets?: readonly { manifest: Readonly<TaskSecretManifestEntry>; material: Readonly<TaskSecretMaterial> | null }[];
|
|
139
141
|
assignment: Readonly<TaskAssignment>;
|
|
140
142
|
leaseId: string;
|
|
141
143
|
manifestRevision: number;
|
|
@@ -293,12 +295,13 @@ type CredentialDescriptor = {
|
|
|
293
295
|
sessionId: string | null;
|
|
294
296
|
authKind: "env" | "config-dir" | "oauth" | "token" | "none" | null;
|
|
295
297
|
location: CredentialLocation | null;
|
|
298
|
+
taskSecret?: { entry: TaskSecretManifestEntry; orgId: string; taskId: string };
|
|
296
299
|
};
|
|
297
300
|
|
|
298
301
|
type CredentialRecord = {
|
|
299
302
|
descriptor: CredentialDescriptor;
|
|
300
303
|
availability: CredentialMaterialAvailability;
|
|
301
|
-
material: LeasedValue | null;
|
|
304
|
+
material: LeasedValue | TaskSecretMaterial | null;
|
|
302
305
|
observedVersion: number | null;
|
|
303
306
|
};
|
|
304
307
|
|
|
@@ -527,13 +530,16 @@ function manifestDescriptors(lease: TaskLeaseV1): CredentialDescriptor[] {
|
|
|
527
530
|
...lease.manifest.engines.map(descriptorFromEngine),
|
|
528
531
|
...lease.manifest.mcp.map(descriptorFromMcp),
|
|
529
532
|
];
|
|
533
|
+
for (const entry of lease.manifest.taskSecrets ?? []) out.push({ class: "task-secret", credentialId: entry.credentialId,
|
|
534
|
+
revision: String(entry.generation), parameter: entry.parameter, version: entry.version, sessionId: entry.sessionId,
|
|
535
|
+
authKind: null, location: null, taskSecret: { entry, orgId: lease.orgId, taskId: lease.taskId } });
|
|
530
536
|
if (lease.manifest.github) out.push(descriptorFromStatic(lease.manifest.github, "github"));
|
|
531
537
|
if (lease.manifest.ssh) out.push(descriptorFromStatic(lease.manifest.ssh, "ssh"));
|
|
532
538
|
return out;
|
|
533
539
|
}
|
|
534
540
|
|
|
535
541
|
function materialMatchesDescriptor(
|
|
536
|
-
material: LeasedValue,
|
|
542
|
+
material: LeasedValue | TaskSecretMaterial,
|
|
537
543
|
descriptor: CredentialDescriptor,
|
|
538
544
|
): boolean {
|
|
539
545
|
if (material.class !== descriptor.class) return false;
|
|
@@ -542,7 +548,7 @@ function materialMatchesDescriptor(
|
|
|
542
548
|
return descriptor.authKind === null;
|
|
543
549
|
}
|
|
544
550
|
|
|
545
|
-
function freezeMaterial<T extends LeasedValue>(material: T): Readonly<T> {
|
|
551
|
+
function freezeMaterial<T extends LeasedValue | TaskSecretMaterial>(material: T): Readonly<T> {
|
|
546
552
|
Object.freeze(material.location);
|
|
547
553
|
if (material.class === "engine") {
|
|
548
554
|
if (material.authKind === "env") Object.freeze(material.env);
|
|
@@ -628,6 +634,11 @@ function freezeView(
|
|
|
628
634
|
manifestDigest: lease.manifestDigest,
|
|
629
635
|
expiresAt,
|
|
630
636
|
projectIds: Object.freeze(lease.manifest.projects.map((project) => project.projectId)),
|
|
637
|
+
...(lease.manifest.taskSecrets?.length ? { taskSecrets: Object.freeze(lease.manifest.taskSecrets.map(manifest => {
|
|
638
|
+
const record = requireRecord(manifest.credentialId);
|
|
639
|
+
if (record.material !== null && record.material.class !== "task-secret") throw new CredentialLeaseError("task secret material is incomplete");
|
|
640
|
+
return Object.freeze({ manifest: Object.freeze({ ...manifest, domains: Object.freeze([...manifest.domains]) as unknown as string[] }), material: record.material as Readonly<TaskSecretMaterial> | null });
|
|
641
|
+
})) } : {}),
|
|
631
642
|
engines: Object.freeze(engines),
|
|
632
643
|
mcp: Object.freeze(mcp),
|
|
633
644
|
github: staticCredential<GithubLeasedValue>(lease.manifest.github, "github"),
|
|
@@ -1671,7 +1682,7 @@ export class TaskCredentialLeaseRegistry {
|
|
|
1671
1682
|
!descriptor.parameter ||
|
|
1672
1683
|
!descriptor.version ||
|
|
1673
1684
|
!descriptor.sessionId ||
|
|
1674
|
-
!descriptor.location
|
|
1685
|
+
(!descriptor.location && !descriptor.taskSecret)
|
|
1675
1686
|
) {
|
|
1676
1687
|
throw new CredentialLeaseError("manifest credential is incomplete");
|
|
1677
1688
|
}
|
|
@@ -1729,7 +1740,13 @@ export class TaskCredentialLeaseRegistry {
|
|
|
1729
1740
|
if (!selectors.includes(selector)) {
|
|
1730
1741
|
throw new CredentialLeaseError("Parameter Store returned an unrequested credential version");
|
|
1731
1742
|
}
|
|
1732
|
-
|
|
1743
|
+
let material: LeasedValue | TaskSecretMaterial | null;
|
|
1744
|
+
if (descriptor.taskSecret) {
|
|
1745
|
+
const { entry, orgId, taskId } = descriptor.taskSecret;
|
|
1746
|
+
const digest = createHash("sha256").update(entry.name).digest("hex");
|
|
1747
|
+
if (entry.parameter !== `/uai/lease/tasks/${taskId}/secrets/${digest}/${entry.stagingId}`) throw new CredentialLeaseError("task secret parameter does not match its name");
|
|
1748
|
+
material = parseTaskSecretMaterial(parameter.value, { orgId, taskId, name: entry.name, stagingId: entry.stagingId });
|
|
1749
|
+
} else material = parseLeasedValue(parameter.value, descriptor.location!);
|
|
1733
1750
|
if (!material || !materialMatchesDescriptor(material, descriptor)) {
|
|
1734
1751
|
throw new CredentialLeaseError("credential envelope does not match its manifest entry");
|
|
1735
1752
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TaskSecretApplied } from "../src/task-secrets";
|
|
2
|
+
import { openTaskSecrets, closeTaskSecrets } from "./credential-consumers/task-secrets";
|
|
1
3
|
/**
|
|
2
4
|
* ADR-154 on the runner: the one place a task on cloud credentials is wired.
|
|
3
5
|
*
|
|
@@ -148,7 +150,7 @@ export class AuthoritativeTaskFilesFailure extends Error {
|
|
|
148
150
|
export interface CloudCredentialRuntimeDeps {
|
|
149
151
|
runnerKind: "owned" | "task-machine";
|
|
150
152
|
/** To the cloud, over the authenticated bridge. False: not sent now. */
|
|
151
|
-
send(frame: LeaseRequestV1Frame | CredentialRefreshFrame | ProjectEnvReplicaV1AckFrame): boolean;
|
|
153
|
+
send(frame: LeaseRequestV1Frame | CredentialRefreshFrame | ProjectEnvReplicaV1AckFrame | TaskSecretApplied): boolean;
|
|
152
154
|
/** Every engine of the run (and older) proven stopped in every runtime it
|
|
153
155
|
* ran in: the orchestrator's `quiesceLeasedEngineAgents`. */
|
|
154
156
|
quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
@@ -1069,6 +1071,7 @@ export class CloudCredentialRuntime {
|
|
|
1069
1071
|
|
|
1070
1072
|
#openGate(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
|
|
1071
1073
|
if (this.#shutDown) throw new CredentialLeaseUnavailableError("the runner is shutting down");
|
|
1074
|
+
openTaskSecrets(assignment, view);
|
|
1072
1075
|
openEngineGate(assignment, view);
|
|
1073
1076
|
openMcpGate(assignment, view);
|
|
1074
1077
|
openPersonCredentialGate(assignment, view);
|
|
@@ -1085,6 +1088,11 @@ export class CloudCredentialRuntime {
|
|
|
1085
1088
|
this.#personCredentialsChanged(assignment, view);
|
|
1086
1089
|
this.#publishMcpMembership(assignment.taskId, view);
|
|
1087
1090
|
this.#runRecoveredSetup(assignment);
|
|
1091
|
+
// Empty sets matter too: a new runner process may first see a parked task after revocation.
|
|
1092
|
+
queueMicrotask(() => {
|
|
1093
|
+
if (this.#shutDown || this.registry.currentView(assignment) !== view) return;
|
|
1094
|
+
this.#deps.send({ kind: "task.secret.applied", taskId: assignment.taskId, runEpoch: assignment.runEpoch, manifestRevision: view.manifestRevision, manifestDigest: view.manifestDigest });
|
|
1095
|
+
});
|
|
1088
1096
|
}
|
|
1089
1097
|
|
|
1090
1098
|
#personCredentialsChanged(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
|
|
@@ -1201,6 +1209,7 @@ export class CloudCredentialRuntime {
|
|
|
1201
1209
|
attempt(() => entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials were withdrawn")));
|
|
1202
1210
|
}
|
|
1203
1211
|
}
|
|
1212
|
+
attempt(() => closeTaskSecrets(assignment));
|
|
1204
1213
|
attempt(() => closeEngineGate(assignment));
|
|
1205
1214
|
attempt(() => {
|
|
1206
1215
|
const mcp = leasedMcpFor(assignment.taskId);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TaskAssignment, TaskCredentialView } from "../cloud-credential-leases";
|
|
2
|
+
const served = new Map<string, TaskCredentialView>();
|
|
3
|
+
export function openTaskSecrets(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void { if (view.assignment.taskId !== assignment.taskId || view.assignment.runEpoch !== assignment.runEpoch) throw new Error("task secret assignment mismatch"); served.set(assignment.taskId, view); }
|
|
4
|
+
export function closeTaskSecrets(assignment: Readonly<TaskAssignment>): void { const view = served.get(assignment.taskId); if (view && view.assignment.runEpoch <= assignment.runEpoch) served.delete(assignment.taskId); }
|
|
5
|
+
export function taskSecretsView(taskId: string, runEpoch?: number | null) { const view = served.get(taskId); return view && (runEpoch == null || view.assignment.runEpoch === runEpoch) ? view.taskSecrets ?? [] : []; }
|
|
6
|
+
export function deliveredTaskSecrets(taskId: string, runEpoch?: number | null): { revision: string; withheld: string[]; values: Record<string, string> } {
|
|
7
|
+
const entries = taskSecretsView(taskId, runEpoch); const values: Record<string,string> = Object.create(null) as Record<string,string>;
|
|
8
|
+
for (const e of entries) if (e.manifest.mode === "delivered" && e.material) values[e.manifest.name] = e.material.value;
|
|
9
|
+
return { revision: JSON.stringify(entries.map(e => [e.manifest.name, e.manifest.generation, e.material !== null])), withheld: entries.map(e => e.manifest.name), values };
|
|
10
|
+
}
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ENGINE_ACCOUNT_PATHS } from "../src/engine-account-paths";
|
|
1
2
|
/**
|
|
2
3
|
* Engine accounts (ADR-076) — multiple credentials per engine kind, selected
|
|
3
4
|
* by ENV at each `docker exec`, rotated automatically with rate-limit failover.
|
|
@@ -97,26 +98,23 @@ const KINDS: Record<string, KindDescriptor> = {
|
|
|
97
98
|
grok: {
|
|
98
99
|
authKind: "config-dir",
|
|
99
100
|
defaultHostDir: (owner) => join(owner, ".grok"),
|
|
100
|
-
|
|
101
|
+
...ENGINE_ACCOUNT_PATHS.grok,
|
|
101
102
|
selectVar: "GROK_HOME",
|
|
102
|
-
containerDirPrefix: "/home/node/.grok-acct-",
|
|
103
103
|
extraHostDir: (owner, id) => join(owner, `.grok-acct-${id}`),
|
|
104
104
|
},
|
|
105
105
|
cursor: { authKind: "env" },
|
|
106
106
|
codex: {
|
|
107
107
|
authKind: "config-dir",
|
|
108
108
|
defaultHostDir: (owner) => join(owner, ".codex"),
|
|
109
|
-
|
|
109
|
+
...ENGINE_ACCOUNT_PATHS.codex,
|
|
110
110
|
selectVar: "CODEX_HOME",
|
|
111
|
-
containerDirPrefix: "/home/node/.codex-acct-",
|
|
112
111
|
extraHostDir: (owner, id) => join(owner, `.codex-acct-${id}`),
|
|
113
112
|
},
|
|
114
113
|
opencode: {
|
|
115
114
|
authKind: "config-dir",
|
|
116
115
|
defaultHostDir: (owner) => join(owner, ".local", "share", "opencode"),
|
|
117
|
-
|
|
116
|
+
...ENGINE_ACCOUNT_PATHS.opencode,
|
|
118
117
|
selectVar: "OPENCODE_DATA_DIR",
|
|
119
|
-
containerDirPrefix: "/home/node/.local/share/opencode-acct-",
|
|
120
118
|
extraHostDir: (owner, id) => join(owner, ".local", "share", `opencode-acct-${id}`),
|
|
121
119
|
},
|
|
122
120
|
};
|
package/lib/host-env.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
queueStoreWrite,
|
|
24
24
|
type CredentialStore,
|
|
25
25
|
} from "./credential-store";
|
|
26
|
-
import {
|
|
26
|
+
import { isReservedTaskEnvName } from "../src/task-env-names";
|
|
27
27
|
import { getDb, schema } from "./db";
|
|
28
28
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
29
29
|
import { signInBoxMode } from "./signin-box";
|
|
@@ -401,32 +401,7 @@ export function getDecryptedForProject(projectId: string): Record<string, string
|
|
|
401
401
|
* container tasks: git/gh plumbing, loader and shell hooks, toolchain config,
|
|
402
402
|
* the host's own `UAI_*` namespace, engine credentials, and the browser path.
|
|
403
403
|
*/
|
|
404
|
-
|
|
405
|
-
"GH_TOKEN", "GITHUB_TOKEN", "GH_CONFIG_DIR", "GH_HOST",
|
|
406
|
-
"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY",
|
|
407
|
-
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_INDEX_FILE", "GIT_EXEC_PATH",
|
|
408
|
-
"GIT_ASKPASS", "SSH_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND",
|
|
409
|
-
"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
|
|
410
|
-
"PATH", "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "NODE_OPTIONS",
|
|
411
|
-
"NODE_PATH", "SHELL", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
|
|
412
|
-
"NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE",
|
|
413
|
-
"__CURSOR_SANDBOX_ENV_RESTORE", "GROK_LOGIN_ENV",
|
|
414
|
-
// Every name an env-token engine's account may set (ADR-154's contract):
|
|
415
|
-
// a project value never stands in for an engine's credential.
|
|
416
|
-
...Object.values(ENGINE_ENV_CREDENTIAL_KEYS).flat(),
|
|
417
|
-
"PLAYWRIGHT_BROWSERS_PATH",
|
|
418
|
-
]);
|
|
419
|
-
const RESERVED_TASK_ENV_NPM = /^npm_config_(prefix|cache|logs[_-]dir|umask)$/i;
|
|
420
|
-
/** Toolchain prefixes, in any case, as task-up.sh matches them. */
|
|
421
|
-
const RESERVED_TASK_ENV_TOOLCHAIN = /^(asdf|corepack)_/i;
|
|
422
|
-
|
|
423
|
-
export function isReservedTaskEnvName(name: string): boolean {
|
|
424
|
-
if (RESERVED_TASK_ENV_EXACT.has(name)) return true;
|
|
425
|
-
if (name.startsWith("GIT_CONFIG") || RESERVED_TASK_ENV_TOOLCHAIN.test(name)) return true;
|
|
426
|
-
if (name.startsWith("UAI_")) return true;
|
|
427
|
-
if (name.startsWith("__UAI_ENV_")) return true;
|
|
428
|
-
return RESERVED_TASK_ENV_NPM.test(name);
|
|
429
|
-
}
|
|
404
|
+
export { isReservedTaskEnvName } from "../src/task-env-names";
|
|
430
405
|
|
|
431
406
|
/**
|
|
432
407
|
* Rollout-only project env for an ADR-121 machine task whose persisted mode
|