@runuai/host 0.9.166 → 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/images/standard/Dockerfile +3 -3
- package/lib/agent-cli.ts +789 -21
- package/lib/agents/codex.ts +2 -2
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +201 -25
- package/lib/agents/durable-proc.ts +12 -5
- package/lib/agents/grok.ts +21 -8
- package/lib/agents/machine-durable.ts +12 -5
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/registry.ts +19 -19
- package/lib/agents/transport.ts +5 -0
- package/lib/agents/types.ts +1 -1
- package/lib/agents/usage.ts +16 -9
- 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 +124 -18
- package/lib/project-env-loader.ts +5 -0
- package/lib/secrets-proxy/policy.ts +12 -3
- package/lib/standard-image.ts +1 -1
- 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/engine-models.ts +16 -8
- package/src/index.ts +66 -3
- package/src/main.ts +125 -0
- package/src/protocol.ts +190 -4
- 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/grok.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
spawnEnvironmentProcess,
|
|
45
45
|
type AgentEnvironmentProcess,
|
|
46
46
|
} from "./proc";
|
|
47
|
+
import { rateLimitErrorUsage } from "./rate-limit";
|
|
47
48
|
import { register } from "./registry";
|
|
48
49
|
import { GROK_MODELS } from "../../src/engine-models";
|
|
49
50
|
import type {
|
|
@@ -58,10 +59,9 @@ import type {
|
|
|
58
59
|
const GROK_BIN = "/home/node/.grok/bin/grok";
|
|
59
60
|
export const GROK_LOADER_LOGIN_ENV = "0";
|
|
60
61
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
const GROK_DEFAULT_MODEL = "grok-4.5";
|
|
62
|
+
// Static fallback catalog verified against `grok models`; keep the shared list
|
|
63
|
+
// in host-agent/src/engine-models.ts (GROK_MODELS) current with the pinned CLI.
|
|
64
|
+
const GROK_DEFAULT_MODEL = "grok-4.7";
|
|
65
65
|
// Grok has no per-invocation reasoning-effort flag.
|
|
66
66
|
const GROK_EFFORTS: string[] = [];
|
|
67
67
|
|
|
@@ -313,10 +313,23 @@ export class GrokSession implements AgentSession {
|
|
|
313
313
|
this.emit({ type: "error", message: `grok spawn failed: ${spawnErr}` });
|
|
314
314
|
} else if (code !== 0) {
|
|
315
315
|
const tail = stderr.trim().slice(-500);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
const message = `grok exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`;
|
|
317
|
+
// Same path as Claude/Codex/OpenCode: a usage-cap / 429 error emits
|
|
318
|
+
// account_usage (source error-text) so the orchestrator can rotate.
|
|
319
|
+
const accountUsage = rateLimitErrorUsage(message);
|
|
320
|
+
if (accountUsage) {
|
|
321
|
+
this.emit({ type: "account_usage", usage: accountUsage });
|
|
322
|
+
this.emit({
|
|
323
|
+
type: "error",
|
|
324
|
+
message,
|
|
325
|
+
retryable: "rate_limit",
|
|
326
|
+
...(accountUsage.resetsAt === undefined
|
|
327
|
+
? {}
|
|
328
|
+
: { resetsAt: accountUsage.resetsAt }),
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
this.emit({ type: "error", message });
|
|
332
|
+
}
|
|
320
333
|
}
|
|
321
334
|
// Finalize the streamed message (no-op text if the turn produced none).
|
|
322
335
|
if (sawText) this.emit({ type: "message_complete", text: acc });
|
|
@@ -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/registry.ts
CHANGED
|
@@ -144,25 +144,25 @@ export function capabilities(): AgentKindCapability[] {
|
|
|
144
144
|
: (adapter.available?.() ?? true) || advertisementFallback(adapter.kind),
|
|
145
145
|
)
|
|
146
146
|
.map((adapter) => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
147
|
+
const out: AgentKindCapability = {
|
|
148
|
+
kind: adapter.kind,
|
|
149
|
+
label: adapter.label,
|
|
150
|
+
supportedModels: adapter.supportedModels(),
|
|
151
|
+
supportedEfforts: adapter.supportedEfforts(),
|
|
152
|
+
};
|
|
153
|
+
if (adapter.defaultModel !== undefined) {
|
|
154
|
+
out.defaultModel = adapter.defaultModel;
|
|
155
|
+
}
|
|
156
|
+
if (adapter.defaultEffort !== undefined) {
|
|
157
|
+
out.defaultEffort = adapter.defaultEffort;
|
|
158
|
+
}
|
|
159
|
+
if (adapter.executionProfiles && adapter.executionProfiles.length > 0) {
|
|
160
|
+
out.executionProfiles = adapter.executionProfiles.map((profile) => ({
|
|
161
|
+
...profile,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
});
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
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);
|
package/lib/agents/types.ts
CHANGED
|
@@ -124,7 +124,7 @@ export interface AgentAccountUsage {
|
|
|
124
124
|
resetsAt?: number;
|
|
125
125
|
usingOverage?: boolean;
|
|
126
126
|
windows: EngineUsageWindow[];
|
|
127
|
-
source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
|
|
127
|
+
source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text" | "cursor-period-usage";
|
|
128
128
|
observedAt: number;
|
|
129
129
|
/** Which provider capacity this reading can prove usable again. Claude emits
|
|
130
130
|
* one event per named window; Codex snapshots cover every window. Internal
|
package/lib/agents/usage.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared token/cost extraction for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Shared token/cost extraction for a stream-json `result` envelope (ADR-071
|
|
3
|
+
* metering). Claude Code reports snake_case `usage` plus `total_cost_usd` and
|
|
4
|
+
* per-model `modelUsage`. cursor-agent 2026.10.x reports camelCase counts and
|
|
5
|
+
* no cost: `{inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens}`.
|
|
6
|
+
* Snake_case wins when a field is present, so Claude's path is unchanged.
|
|
6
7
|
*
|
|
7
8
|
* Codex (app-server protocol) and Kimi/Grok (plain text streams) report no
|
|
8
|
-
* token/cost data
|
|
9
|
+
* token/cost data through this extractor.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { AgentUsage } from "./types";
|
|
@@ -18,6 +19,12 @@ function num(v: unknown): number | undefined {
|
|
|
18
19
|
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** Claude's name when it is a number, otherwise Cursor 2026.10's. */
|
|
23
|
+
function firstNum(claude: unknown, cursor: unknown): number | undefined {
|
|
24
|
+
const primary = num(claude);
|
|
25
|
+
return primary !== undefined ? primary : num(cursor);
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
/** Pull AgentUsage from a `result` line, or undefined when none is reported. */
|
|
22
29
|
export function extractResultUsage(
|
|
23
30
|
json: Record<string, unknown>,
|
|
@@ -26,10 +33,10 @@ export function extractResultUsage(
|
|
|
26
33
|
const models = isObj(json.modelUsage) ? Object.keys(json.modelUsage) : [];
|
|
27
34
|
const usage: AgentUsage = {
|
|
28
35
|
model: models.length ? models.join(", ") : undefined,
|
|
29
|
-
inputTokens:
|
|
30
|
-
outputTokens:
|
|
31
|
-
cacheReadTokens:
|
|
32
|
-
cacheCreateTokens:
|
|
36
|
+
inputTokens: firstNum(u.input_tokens, u.inputTokens),
|
|
37
|
+
outputTokens: firstNum(u.output_tokens, u.outputTokens),
|
|
38
|
+
cacheReadTokens: firstNum(u.cache_read_input_tokens, u.cacheReadTokens),
|
|
39
|
+
cacheCreateTokens: firstNum(u.cache_creation_input_tokens, u.cacheWriteTokens),
|
|
33
40
|
costUsd: num(json.total_cost_usd),
|
|
34
41
|
};
|
|
35
42
|
// Nothing usable reported → omit rather than send an empty object.
|
|
@@ -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
|