@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/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
|
|
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
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-161 §2: the live-project commands wired to this runner's real state —
|
|
3
|
+
* the task's persisted environment, its run-epoch admission, its lifecycle
|
|
4
|
+
* slot, its lease grant and env replica, and its persisted env spec.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { activeCloudCredentials, credentialsMode } from "./cloud-credential-host";
|
|
8
|
+
import { getOrchestrator } from "./orchestrator";
|
|
9
|
+
import {
|
|
10
|
+
getHostTask,
|
|
11
|
+
hostTaskProjectEnvSpec,
|
|
12
|
+
readTaskRunEpochState,
|
|
13
|
+
taskProjectEnvSpec,
|
|
14
|
+
upsertHostTask,
|
|
15
|
+
type TaskProjectEnvSpec,
|
|
16
|
+
} from "./runtime-state";
|
|
17
|
+
import { taskUaiDir } from "./env";
|
|
18
|
+
import { captureTaskEnvSchemaFromClone, taskEnvSchemaObservation } from "./project-env-schema-capture";
|
|
19
|
+
import { reconstructPersistedTaskEnvironment } from "./task-environment";
|
|
20
|
+
import { forgetTaskEnvSnapshot, taskEnvResolver } from "./task-env-source";
|
|
21
|
+
import { currentTaskGatewayRegistration, setTaskGatewayEnvResolver } from "./task-gateway-state";
|
|
22
|
+
import { TaskProjectJournalStore, TaskProjectOps } from "./task-project-ops";
|
|
23
|
+
import type { TaskCommandProject } from "../src/protocol";
|
|
24
|
+
|
|
25
|
+
function runIsCurrent(taskId: string, runEpoch: number): boolean {
|
|
26
|
+
const task = getHostTask(taskId);
|
|
27
|
+
if (!task || task.statusMirror !== "running") return false;
|
|
28
|
+
const state = readTaskRunEpochState(taskId);
|
|
29
|
+
// A task started before run epochs carries none; the cloud never sends
|
|
30
|
+
// these commands to it (it has no `live-task-projects-v1` contract).
|
|
31
|
+
return state !== null && state.assignmentOpen && state.highestRunEpoch === runEpoch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Names the cloud's literal channel must never set live: the editor theme
|
|
35
|
+
* is the task's, chosen at start. */
|
|
36
|
+
const RESERVED_LITERAL_ENV = new Set(["UAI_EDITOR_THEME"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One project (and its preview URL literals) added to a persisted env spec.
|
|
40
|
+
* Every other entry stays; a literal name the task already has keeps its
|
|
41
|
+
* value, as at start where the first project to declare one wins.
|
|
42
|
+
*/
|
|
43
|
+
export function specWithProject(
|
|
44
|
+
spec: TaskProjectEnvSpec,
|
|
45
|
+
project: TaskCommandProject,
|
|
46
|
+
previewEnv: Record<string, string> = {},
|
|
47
|
+
): TaskProjectEnvSpec {
|
|
48
|
+
const hasProject = spec.projects.some((entry) => entry.projectId === project.id);
|
|
49
|
+
const addedLiterals = Object.entries(previewEnv).filter(
|
|
50
|
+
([name]) => !RESERVED_LITERAL_ENV.has(name) && !(name in spec.previewEnv),
|
|
51
|
+
);
|
|
52
|
+
if (hasProject && addedLiterals.length === 0) return spec;
|
|
53
|
+
const [entry] = taskProjectEnvSpec({
|
|
54
|
+
projects: [project],
|
|
55
|
+
task: { previewEnv: {} } as Parameters<typeof taskProjectEnvSpec>[0]["task"],
|
|
56
|
+
}).projects;
|
|
57
|
+
return {
|
|
58
|
+
...spec,
|
|
59
|
+
projects: hasProject
|
|
60
|
+
? spec.projects
|
|
61
|
+
: [...spec.projects, entry!].sort(
|
|
62
|
+
(left, right) => left.position - right.position || left.projectId.localeCompare(right.projectId),
|
|
63
|
+
),
|
|
64
|
+
previewEnv: Object.fromEntries(
|
|
65
|
+
[...Object.entries(spec.previewEnv), ...addedLiterals].sort(([left], [right]) => left.localeCompare(right)),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function liveProjectsSupported(taskId: string): boolean {
|
|
71
|
+
const task = getHostTask(taskId);
|
|
72
|
+
if (!task) return false;
|
|
73
|
+
try {
|
|
74
|
+
return hostTaskProjectEnvSpec(task) !== null && currentTaskGatewayRegistration(taskId) !== null;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Persist the extended spec, then ALWAYS re-point the live resolver at the
|
|
82
|
+
* persisted spec, so a repeat repairs a resolver a crash left behind. Throws
|
|
83
|
+
* when the environment can't take it; Activate then answers `incomplete`.
|
|
84
|
+
*/
|
|
85
|
+
function installProjectEnv(
|
|
86
|
+
taskId: string,
|
|
87
|
+
runEpoch: number,
|
|
88
|
+
project: TaskCommandProject,
|
|
89
|
+
previewEnv: Record<string, string>,
|
|
90
|
+
): void {
|
|
91
|
+
const task = getHostTask(taskId);
|
|
92
|
+
if (!task) throw new Error(`no such task: ${taskId}`);
|
|
93
|
+
const spec = hostTaskProjectEnvSpec(task);
|
|
94
|
+
if (spec === null) throw new Error("this task has no persisted env spec to extend");
|
|
95
|
+
const registration = currentTaskGatewayRegistration(taskId);
|
|
96
|
+
if (!registration) throw new Error("this task has no live env gateway");
|
|
97
|
+
const next = specWithProject(spec, project, previewEnv);
|
|
98
|
+
if (next !== spec) upsertHostTask(taskId, { projectEnvSpec: JSON.stringify(next) });
|
|
99
|
+
forgetTaskEnvSnapshot(taskId);
|
|
100
|
+
if (!setTaskGatewayEnvResolver(taskId, registration.generation, taskEnvResolver(taskId, next, runEpoch))) {
|
|
101
|
+
throw new Error("the task's env gateway changed while the project was installed");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let singleton: TaskProjectOps | null = null;
|
|
106
|
+
|
|
107
|
+
export function taskProjectOps(): TaskProjectOps {
|
|
108
|
+
singleton ??= new TaskProjectOps({
|
|
109
|
+
journal: new TaskProjectJournalStore(taskUaiDir),
|
|
110
|
+
environment: async (taskId) => {
|
|
111
|
+
const task = getHostTask(taskId);
|
|
112
|
+
if (!task || task.statusMirror !== "running") return null;
|
|
113
|
+
return reconstructPersistedTaskEnvironment(task);
|
|
114
|
+
},
|
|
115
|
+
runIsCurrent,
|
|
116
|
+
inLifecycleSlot: (taskId, fn) => getOrchestrator().runTaskLifecycle(taskId, fn),
|
|
117
|
+
credentials: () => (credentialsMode() === "active" ? "cloud" : "own"),
|
|
118
|
+
installedGrant: (taskId, runEpoch) => {
|
|
119
|
+
const composer = activeCloudCredentials();
|
|
120
|
+
if (!composer) return null;
|
|
121
|
+
const view = composer.registry.currentView({ taskId, runEpoch });
|
|
122
|
+
return view
|
|
123
|
+
? { runEpoch, manifestRevision: view.manifestRevision, manifestDigest: view.manifestDigest }
|
|
124
|
+
: null;
|
|
125
|
+
},
|
|
126
|
+
envServes: (taskId, runEpoch, projectId) => {
|
|
127
|
+
const composer = activeCloudCredentials();
|
|
128
|
+
// The runner's own store serves every project it holds values for.
|
|
129
|
+
if (!composer) return true;
|
|
130
|
+
const view = composer.registry.currentView({ taskId, runEpoch });
|
|
131
|
+
if (!view || !view.projectIds.includes(projectId)) return false;
|
|
132
|
+
return composer.envSnapshot({ taskId, runEpoch }, view.projectIds) !== null;
|
|
133
|
+
},
|
|
134
|
+
liveProjectsSupported,
|
|
135
|
+
captureEnvSchema: (taskId, projectId, git) => captureTaskEnvSchemaFromClone(taskId, projectId, git),
|
|
136
|
+
storedEnvSchema: (taskId, projectId) => taskEnvSchemaObservation(taskId, projectId),
|
|
137
|
+
servedProjectIds: (taskId, runEpoch) => {
|
|
138
|
+
const composer = activeCloudCredentials();
|
|
139
|
+
if (!composer) return null;
|
|
140
|
+
const view = composer.registry.currentView({ taskId, runEpoch });
|
|
141
|
+
if (!view) return null;
|
|
142
|
+
return composer.envSnapshot({ taskId, runEpoch }, view.projectIds) !== null ? [...view.projectIds] : null;
|
|
143
|
+
},
|
|
144
|
+
installProjectEnv,
|
|
145
|
+
});
|
|
146
|
+
return singleton;
|
|
147
|
+
}
|