@runuai/host 0.9.167 → 0.9.169
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 +811 -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/disk-pressure.ts +42 -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/standard-image-cleanup.ts +117 -0
- package/lib/standard-image.ts +31 -9
- package/lib/task-env-source.ts +2 -0
- package/lib/task-environment/types.ts +20 -15
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -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 +142 -0
- package/src/protocol.ts +204 -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/orchestrator.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { AssignmentEvidenceJournal } from "./assignment-evidence";
|
|
3
|
+
import { durableAgentSessionId } from "./agents/transport";
|
|
4
|
+
import { agentOutputSource } from "./agents/output-source";
|
|
5
|
+
import { taskDir } from "./env";
|
|
6
|
+
import { readTaskRunEpochState } from "./runtime-state";
|
|
7
|
+
import type { AssignmentDelivery, AssignmentTurnEvidence } from "../src/protocol";
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Orchestrator — the chat channel runtime.
|
|
3
11
|
*
|
|
@@ -292,6 +300,7 @@ interface Channel {
|
|
|
292
300
|
/** ADR-083: host-derived role assignment for execution-profile enforcement. */
|
|
293
301
|
mode: "open" | "secretary";
|
|
294
302
|
secretaryAgentId?: string;
|
|
303
|
+
leadRunId?: string | null;
|
|
295
304
|
sessions: Map<string, AgentSession>;
|
|
296
305
|
/** Session-spawn inputs, kept so sessions can be started lazily. */
|
|
297
306
|
containerName: string;
|
|
@@ -386,10 +395,10 @@ interface Channel {
|
|
|
386
395
|
* boundary. Adapters do not share one mid-turn input contract, so only this
|
|
387
396
|
* cross-lane role is serialized at the host boundary; open mode keeps its
|
|
388
397
|
* legacy immediate-concurrent delivery behavior. */
|
|
389
|
-
pendingPrompts: Map<string,
|
|
398
|
+
pendingPrompts: Map<string, AcceptedPrompt[]>;
|
|
390
399
|
/** Prompts accepted by channelDeliver whose final session boundary rejected
|
|
391
400
|
* specifically because the container runtime became unavailable. */
|
|
392
|
-
runtimeDeferredPrompts: Map<string,
|
|
401
|
+
runtimeDeferredPrompts: Map<string, AcceptedPrompt[]>;
|
|
393
402
|
/** Missing/blocked sessions to reconcile exactly once after runtime ready. */
|
|
394
403
|
runtimeDeferredAgents: Set<string>;
|
|
395
404
|
/** Agents whose current turn was interrupted (ESC) — their next
|
|
@@ -471,7 +480,7 @@ interface Channel {
|
|
|
471
480
|
accountByAgent: Map<string, string>;
|
|
472
481
|
/** ADR-076: the last prompt delivered to each agent, re-sent after an
|
|
473
482
|
* account rotation so the interrupted turn resumes on the new account. */
|
|
474
|
-
lastPrompt: Map<string,
|
|
483
|
+
lastPrompt: Map<string, AcceptedPrompt>;
|
|
475
484
|
/** ADR-076: per-agent account-rotation budget (reset on a successful turn) —
|
|
476
485
|
* bounds a rate-limit storm across accounts, mirroring the respawn budget. */
|
|
477
486
|
rotations: Map<string, number>;
|
|
@@ -537,8 +546,27 @@ function mcpAgentMembershipFingerprint(
|
|
|
537
546
|
);
|
|
538
547
|
}
|
|
539
548
|
|
|
549
|
+
const evidenceSessionIds = new WeakMap<AgentSession, string>();
|
|
550
|
+
function evidenceSessionId(taskId: string, agentId: string, session: AgentSession): string {
|
|
551
|
+
let id = evidenceSessionIds.get(session);
|
|
552
|
+
if (!id) {
|
|
553
|
+
// Pin to this session object, including its final/aborted boundary. The
|
|
554
|
+
// registry may already contain its replacement by the time output ends.
|
|
555
|
+
id = durableAgentSessionId(taskId, agentId) ?? randomUUID();
|
|
556
|
+
evidenceSessionIds.set(session, id);
|
|
557
|
+
}
|
|
558
|
+
return id;
|
|
559
|
+
}
|
|
560
|
+
const evidenceRuntimeVersion: string | null = (() => {
|
|
561
|
+
try { return (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version; }
|
|
562
|
+
catch { return null; }
|
|
563
|
+
})();
|
|
564
|
+
|
|
540
565
|
interface AcceptedPrompt {
|
|
541
566
|
text: string;
|
|
567
|
+
/** Frozen at acceptance. Null means unallocated, not inherit the latest. */
|
|
568
|
+
assignment: AssignmentDelivery | null;
|
|
569
|
+
leadRunId?: string | null;
|
|
542
570
|
}
|
|
543
571
|
|
|
544
572
|
interface RosterSessionChange {
|
|
@@ -927,6 +955,16 @@ export class Orchestrator {
|
|
|
927
955
|
}
|
|
928
956
|
|
|
929
957
|
private emitHost(event: HostEvent): void {
|
|
958
|
+
if (event.kind === "agent.message_complete" || event.kind === "agent.turn_complete") {
|
|
959
|
+
const session = this.channels.get(event.taskId)?.sessions.get(event.agentId);
|
|
960
|
+
const path = join(taskDir(event.taskId), ".uai", "assignment-evidence", `${event.agentId}.json`);
|
|
961
|
+
if (session && existsSync(path)) {
|
|
962
|
+
const evidence = new AssignmentEvidenceJournal(path).observe(
|
|
963
|
+
evidenceSessionId(event.taskId, event.agentId, session), agentOutputSource.getStore() ? agentOutputSource.getStore() + ":" + event.kind : undefined, event.kind === "agent.turn_complete",
|
|
964
|
+
);
|
|
965
|
+
if (evidence) event = { ...event, evidence };
|
|
966
|
+
}
|
|
967
|
+
}
|
|
930
968
|
for (const fn of this.hostSubscribers) {
|
|
931
969
|
try {
|
|
932
970
|
fn(event);
|
|
@@ -1090,6 +1128,7 @@ export class Orchestrator {
|
|
|
1090
1128
|
channel.humans = spec.humans ?? [];
|
|
1091
1129
|
channel.mode = spec.mode === "secretary" ? "secretary" : "open";
|
|
1092
1130
|
channel.secretaryAgentId = spec.secretaryAgentId;
|
|
1131
|
+
channel.leadRunId = spec.leadRunId ?? null;
|
|
1093
1132
|
channel.browserTesting = spec.browserTesting === true;
|
|
1094
1133
|
if (channel.browserTesting) {
|
|
1095
1134
|
const affectedKinds = browserWasEnabled
|
|
@@ -1390,6 +1429,7 @@ export class Orchestrator {
|
|
|
1390
1429
|
HOST_ACCOUNT_SCOPE,
|
|
1391
1430
|
mode: spec.mode === "secretary" ? "secretary" : "open",
|
|
1392
1431
|
secretaryAgentId: spec.secretaryAgentId,
|
|
1432
|
+
leadRunId: spec.leadRunId ?? null,
|
|
1393
1433
|
sessions: new Map(),
|
|
1394
1434
|
containerName: `task-${taskId.toLowerCase()}-app-1`,
|
|
1395
1435
|
agentEnvironment: null,
|
|
@@ -2667,6 +2707,7 @@ export class Orchestrator {
|
|
|
2667
2707
|
task.ownerUserId,
|
|
2668
2708
|
apiUrl,
|
|
2669
2709
|
cliSecret,
|
|
2710
|
+
this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
|
|
2670
2711
|
),
|
|
2671
2712
|
task,
|
|
2672
2713
|
),
|
|
@@ -4450,6 +4491,7 @@ export class Orchestrator {
|
|
|
4450
4491
|
task.ownerUserId,
|
|
4451
4492
|
apiUrl,
|
|
4452
4493
|
cliSecret,
|
|
4494
|
+
this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
|
|
4453
4495
|
),
|
|
4454
4496
|
task,
|
|
4455
4497
|
),
|
|
@@ -4686,28 +4728,48 @@ export class Orchestrator {
|
|
|
4686
4728
|
channel: Channel,
|
|
4687
4729
|
agentId: string,
|
|
4688
4730
|
session: AgentSession,
|
|
4689
|
-
prompt: string,
|
|
4731
|
+
prompt: string | AcceptedPrompt,
|
|
4732
|
+
assignment?: AssignmentDelivery,
|
|
4690
4733
|
): void {
|
|
4734
|
+
const replay = typeof prompt !== "string";
|
|
4735
|
+
const text = typeof prompt === "string" ? prompt : prompt.text;
|
|
4736
|
+
const leadRunId = replay ? (prompt as AcceptedPrompt).leadRunId ?? null : this.isSecretary(channel, agentId) ? channel.leadRunId ?? null : null;
|
|
4737
|
+
const originalAssignment = typeof prompt === "string" ? (this.isSecretary(channel, agentId) ? null : assignment) : prompt.assignment;
|
|
4738
|
+
let retainedAssignment = originalAssignment ?? null;
|
|
4739
|
+
const journalPath = join(taskDir(channel.taskId), ".uai", "assignment-evidence", `${agentId}.json`);
|
|
4740
|
+
let turn: AssignmentTurnEvidence | undefined;
|
|
4741
|
+
const journal = assignment || leadRunId || existsSync(journalPath) ? new AssignmentEvidenceJournal(journalPath) : null;
|
|
4742
|
+
const sessionId = evidenceSessionId(channel.taskId, agentId, session);
|
|
4743
|
+
if (journal) {
|
|
4744
|
+
const begun = journal.begin({ sessionId, engine: session.kind,
|
|
4745
|
+
runEpoch: readTaskRunEpochState(channel.taskId)?.highestRunEpoch ?? 0,
|
|
4746
|
+
recipientGeneration: channel.roster.find(a => a.id === agentId)?.tokenGeneration ?? null,
|
|
4747
|
+
runtimeVersion: evidenceRuntimeVersion, assignment: originalAssignment, replay, leadRunId });
|
|
4748
|
+
if (!begun) return; // stable assignment replay, already accepted
|
|
4749
|
+
turn = begun;
|
|
4750
|
+
retainedAssignment = begun.assignment;
|
|
4751
|
+
}
|
|
4691
4752
|
// ADR-076: this is the prompt actually in flight. A queued follow-up must
|
|
4692
4753
|
// not replace it or account rotation would replay the wrong turn.
|
|
4693
|
-
|
|
4694
|
-
|
|
4754
|
+
const accepted: AcceptedPrompt = { text, assignment: retainedAssignment, leadRunId };
|
|
4755
|
+
channel.lastPrompt.set(agentId, accepted);
|
|
4695
4756
|
const acceptedForAgent = channel.acceptedPrompts.get(agentId) ?? [];
|
|
4696
4757
|
acceptedForAgent.push(accepted);
|
|
4697
4758
|
channel.acceptedPrompts.set(agentId, acceptedForAgent);
|
|
4698
4759
|
this.incrementActiveTurns(channel, agentId);
|
|
4699
|
-
void session.send(
|
|
4760
|
+
void session.send(text).catch((err: unknown) => {
|
|
4761
|
+
if (turn) journal?.failed(sessionId, turn.turnId);
|
|
4700
4762
|
if (channel.sessions.get(agentId) === session) {
|
|
4701
4763
|
this.removeAcceptedPrompt(channel, agentId, accepted);
|
|
4702
4764
|
const remaining = this.decrementActiveTurns(channel, agentId);
|
|
4703
4765
|
if (
|
|
4704
4766
|
remaining === 0 &&
|
|
4705
|
-
channel.lastPrompt.get(agentId) ===
|
|
4767
|
+
channel.lastPrompt.get(agentId) === accepted
|
|
4706
4768
|
) {
|
|
4707
4769
|
channel.lastPrompt.delete(agentId);
|
|
4708
4770
|
}
|
|
4709
4771
|
if (err instanceof ContainerRuntimeUnavailableError) {
|
|
4710
|
-
this.deferPromptUntilRuntimeReady(channel, agentId,
|
|
4772
|
+
this.deferPromptUntilRuntimeReady(channel, agentId, accepted);
|
|
4711
4773
|
} else {
|
|
4712
4774
|
this.startNextPendingPrompt(channel, agentId);
|
|
4713
4775
|
}
|
|
@@ -4745,7 +4807,7 @@ export class Orchestrator {
|
|
|
4745
4807
|
private deferPromptUntilRuntimeReady(
|
|
4746
4808
|
channel: Channel,
|
|
4747
4809
|
agentId: string,
|
|
4748
|
-
prompt:
|
|
4810
|
+
prompt: AcceptedPrompt,
|
|
4749
4811
|
): void {
|
|
4750
4812
|
channel.runtimeDeferredAgents.add(agentId);
|
|
4751
4813
|
if (this.isSecretary(channel, agentId)) {
|
|
@@ -4810,6 +4872,7 @@ export class Orchestrator {
|
|
|
4810
4872
|
/** ADR-141 rung 1: only a human turn is shadow-routed. An older cloud
|
|
4811
4873
|
* sends nothing, which reads as "not human" and keeps the pass off. */
|
|
4812
4874
|
origin?: DeliveryOrigin,
|
|
4875
|
+
assignment?: AssignmentDelivery,
|
|
4813
4876
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
4814
4877
|
// Slash command: re-run GitHub auth setup for this task (ADR-027). Handled
|
|
4815
4878
|
// before sessions so it works even when an agent isn't ready.
|
|
@@ -4829,11 +4892,15 @@ export class Orchestrator {
|
|
|
4829
4892
|
const session = channel.sessions.get(agentId);
|
|
4830
4893
|
if (!session) return { ok: false, error: `no such agent: ${agentId}` };
|
|
4831
4894
|
|
|
4895
|
+
if (assignment && (assignment.runEpoch !== (readTaskRunEpochState(taskId)?.highestRunEpoch ?? 0) ||
|
|
4896
|
+
assignment.recipientGeneration !== (channel.roster.find(a => a.id === agentId)?.tokenGeneration ?? null))) {
|
|
4897
|
+
return { ok: false, error: "assignment belongs to another run or agent generation" };
|
|
4898
|
+
}
|
|
4832
4899
|
const prompt = rewriteAttachmentRefs(text);
|
|
4833
4900
|
if (this.isSecretary(channel, agentId)) {
|
|
4834
4901
|
const queue = channel.pendingPrompts.get(agentId) ?? [];
|
|
4835
4902
|
if ((channel.activeTurns.get(agentId) ?? 0) > 0 || queue.length > 0) {
|
|
4836
|
-
queue.push(prompt);
|
|
4903
|
+
queue.push({ text: prompt, assignment: assignment ?? null, leadRunId: channel.leadRunId ?? null });
|
|
4837
4904
|
channel.pendingPrompts.set(agentId, queue);
|
|
4838
4905
|
// A replacement session may have bound after the queue was observed
|
|
4839
4906
|
// but before this delivery. Preserve FIFO by starting the oldest item.
|
|
@@ -4845,7 +4912,7 @@ export class Orchestrator {
|
|
|
4845
4912
|
// Preserve the legacy open-room contract: adapters receive concurrent
|
|
4846
4913
|
// deliveries immediately. Only the designated Secretary needs the hard
|
|
4847
4914
|
// turn boundary because crew replies can arrive during its dispatch turn.
|
|
4848
|
-
this.startPrompt(channel, agentId, session, prompt);
|
|
4915
|
+
this.startPrompt(channel, agentId, session, prompt, assignment);
|
|
4849
4916
|
}
|
|
4850
4917
|
// ADR-141 rung 1: after the turn is on its way, ask what Auto would have
|
|
4851
4918
|
// done and report it. Fire-and-forget by design — it must never delay or
|
|
@@ -5561,7 +5628,7 @@ export class Orchestrator {
|
|
|
5561
5628
|
channel.lastPrompt.delete(agentId);
|
|
5562
5629
|
if (accepted.length > 0 && !interrupted) {
|
|
5563
5630
|
for (const prompt of accepted) {
|
|
5564
|
-
this.deferPromptUntilRuntimeReady(channel, agentId, prompt
|
|
5631
|
+
this.deferPromptUntilRuntimeReady(channel, agentId, prompt);
|
|
5565
5632
|
}
|
|
5566
5633
|
} else {
|
|
5567
5634
|
// Recreate even an idle session after readiness, without fabricating a
|
|
@@ -5695,6 +5762,7 @@ export class Orchestrator {
|
|
|
5695
5762
|
task.ownerUserId,
|
|
5696
5763
|
apiUrl,
|
|
5697
5764
|
cliSecret,
|
|
5765
|
+
this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
|
|
5698
5766
|
);
|
|
5699
5767
|
const boundAccountId = channel.accountByAgent.get(agentId);
|
|
5700
5768
|
const boundAccount = boundAccountId
|
|
@@ -5909,6 +5977,7 @@ export class Orchestrator {
|
|
|
5909
5977
|
task.ownerUserId,
|
|
5910
5978
|
apiUrl,
|
|
5911
5979
|
cliSecret,
|
|
5980
|
+
this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
|
|
5912
5981
|
);
|
|
5913
5982
|
channel.accountByAgent.set(agentId, next.id);
|
|
5914
5983
|
noteEngineAccountUsed(next.id);
|
|
@@ -6118,6 +6187,7 @@ export class Orchestrator {
|
|
|
6118
6187
|
task.ownerUserId,
|
|
6119
6188
|
apiUrl,
|
|
6120
6189
|
cliSecret,
|
|
6190
|
+
this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
|
|
6121
6191
|
);
|
|
6122
6192
|
channel.accountByAgent.set(agentId, account.id);
|
|
6123
6193
|
noteEngineAccountUsed(account.id);
|
|
@@ -7484,7 +7554,13 @@ export function buildSystemPreamble(
|
|
|
7484
7554
|
`\`${(agent.permissions ?? []).join(", ")}\`. Commands: \`projects list\`,`,
|
|
7485
7555
|
"`people list`, `task list`" +
|
|
7486
7556
|
(agent.permissions?.includes("tasks.create")
|
|
7487
|
-
? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
|
|
7557
|
+
? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle] [--astronaut engine/model/effort] [--start --request-id <logical-run-id>]`"
|
|
7558
|
+
: "") +
|
|
7559
|
+
(agent.permissions?.includes("tasks.read")
|
|
7560
|
+
? ", `task watch <childId> [--after <cursor>]`, `task usage <childId>`"
|
|
7561
|
+
: "") +
|
|
7562
|
+
(agent.permissions?.includes("tasks.chat")
|
|
7563
|
+
? ", `task send <childId> <text>`, `task answer <childId> <cardId> <option>`"
|
|
7488
7564
|
: "") +
|
|
7489
7565
|
(agent.permissions?.includes("tasks.close")
|
|
7490
7566
|
? ", `task close --status finished|canceled [--reason \"final report\"]`"
|
|
@@ -7520,8 +7596,13 @@ export function buildSystemPreamble(
|
|
|
7520
7596
|
`shell command \`node ${CONTAINER_CLI_PATH} task create …\`. Do NOT`,
|
|
7521
7597
|
"use your built-in TaskCreate / task tools for this — those are just",
|
|
7522
7598
|
"your own private to-do notes and are invisible to the human in Uai.",
|
|
7523
|
-
"
|
|
7524
|
-
"
|
|
7599
|
+
"Tasks you create are your own direct children, DRAFTS by default.",
|
|
7600
|
+
"Only an explicit tasks.start grant lets --start launch them,",
|
|
7601
|
+
"under the owner's limits and this task's account lane.",
|
|
7602
|
+
"Only children you started are delegated to you; a live chat-capable Secretary receives coalesced events. Otherwise the child addresses the person.",
|
|
7603
|
+
"Closing the parent leaves running children alive. Child permissions never exceed your live creation ceiling.",
|
|
7604
|
+
"Send/answer are visibly delegated agent inputs, never human approval.",
|
|
7605
|
+
"Discover options first with",
|
|
7525
7606
|
"`projects list` / `people list`.",
|
|
7526
7607
|
]
|
|
7527
7608
|
: []),
|
|
@@ -181,6 +181,7 @@ export function taskProjectEnvResolver(
|
|
|
181
181
|
readonly placeholders: Readonly<Record<string, string>>;
|
|
182
182
|
} = () => ({ withheld: [], placeholders: {} }),
|
|
183
183
|
taskSource: () => TaskEnvSource = () => STORED,
|
|
184
|
+
taskSecrets: () => { revision: string; withheld: readonly string[]; values: Readonly<Record<string, string>> } = () => ({ revision: "", withheld: [], values: {} }),
|
|
184
185
|
): TaskGatewayEnvResolver {
|
|
185
186
|
const projectIds = [...new Set(spec.projects.map((project) => project.projectId))];
|
|
186
187
|
let revisions: number[] | undefined;
|
|
@@ -192,7 +193,9 @@ export function taskProjectEnvResolver(
|
|
|
192
193
|
const currentUai = uaiEnv();
|
|
193
194
|
const currentProxied = proxiedEnv();
|
|
194
195
|
const currentSource = taskSource();
|
|
196
|
+
const secrets = taskSecrets();
|
|
195
197
|
const currentRunnerKey = JSON.stringify([
|
|
198
|
+
secrets.revision,
|
|
196
199
|
Object.entries(currentUai).sort(([a], [b]) => compareAscii(a, b)),
|
|
197
200
|
[...currentProxied.withheld].sort(compareAscii),
|
|
198
201
|
Object.entries(currentProxied.placeholders).sort(([a], [b]) => compareAscii(a, b)),
|
|
@@ -214,6 +217,7 @@ export function taskProjectEnvResolver(
|
|
|
214
217
|
Object.assign(values, resolved.values);
|
|
215
218
|
const withheld = new Set([
|
|
216
219
|
...currentProxied.withheld,
|
|
220
|
+
...secrets.withheld,
|
|
217
221
|
...(currentSource.kind === "view" ? currentSource.view.notDelivered : []),
|
|
218
222
|
]);
|
|
219
223
|
for (const name of withheld) delete values[name];
|
|
@@ -225,6 +229,7 @@ export function taskProjectEnvResolver(
|
|
|
225
229
|
values[name] = placeholder;
|
|
226
230
|
}
|
|
227
231
|
}
|
|
232
|
+
Object.assign(values, secrets.values);
|
|
228
233
|
for (const [name, value] of Object.entries(currentUai)) {
|
|
229
234
|
// Only reserved names: Uai's own variables, never a project's.
|
|
230
235
|
if (ENV_NAME.test(name) && isReservedTaskEnvName(name) && !value.includes("\0")) values[name] = value;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { taskSecretsView } from "../credential-consumers/task-secrets";
|
|
1
2
|
/**
|
|
2
3
|
* Which keys each task has in proxy mode (ADR-153's effective policy: the
|
|
3
4
|
* owner's binding ∩ the task's schema observation), and what of that the
|
|
@@ -36,21 +37,29 @@ export function createTaskProxyPolicySource(
|
|
|
36
37
|
return {
|
|
37
38
|
proxiedKeys: (taskId) => {
|
|
38
39
|
if (!isServed(taskId)) return null;
|
|
39
|
-
|
|
40
|
+
const secrets = taskSecretsView(taskId);
|
|
41
|
+
const taskKeys = secrets.filter(s => s.manifest.mode === "proxied").map(s => ({ projectId: `task-secret:${taskId}`, key: s.manifest.name, domains: s.manifest.domains, placeholder: s.manifest.placeholder, head: { revision: s.manifest.generation, generation: s.manifest.generation, tombstone: false as const, valueRef: { kind: "staged" as const, stagingId: s.manifest.stagingId } } }));
|
|
42
|
+
return [...taskKeys, ...(viewOf(taskId)?.proxied ?? []).filter(e => !secrets.some(s => s.manifest.name === e.key)).map(({ projectId, key, domains, placeholder, head }) => ({
|
|
40
43
|
projectId,
|
|
41
44
|
key,
|
|
42
45
|
domains,
|
|
43
46
|
placeholder,
|
|
44
47
|
head,
|
|
45
|
-
}));
|
|
48
|
+
}))];
|
|
46
49
|
},
|
|
47
50
|
realValue: (taskId, ref) => {
|
|
51
|
+
if (!isServed(taskId)) return null;
|
|
52
|
+
if (ref.projectId === `task-secret:${taskId}`) {
|
|
53
|
+
const secret = taskSecretsView(taskId).find(s => s.manifest.name === ref.key && s.manifest.mode === "proxied");
|
|
54
|
+
return secret && ref.head?.generation === secret.manifest.generation && ref.head.valueRef?.kind === "staged" && ref.head.valueRef.stagingId === secret.manifest.stagingId ? secret.material?.value ?? null : null;
|
|
55
|
+
}
|
|
56
|
+
if (taskSecretsView(taskId).some(s => s.manifest.name === ref.key)) return null;
|
|
48
57
|
const { head } = ref;
|
|
49
58
|
if (head === undefined) return null;
|
|
50
59
|
const proxied = viewOf(taskId)?.proxied.find((entry) => entry.projectId === ref.projectId && entry.key === ref.key);
|
|
51
60
|
return proxied !== undefined && sameTaskEnvHead(proxied.head, head) ? proxied.value : null;
|
|
52
61
|
},
|
|
53
|
-
notDelivered: (taskId) => viewOf(taskId)?.notDelivered ??
|
|
62
|
+
notDelivered: (taskId) => [...(viewOf(taskId)?.notDelivered ?? []), ...taskSecretsView(taskId).map(s => s.manifest.name)],
|
|
54
63
|
};
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { CONTEXT_HASH_LABEL } from "./standard-image-pin";
|
|
5
|
+
|
|
6
|
+
type Result = { code: number | null; stdout: string; stderr: string };
|
|
7
|
+
export type DockerCommand = (command: string, args: string[], timeoutMs?: number) => Promise<Result>;
|
|
8
|
+
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
|
|
9
|
+
const SWEEP_LIMIT = 16;
|
|
10
|
+
const HISTORY_LIMIT = 128;
|
|
11
|
+
const HISTORY_BYTES = 16 * 1024;
|
|
12
|
+
|
|
13
|
+
/** Context labels are inherited by task images. Authority to delete comes
|
|
14
|
+
* from observing this exact id under the standard tag, never from a scan. */
|
|
15
|
+
export class StandardImageCleanup {
|
|
16
|
+
private readonly observed = new Set<string>();
|
|
17
|
+
private loading: Promise<void> | undefined;
|
|
18
|
+
private statePath: string | undefined;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly stateFile?: () => Promise<string>) {}
|
|
21
|
+
|
|
22
|
+
private async load(): Promise<void> {
|
|
23
|
+
if (!this.stateFile) return;
|
|
24
|
+
try {
|
|
25
|
+
this.statePath = await this.stateFile();
|
|
26
|
+
const fd = openSync(this.statePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd);
|
|
29
|
+
if (!stat.isFile() || stat.size > HISTORY_BYTES) return;
|
|
30
|
+
const bytes = Buffer.alloc(HISTORY_BYTES + 1);
|
|
31
|
+
const length = readSync(fd, bytes, 0, bytes.length, 0);
|
|
32
|
+
if (length > HISTORY_BYTES) return;
|
|
33
|
+
const value = JSON.parse(bytes.subarray(0, length).toString("utf8")) as { version?: unknown; ids?: unknown };
|
|
34
|
+
if (value?.version !== 1 || !Array.isArray(value.ids) || value.ids.length > HISTORY_LIMIT || !value.ids.every(id => typeof id === "string" && IMAGE_ID.test(id))) return;
|
|
35
|
+
for (const id of value.ids) this.observed.add(id as string);
|
|
36
|
+
} finally { closeSync(fd); }
|
|
37
|
+
} catch { /* Missing, corrupt or unreadable history authorizes nothing. */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private save(): void {
|
|
41
|
+
if (!this.statePath) return;
|
|
42
|
+
const temporary = `${this.statePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, ids: [...this.observed] }), { flag: "wx", mode: 0o600 });
|
|
46
|
+
renameSync(temporary, this.statePath);
|
|
47
|
+
} catch {
|
|
48
|
+
// ENOSPC must not block a build or safe cleanup using in-memory proof.
|
|
49
|
+
console.warn("[host-agent] could not persist standard-image history");
|
|
50
|
+
} finally {
|
|
51
|
+
try { unlinkSync(temporary); } catch { /* Renamed or never created. */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async observe(run: DockerCommand, ref = "uai-standard:dev"): Promise<string | null> {
|
|
56
|
+
await (this.loading ??= this.load());
|
|
57
|
+
const result = await run("docker", ["image", "inspect", "--format", "{{json .}}", ref]);
|
|
58
|
+
if (result.code !== 0) return null;
|
|
59
|
+
try {
|
|
60
|
+
const image = JSON.parse(result.stdout) as { Id?: string; Config?: { Labels?: Record<string, string> } };
|
|
61
|
+
if (!IMAGE_ID.test(image.Id ?? "") || !/^[a-f0-9]{32}$/.test(image.Config?.Labels?.[CONTEXT_HASH_LABEL] ?? "")) return null;
|
|
62
|
+
if (ref === "uai-standard:dev") {
|
|
63
|
+
this.observed.delete(image.Id!);
|
|
64
|
+
this.observed.add(image.Id!);
|
|
65
|
+
if (this.observed.size > HISTORY_LIMIT) this.observed.delete(this.observed.values().next().value!);
|
|
66
|
+
this.save();
|
|
67
|
+
}
|
|
68
|
+
return image.Id!;
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async sweep(run: DockerCommand, previousId: string | null): Promise<void> {
|
|
73
|
+
const deadline = Date.now() + 30_000;
|
|
74
|
+
const boundedRun: DockerCommand = (command, args) => run(command, args, 5_000);
|
|
75
|
+
const currentId = await this.observe(boundedRun);
|
|
76
|
+
if (!currentId) return;
|
|
77
|
+
const history = await boundedRun("docker", ["image", "history", "--quiet", "--no-trunc", currentId]);
|
|
78
|
+
if (history.code !== 0) return;
|
|
79
|
+
const currentChain = new Set([currentId, ...history.stdout.trim().split(/\s+/)]);
|
|
80
|
+
const candidates = new Set([...(previousId ? [previousId] : []), ...this.observed]);
|
|
81
|
+
for (const id of [...candidates].filter(id => this.observed.has(id) && !currentChain.has(id)).slice(0, SWEEP_LIMIT)) {
|
|
82
|
+
if (Date.now() >= deadline) break;
|
|
83
|
+
// Round-robin retained/in-use ids so a busy prefix cannot starve later ids.
|
|
84
|
+
this.observed.delete(id);
|
|
85
|
+
this.observed.add(id);
|
|
86
|
+
const tags = await boundedRun("docker", ["image", "inspect", "--format", "{{json .RepoTags}}", id]);
|
|
87
|
+
if (tags.code !== 0) continue;
|
|
88
|
+
try { const parsed: unknown = JSON.parse(tags.stdout); if (parsed !== null && (!Array.isArray(parsed) || parsed.length !== 0)) continue; } catch { continue; }
|
|
89
|
+
const containers = await boundedRun("docker", ["ps", "--all", "--quiet", "--filter", `ancestor=${id}`]);
|
|
90
|
+
if (containers.code !== 0 || containers.stdout.trim()) continue;
|
|
91
|
+
// Normal Docker removal reclaims otherwise-unused untagged parents.
|
|
92
|
+
// Shared ancestors of the current image (or any other child/container)
|
|
93
|
+
// remain protected by the daemon. Never force or target a parent id.
|
|
94
|
+
const removed = await boundedRun("docker", ["image", "rm", id]);
|
|
95
|
+
if (removed.code === 0) {
|
|
96
|
+
this.observed.delete(id);
|
|
97
|
+
console.log("[host-agent] removed unused previous standard image", id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
this.save();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Per-process exponential retry, keyed by the desired content/configuration.
|
|
105
|
+
* A new host payload/engine configuration retries immediately. A restart also
|
|
106
|
+
* retries once; repeated failures back off 2h, 4h, …, 24h instead of hourly. */
|
|
107
|
+
export class StandardImageBuildBackoff {
|
|
108
|
+
private state: { key: string; failures: number; retryAt: number; error: string } | null = null;
|
|
109
|
+
remaining(key: string, now = Date.now()): { delay: number; retryAt: number; error: string } | null {
|
|
110
|
+
return this.state?.key === key && this.state.retryAt > now ? { delay: this.state.retryAt - now, retryAt: this.state.retryAt, error: this.state.error } : null;
|
|
111
|
+
}
|
|
112
|
+
failed(key: string, error: string, now = Date.now()): void {
|
|
113
|
+
const failures = this.state?.key === key ? this.state.failures + 1 : 1;
|
|
114
|
+
this.state = { key, failures, retryAt: now + Math.min(24, 2 ** Math.min(failures, 5)) * 3_600_000, error };
|
|
115
|
+
}
|
|
116
|
+
clear(): void { this.state = null; }
|
|
117
|
+
}
|
package/lib/standard-image.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Standard-image build pipeline (ADR-022 / docs/runtime.md).
|
|
3
3
|
*
|
|
4
4
|
* Every host builds exactly one standard image (`uai-standard:dev`) and
|
|
5
|
-
* caches it. `ensureStandardImage()`
|
|
5
|
+
* caches it. `ensureStandardImage()` runs at startup and on the owned-host
|
|
6
|
+
* hourly maintenance schedule (local build failures back off):
|
|
6
7
|
*
|
|
7
8
|
* 1. `docker volume create uai-asdf-data` — the host-wide asdf data volume
|
|
8
9
|
* that caches lazily-installed runtime versions across tasks (idempotent;
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
standardImagePullDigest,
|
|
53
54
|
} from "./standard-image-pin";
|
|
54
55
|
import { sidecarMode } from "./sidecar";
|
|
56
|
+
import { StandardImageCleanup, StandardImageBuildBackoff } from "./standard-image-cleanup";
|
|
55
57
|
|
|
56
58
|
/** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
|
|
57
59
|
export const STANDARD_IMAGE_TAG = "uai-standard:dev";
|
|
@@ -1296,8 +1298,15 @@ export type ActivationIsCurrent = () => boolean;
|
|
|
1296
1298
|
/** Tests: forget every remembered pass. */
|
|
1297
1299
|
export function resetStandardImageMemo(): void {
|
|
1298
1300
|
lastOkByKey.clear();
|
|
1301
|
+
buildBackoff.clear();
|
|
1299
1302
|
}
|
|
1300
1303
|
|
|
1304
|
+
const buildBackoff = new StandardImageBuildBackoff();
|
|
1305
|
+
const imageCleanup = new StandardImageCleanup(async () => {
|
|
1306
|
+
const { env } = await import("./env");
|
|
1307
|
+
return resolve(env.dataDir, "standard-image-seen.json");
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1301
1310
|
/** The owned-host schedule (docs/agent-notes.md, "image maintenance"). */
|
|
1302
1311
|
export const MAINTENANCE_INTERVAL_MS = 60 * 60_000;
|
|
1303
1312
|
|
|
@@ -1418,6 +1427,7 @@ async function ensureStandardImageInner(
|
|
|
1418
1427
|
// hash, not this host's per-config hash, which would disagree by design
|
|
1419
1428
|
// and rebuild forever). Any pull-path failure falls through to the local
|
|
1420
1429
|
// build below, so offline and registry-down hosts behave exactly as today.
|
|
1430
|
+
const previousImageId = engine.apple ? null : await imageCleanup.observe(run);
|
|
1421
1431
|
const pin = await readStandardImagePin();
|
|
1422
1432
|
if (pin !== null) {
|
|
1423
1433
|
if (inspect.code === 0 && labeledHash === pin.contextHash) {
|
|
@@ -1473,13 +1483,18 @@ async function ensureStandardImageInner(
|
|
|
1473
1483
|
imageReady = true;
|
|
1474
1484
|
} else {
|
|
1475
1485
|
const context = standardImageDir();
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1486
|
+
const buildKey = `${contextHash ?? "unavailable"}:${JSON.stringify(engines)}:${pin?.contextHash ?? "local"}`;
|
|
1487
|
+
const deferred = engine.apple || labeledHash === null ? null : buildBackoff.remaining(buildKey);
|
|
1488
|
+
if (!deferred) {
|
|
1489
|
+
console.log(
|
|
1490
|
+
inspect.code === 0
|
|
1491
|
+
? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
|
|
1492
|
+
: `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
const build = deferred ? { code: 1, stdout: "", stderr: `${deferred.error}\nStandard image build deferred until ${new Date(deferred.retryAt).toISOString()}.` } : await run(engine.command, [
|
|
1482
1496
|
"build",
|
|
1497
|
+
...(engine.apple ? [] : ["--force-rm"]),
|
|
1483
1498
|
"-t",
|
|
1484
1499
|
STANDARD_IMAGE_TAG,
|
|
1485
1500
|
...engineArgs,
|
|
@@ -1491,11 +1506,14 @@ async function ensureStandardImageInner(
|
|
|
1491
1506
|
if (build.code === 0) {
|
|
1492
1507
|
console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
|
|
1493
1508
|
imageReady = true;
|
|
1509
|
+
buildBackoff.clear();
|
|
1494
1510
|
} else {
|
|
1495
1511
|
buildError =
|
|
1496
1512
|
build.stderr.trim() ||
|
|
1497
1513
|
`docker build exited ${build.code ?? "spawn error"}`;
|
|
1514
|
+
if (!deferred && !engine.apple) buildBackoff.failed(buildKey, buildError);
|
|
1498
1515
|
console.warn(
|
|
1516
|
+
deferred ? `[host-agent] ${buildError}` :
|
|
1499
1517
|
`[host-agent] standard image build failed (exit ${build.code ?? "spawn"}); ` +
|
|
1500
1518
|
"continuing. Tasks needing the image will surface this error.\n" +
|
|
1501
1519
|
buildError,
|
|
@@ -1505,6 +1523,10 @@ async function ensureStandardImageInner(
|
|
|
1505
1523
|
}
|
|
1506
1524
|
}
|
|
1507
1525
|
|
|
1526
|
+
// Revisit ids durably observed under the standard tag across restarts.
|
|
1527
|
+
// Inherited labels never authorize sweeping task/compose images.
|
|
1528
|
+
if (imageReady && !engine.apple) await imageCleanup.sweep(run, previousImageId);
|
|
1529
|
+
|
|
1508
1530
|
// 3. Reconcile every Uai-owned writer under the volume-global flock. Lock
|
|
1509
1531
|
// bootstrap/permission repair precedes the single upgrade→repair→proof
|
|
1510
1532
|
// transaction so node-run task materializers can acquire legacy volumes.
|
|
@@ -1648,7 +1670,7 @@ async function ensureStandardImageInner(
|
|
|
1648
1670
|
}
|
|
1649
1671
|
return {
|
|
1650
1672
|
ok: false,
|
|
1651
|
-
error: `Could not prepare shared runtime maintenance lock ${ASDF_MAINTENANCE_LOCK_PATH}.`,
|
|
1673
|
+
error: `${buildError ? `${buildError} ` : ""}Could not prepare shared runtime maintenance lock ${ASDF_MAINTENANCE_LOCK_PATH}.`,
|
|
1652
1674
|
};
|
|
1653
1675
|
}
|
|
1654
1676
|
if (!(await reconcileVolumeAgentClis(activation))) {
|
|
@@ -1661,7 +1683,7 @@ async function ensureStandardImageInner(
|
|
|
1661
1683
|
return {
|
|
1662
1684
|
ok: false,
|
|
1663
1685
|
error:
|
|
1664
|
-
"Shared runtime maintenance failed; agent CLIs or Corepack did not pass execution proof
|
|
1686
|
+
`${buildError ? `${buildError} ` : ""}Shared runtime maintenance failed; agent CLIs or Corepack did not pass execution proof.`,
|
|
1665
1687
|
};
|
|
1666
1688
|
}
|
|
1667
1689
|
return { ok: true };
|
package/lib/task-env-source.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deliveredTaskSecrets } from "./credential-consumers/task-secrets";
|
|
1
2
|
/**
|
|
2
3
|
* ADR-153 §4 and §6: where a task's project env comes from, for its /env and
|
|
3
4
|
* its proxy alike, on an owned runner and on a task machine's sidecar.
|
|
@@ -233,6 +234,7 @@ export function taskEnvResolver(taskId: string, spec: TaskProjectEnvSpec, runEpo
|
|
|
233
234
|
() => taskTrustEnv(taskId),
|
|
234
235
|
() => taskProxiedEnv(taskId),
|
|
235
236
|
() => taskEnvSource(taskId, spec, runEpoch),
|
|
237
|
+
() => deliveredTaskSecrets(taskId, runEpoch),
|
|
236
238
|
);
|
|
237
239
|
}
|
|
238
240
|
|