@runuai/host 0.9.153 → 0.9.155
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 +64 -1
- package/lib/agents/claude.ts +19 -2
- package/lib/agents/codex.ts +15 -1
- package/lib/agents/registry.ts +5 -4
- package/lib/agents/types.ts +4 -1
- package/lib/agents/uai.ts +15 -1
- package/lib/cloud-credential-leases.ts +9 -0
- package/lib/cloud-credential-runtime.ts +187 -21
- package/lib/credential-consumers/github.ts +140 -32
- package/lib/orchestrator.ts +90 -25
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-token.ts +3 -0
- package/package.json +1 -1
- package/src/cloud-credentials.ts +25 -0
- package/src/index.ts +3 -6
- package/src/main.ts +16 -10
- package/src/protocol.ts +42 -0
package/lib/agent-cli.ts
CHANGED
|
@@ -210,7 +210,14 @@ export function agentCliEnv(
|
|
|
210
210
|
return {
|
|
211
211
|
...runtimeAuthority,
|
|
212
212
|
UAI_TASK_TOKEN: signTaskToken(
|
|
213
|
-
{
|
|
213
|
+
{
|
|
214
|
+
taskId,
|
|
215
|
+
userId: ownerUserId,
|
|
216
|
+
permissions,
|
|
217
|
+
agentId: agent.id,
|
|
218
|
+
// ADR-157 §3: binds the token to this agent, not merely its handle.
|
|
219
|
+
...(agent.tokenGeneration ? { generation: agent.tokenGeneration } : {}),
|
|
220
|
+
},
|
|
214
221
|
cliSecret,
|
|
215
222
|
),
|
|
216
223
|
UAI_API_URL: apiUrl,
|
|
@@ -312,6 +319,58 @@ async function main() {
|
|
|
312
319
|
}
|
|
313
320
|
return;
|
|
314
321
|
}
|
|
322
|
+
if (group === "crew") {
|
|
323
|
+
// ADR-157: the task's built-in Astronaut (the @astronaut Uai builds, never a
|
|
324
|
+
// persona) changes its own crew. One agent per call, always with a --why.
|
|
325
|
+
const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
|
|
326
|
+
const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
|
|
327
|
+
if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
|
|
328
|
+
if (action === "add") {
|
|
329
|
+
const personaId = pos[0];
|
|
330
|
+
if (!personaId || !flags.why) {
|
|
331
|
+
console.error('uai: crew add needs a persona id (see crew list) and --why "<reason>"');
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
const body = { personaId, why: flags.why };
|
|
335
|
+
if (flags.as) body.handle = handleOf(flags.as);
|
|
336
|
+
if (flags.engine) body.kind = flags.engine;
|
|
337
|
+
if (flags.model) body.model = flags.model;
|
|
338
|
+
if (flags.effort) body.effort = flags.effort;
|
|
339
|
+
const r = await api("POST", "/api/agent/crew", body);
|
|
340
|
+
out("added @" + r.agent.id + " (" + r.agent.label + ") on " + launch(r.agent) + ". It stays idle until you dispatch work to it.");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (action === "model") {
|
|
344
|
+
const id = handleOf(pos[0] || "");
|
|
345
|
+
if (!id || !flags.why || !(flags.model || flags.engine || flags.effort)) {
|
|
346
|
+
console.error('uai: crew model needs @agent, at least one of --engine, --model and --effort, and --why "<reason>"');
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
// "default" clears the setting back to the engine's own default (null).
|
|
350
|
+
const cleared = (v) => (v === "default" ? null : v);
|
|
351
|
+
const body = { why: flags.why };
|
|
352
|
+
if (flags.engine) body.kind = flags.engine;
|
|
353
|
+
if (flags.model) body.model = cleared(flags.model);
|
|
354
|
+
if (flags.effort) body.effort = cleared(flags.effort);
|
|
355
|
+
const r = await api("PATCH", "/api/agent/crew/" + encodeURIComponent(id), body);
|
|
356
|
+
out(r.changed
|
|
357
|
+
? "@" + id + " now runs on " + launch(r.agent) + "; its session restarts after its current turn"
|
|
358
|
+
: "@" + id + " already runs on " + launch(r.agent) + "; nothing changed");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (action === "remove") {
|
|
362
|
+
const id = handleOf(pos[0] || "");
|
|
363
|
+
if (!id || !flags.why) {
|
|
364
|
+
console.error('uai: crew remove needs @agent and --why "<reason>"');
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
await api("DELETE", "/api/agent/crew/" + encodeURIComponent(id), { why: flags.why });
|
|
368
|
+
out("removed @" + id);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
console.error("uai: crew commands are list, add, model and remove (Astronaut only)");
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
315
374
|
const key = group + " " + (action || "");
|
|
316
375
|
switch (key) {
|
|
317
376
|
case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
|
|
@@ -497,6 +556,10 @@ async function main() {
|
|
|
497
556
|
" uai task history (list your past tasks)",
|
|
498
557
|
" uai task read <taskId> (full chat of a past task you were on)",
|
|
499
558
|
' uai dispatch @agent [@agent…] "instruction" (Secretary only)',
|
|
559
|
+
" uai crew list (Astronaut only: personas, engines, models, limits, roster)",
|
|
560
|
+
' uai crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] --why "<reason>"',
|
|
561
|
+
' uai crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] --why "<reason>" (at least one of the three)',
|
|
562
|
+
' uai crew remove @agent --why "<reason>"',
|
|
500
563
|
" uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
|
|
501
564
|
" uai whoami",
|
|
502
565
|
].join("\\n"));
|
package/lib/agents/claude.ts
CHANGED
|
@@ -390,6 +390,17 @@ const CLAUDE_FULL_ACCESS_ARGS = [
|
|
|
390
390
|
"--strict-mcp-config",
|
|
391
391
|
];
|
|
392
392
|
|
|
393
|
+
/**
|
|
394
|
+
* ADR-157 Astronaut profile: the normal tool set, minus Claude Code's native
|
|
395
|
+
* subagents. An Astronaut's helpers must be crew agents the task can see and
|
|
396
|
+
* bill; a built-in Agent/Task subagent would run (and spend) outside the crew.
|
|
397
|
+
*/
|
|
398
|
+
const CLAUDE_ASTRONAUT_ARGS = [
|
|
399
|
+
...CLAUDE_FULL_ACCESS_ARGS,
|
|
400
|
+
"--disallowedTools",
|
|
401
|
+
"Agent,Task",
|
|
402
|
+
];
|
|
403
|
+
|
|
393
404
|
/**
|
|
394
405
|
* ADR-083 communicator profile. This is deliberately an engine-level tool
|
|
395
406
|
* boundary, not prompt advice: setting sources, hooks, skills, workflows and
|
|
@@ -440,7 +451,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
440
451
|
agent: RosterAgent;
|
|
441
452
|
environment: TaskEnvironmentAgentSessionSurface;
|
|
442
453
|
systemPreamble: string;
|
|
443
|
-
executionProfile?: "communicator";
|
|
454
|
+
executionProfile?: "communicator" | "astronaut";
|
|
444
455
|
attachCompatibilityKey?: string;
|
|
445
456
|
agentEnv?: Record<string, string>;
|
|
446
457
|
}) {
|
|
@@ -453,7 +464,9 @@ export class ClaudeSession implements AgentSession {
|
|
|
453
464
|
const cliArgs = [
|
|
454
465
|
...(args.executionProfile === "communicator"
|
|
455
466
|
? CLAUDE_COMMUNICATOR_ARGS
|
|
456
|
-
:
|
|
467
|
+
: args.executionProfile === "astronaut"
|
|
468
|
+
? CLAUDE_ASTRONAUT_ARGS
|
|
469
|
+
: CLAUDE_FULL_ACCESS_ARGS),
|
|
457
470
|
];
|
|
458
471
|
if (process.env.UAI_CLAUDE_INCLUDE_PARTIAL_MESSAGES === "1") {
|
|
459
472
|
cliArgs.push("--include-partial-messages");
|
|
@@ -704,6 +717,10 @@ register({
|
|
|
704
717
|
defaultModel: CLAUDE_COMMUNICATOR_MODEL,
|
|
705
718
|
defaultEffort: CLAUDE_COMMUNICATOR_EFFORT,
|
|
706
719
|
},
|
|
720
|
+
{
|
|
721
|
+
id: "astronaut",
|
|
722
|
+
mechanism: "claude-disallowed-agent-task-v1",
|
|
723
|
+
},
|
|
707
724
|
],
|
|
708
725
|
// Usable only when a Claude credential is in the env (injected into task
|
|
709
726
|
// containers at task-up). Gates advertisement (ADR-044 P2).
|
package/lib/agents/codex.ts
CHANGED
|
@@ -391,6 +391,8 @@ export class CodexSession implements AgentSession {
|
|
|
391
391
|
attachCompatibilityKey?: string;
|
|
392
392
|
/** ADR-128: `-c key=value` overrides a delegating adapter prepends. */
|
|
393
393
|
cliConfigOverrides?: string[];
|
|
394
|
+
/** ADR-157: the Astronaut runs without Codex's native multi-agent tools. */
|
|
395
|
+
executionProfile?: "communicator" | "astronaut";
|
|
394
396
|
}) {
|
|
395
397
|
this.agentId = args.agent.id;
|
|
396
398
|
this.systemPreamble = args.systemPreamble;
|
|
@@ -425,8 +427,15 @@ export class CodexSession implements AgentSession {
|
|
|
425
427
|
"shell_snapshot",
|
|
426
428
|
"--disable",
|
|
427
429
|
"shell_snapshot_v2",
|
|
428
|
-
"app-server",
|
|
429
430
|
);
|
|
431
|
+
// ADR-157: an Astronaut's helpers must be crew agents the task can see and
|
|
432
|
+
// bill, so Codex's own subagents (spawn_agent; multi_agent is on by
|
|
433
|
+
// default in 0.157, multi_agent_v2 off) are disabled for it. Also after
|
|
434
|
+
// every override, so nothing can turn them back on.
|
|
435
|
+
if (args.executionProfile === "astronaut") {
|
|
436
|
+
codexArgs.push("--disable", "multi_agent", "--disable", "multi_agent_v2");
|
|
437
|
+
}
|
|
438
|
+
codexArgs.push("app-server");
|
|
430
439
|
// ADR-061: the CLI is owned by an in-container runner (durable across
|
|
431
440
|
// host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
|
|
432
441
|
// re-handshakes per host process (initialize + thread/start live in this
|
|
@@ -801,6 +810,9 @@ register({
|
|
|
801
810
|
defaultModel: CODEX_DEFAULT_MODEL,
|
|
802
811
|
supportedEfforts: () => [...CODEX_EFFORTS],
|
|
803
812
|
defaultEffort: CODEX_DEFAULT_EFFORT,
|
|
813
|
+
// ADR-157: enforceable by construction (the --disable flags above). The
|
|
814
|
+
// communicator profile is Claude-only; Codex never declared it.
|
|
815
|
+
executionProfiles: [{ id: "astronaut", mechanism: "codex-disable-multi-agent-v1" }],
|
|
804
816
|
// Usable only when the owner's ~/.codex login exists — task-up copies it into
|
|
805
817
|
// each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
|
|
806
818
|
// (ADR-044 P2).
|
|
@@ -820,6 +832,7 @@ register({
|
|
|
820
832
|
agentEnv,
|
|
821
833
|
attachCompatibilityKey,
|
|
822
834
|
cliConfigOverrides,
|
|
835
|
+
executionProfile,
|
|
823
836
|
}) =>
|
|
824
837
|
new CodexSession({
|
|
825
838
|
taskId,
|
|
@@ -829,5 +842,6 @@ register({
|
|
|
829
842
|
agentEnv,
|
|
830
843
|
attachCompatibilityKey,
|
|
831
844
|
cliConfigOverrides,
|
|
845
|
+
executionProfile,
|
|
832
846
|
}),
|
|
833
847
|
});
|
package/lib/agents/registry.ts
CHANGED
|
@@ -88,10 +88,11 @@ export function setAdvertisementFallback(
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* ADR-154: on cloud credentials the runner holds no engine credential of
|
|
92
|
-
* own, so availability and the fallback above say nothing: the kinds
|
|
93
|
-
*
|
|
94
|
-
* the
|
|
91
|
+
* ADR-154 §10: on cloud credentials the runner holds no engine credential of
|
|
92
|
+
* its own, so availability and the fallback above say nothing: the kinds a
|
|
93
|
+
* leased task can run are advertised, and only those (`CLOUD_CREDENTIAL_KINDS`:
|
|
94
|
+
* the leased lanes and the Managed Engine). Null while the runner runs its
|
|
95
|
+
* tasks on its own credentials. Installed by main.ts.
|
|
95
96
|
*/
|
|
96
97
|
let leasedKinds: () => ReadonlySet<string> | null = () => null;
|
|
97
98
|
|
package/lib/agents/types.ts
CHANGED
|
@@ -57,6 +57,9 @@ export const RosterAgent = z.object({
|
|
|
57
57
|
// ADR-128: which upstream protocol a Uai-engine model speaks — resolved
|
|
58
58
|
// cloud-side from the model catalog; selects the delegated adapter.
|
|
59
59
|
uaiProvider: z.enum(["anthropic", "openai"]).optional(),
|
|
60
|
+
builtIn: z.literal("astronaut").optional(), // ADR-157: the cloud-built Astronaut only
|
|
61
|
+
// ADR-157 §3: signed into the agent's task token as `g` (see agentCliEnv).
|
|
62
|
+
tokenGeneration: z.string().min(1).max(64).optional(),
|
|
60
63
|
});
|
|
61
64
|
export type RosterAgent = z.infer<typeof RosterAgent>;
|
|
62
65
|
|
|
@@ -179,7 +182,7 @@ export interface AgentSessionFactory {
|
|
|
179
182
|
systemPreamble: string;
|
|
180
183
|
/** Host-derived restricted execution policy (ADR-083). Never supplied by
|
|
181
184
|
* the browser or stored on the roster entry. */
|
|
182
|
-
executionProfile?: "communicator";
|
|
185
|
+
executionProfile?: "communicator" | "astronaut";
|
|
183
186
|
/** Durable runners are keyed by task+agent. Host-owned launch settings
|
|
184
187
|
* supply a compatibility key so restart recovery replaces, rather than
|
|
185
188
|
* attaches, a process launched with incompatible model/effort/policy. */
|
package/lib/agents/uai.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* the request's `model`, and the gateway maps slug → upstream id.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { factoryFor, register } from "./registry";
|
|
18
|
+
import { factoryFor, register, supportsExecutionProfile } from "./registry";
|
|
19
19
|
import type { AgentSession, AgentSessionFactory } from "./types";
|
|
20
20
|
|
|
21
21
|
const DEFAULT_GATEWAY_URL = "https://inference.runuai.com";
|
|
@@ -80,6 +80,17 @@ const create: AgentSessionFactory["create"] = async (args): Promise<AgentSession
|
|
|
80
80
|
if (!delegate) {
|
|
81
81
|
throw new Error(`uai engine: no ${delegateKind} adapter registered to delegate to`);
|
|
82
82
|
}
|
|
83
|
+
// ADR-157 §7: the runner checked the profile against the `uai` kind, but the
|
|
84
|
+
// delegate is what enforces it. Refuse rather than run an Astronaut on a
|
|
85
|
+
// delegate that would ignore the profile.
|
|
86
|
+
if (
|
|
87
|
+
args.executionProfile &&
|
|
88
|
+
!supportsExecutionProfile(delegateKind, args.executionProfile)
|
|
89
|
+
) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`uai engine: the ${delegateKind} adapter cannot enforce execution profile "${args.executionProfile}"`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
83
94
|
const taskToken = args.agentEnv?.UAI_TASK_TOKEN;
|
|
84
95
|
if (!taskToken) {
|
|
85
96
|
// agentCliEnv mints no token for a permissionless non-secretary agent;
|
|
@@ -110,5 +121,8 @@ register({
|
|
|
110
121
|
// Always available: the engine needs no host-side credential — auth is
|
|
111
122
|
// the per-agent task token, billing is the org's credits.
|
|
112
123
|
available: () => true,
|
|
124
|
+
// ADR-157: `create` forwards the profile to the Claude or Codex delegate,
|
|
125
|
+
// and both enforce it.
|
|
126
|
+
executionProfiles: [{ id: "astronaut", mechanism: "uai-delegated-astronaut-v1" }],
|
|
113
127
|
create,
|
|
114
128
|
});
|
|
@@ -257,6 +257,12 @@ export interface TaskCredentialLeaseRegistryOptions {
|
|
|
257
257
|
assignment: Readonly<TaskAssignment>,
|
|
258
258
|
view: TaskCredentialView,
|
|
259
259
|
): void;
|
|
260
|
+
/** A material update committed while the existing gate stayed open.
|
|
261
|
+
* Followers may reconcile runtime files from this authoritative view. */
|
|
262
|
+
credentialMaterialChanged?(
|
|
263
|
+
assignment: Readonly<TaskAssignment>,
|
|
264
|
+
view: TaskCredentialView,
|
|
265
|
+
): void;
|
|
260
266
|
cleanupExternal(request: CredentialCleanupRequest): Promise<void>;
|
|
261
267
|
/**
|
|
262
268
|
* Installs files/proxy/engine consumers transactionally. The env-replica
|
|
@@ -2279,6 +2285,9 @@ export class TaskCredentialLeaseRegistry {
|
|
|
2279
2285
|
state.bundle = next;
|
|
2280
2286
|
state.bundleGeneration++;
|
|
2281
2287
|
this.#armBundleTimers(state);
|
|
2288
|
+
if (!refused) {
|
|
2289
|
+
this.#opts.credentialMaterialChanged?.(cloneAssignment(state.assignment), next.view);
|
|
2290
|
+
}
|
|
2282
2291
|
// A refusal opens through its release, inside this boundary: an open
|
|
2283
2292
|
// that fails, its compensating close included, escalates below.
|
|
2284
2293
|
release();
|
|
@@ -63,9 +63,11 @@ import {
|
|
|
63
63
|
cleanupPersonCredentials,
|
|
64
64
|
closePersonCredentialGate,
|
|
65
65
|
drainPersonCredentialWrites,
|
|
66
|
+
holdPersonCredentialGate,
|
|
66
67
|
holdTaskGitCredentials,
|
|
67
68
|
installPersonCredentials,
|
|
68
69
|
openPersonCredentialGate,
|
|
70
|
+
type PersonCredentialLane,
|
|
69
71
|
type TaskGitCredentialHold,
|
|
70
72
|
} from "./credential-consumers/github";
|
|
71
73
|
import {
|
|
@@ -160,18 +162,90 @@ export interface CloudCredentialRuntimeDeps {
|
|
|
160
162
|
* on a grant's snapshot, or closed): whoever follows the task's env
|
|
161
163
|
* (the proxy's trust delivery) looks again. */
|
|
162
164
|
envServedChanged?(taskId: string): void;
|
|
165
|
+
/** Reconcile the owner's GitHub/SSH files in an already-running task.
|
|
166
|
+
* The signal closes with the exact person-credential gate, and the caller
|
|
167
|
+
* retries a transient rejection while that gate and material stay current. */
|
|
168
|
+
personCredentialsReady?(
|
|
169
|
+
assignment: Readonly<TaskAssignment>,
|
|
170
|
+
lanes: readonly PersonCredentialLane[],
|
|
171
|
+
signal: AbortSignal,
|
|
172
|
+
): Promise<void>;
|
|
163
173
|
files?: TaskFilesConsumer | null;
|
|
164
174
|
launchDeadlineMs?: number;
|
|
165
175
|
envReadyTimeoutMs?: number;
|
|
166
176
|
shutdownDrainMs?: number;
|
|
167
177
|
/** The first retry's delay for a recovered start whose startup failed. */
|
|
168
178
|
recoveryRetryBaseMs?: number;
|
|
179
|
+
/** The first retry's delay for task-runtime person-credential writes. */
|
|
180
|
+
personReconcileRetryBaseMs?: number;
|
|
169
181
|
registry?: Partial<
|
|
170
182
|
Pick<TaskCredentialLeaseRegistryOptions, "createSessionReader" | "now" | "setTimer" | "clearTimer" | "randomId" | "onError">
|
|
171
183
|
>;
|
|
172
184
|
cleanup?: Pick<TaskCredentialCleanupDeps, "db" | "removePath" | "destroyRuntime">;
|
|
173
185
|
}
|
|
174
186
|
|
|
187
|
+
interface PersonCredentialStamp {
|
|
188
|
+
/** Stable without retaining another copy of any credential material. */
|
|
189
|
+
identity: string;
|
|
190
|
+
available: boolean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface PersonCredentialSnapshot {
|
|
194
|
+
runEpoch: number;
|
|
195
|
+
github: PersonCredentialStamp;
|
|
196
|
+
ssh: PersonCredentialStamp;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function personCredentialStamp(
|
|
200
|
+
credential: TaskCredentialView["github"] | TaskCredentialView["ssh"],
|
|
201
|
+
): PersonCredentialStamp {
|
|
202
|
+
if (credential === null) return { identity: "absent", available: false };
|
|
203
|
+
return {
|
|
204
|
+
identity: [
|
|
205
|
+
credential.manifest.credentialId,
|
|
206
|
+
credential.manifest.revision,
|
|
207
|
+
credential.availability,
|
|
208
|
+
credential.observedVersion ?? "unreadable",
|
|
209
|
+
].join("\u0000"),
|
|
210
|
+
available: credential.availability === "available",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function personCredentialSnapshot(view: TaskCredentialView): PersonCredentialSnapshot {
|
|
215
|
+
return {
|
|
216
|
+
runEpoch: view.assignment.runEpoch,
|
|
217
|
+
github: personCredentialStamp(view.github),
|
|
218
|
+
ssh: personCredentialStamp(view.ssh),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function samePersonCredentialSnapshot(
|
|
223
|
+
left: PersonCredentialSnapshot | undefined,
|
|
224
|
+
right: PersonCredentialSnapshot,
|
|
225
|
+
): boolean {
|
|
226
|
+
return Boolean(
|
|
227
|
+
left &&
|
|
228
|
+
left.runEpoch === right.runEpoch &&
|
|
229
|
+
left.github.identity === right.github.identity &&
|
|
230
|
+
left.ssh.identity === right.ssh.identity,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What must be rewritten for `next`. GitHub and SSH transport are coupled:
|
|
235
|
+
* when GitHub leaves, the surviving SSH key must select SSH again. */
|
|
236
|
+
function changedPersonCredentialLanes(
|
|
237
|
+
previous: PersonCredentialSnapshot | undefined,
|
|
238
|
+
next: PersonCredentialSnapshot,
|
|
239
|
+
): PersonCredentialLane[] {
|
|
240
|
+
if (samePersonCredentialSnapshot(previous, next)) return [];
|
|
241
|
+
// The pair shares Git transport configuration. Reconcile both available
|
|
242
|
+
// lanes, including a write interrupted by the other lane changing.
|
|
243
|
+
const lanes: PersonCredentialLane[] = [];
|
|
244
|
+
if (next.github.available) lanes.push("github");
|
|
245
|
+
if (next.ssh.available) lanes.push("ssh");
|
|
246
|
+
return lanes;
|
|
247
|
+
}
|
|
248
|
+
|
|
175
249
|
/**
|
|
176
250
|
* Why a start was interrupted, as its signal's reason: Stop, teardown or a
|
|
177
251
|
* newer run took it over, or the runner is shutting down. The first
|
|
@@ -308,6 +382,13 @@ export class CloudCredentialRuntime {
|
|
|
308
382
|
/** Last agent-facing MCP membership published for each task. Kept across a
|
|
309
383
|
* gate close/renewal because the config remains in the task runtime. */
|
|
310
384
|
readonly #publishedMcpMembership = new Map<string, string>();
|
|
385
|
+
/** Person material last opened or installed for each task. Only identities,
|
|
386
|
+
* availability and observed versions: never another copy of a secret. */
|
|
387
|
+
readonly #personCredentialSnapshots = new Map<string, PersonCredentialSnapshot>();
|
|
388
|
+
readonly #personReconciles = new Map<string, { runEpoch: number; controller: AbortController }>();
|
|
389
|
+
/** Stop fences work before it can acquire the lifecycle slot or close the
|
|
390
|
+
* lease. A late grant cannot restart writes in that interval. */
|
|
391
|
+
readonly #personInterrupted = new Map<string, number>();
|
|
311
392
|
readonly #setTimer: typeof setTimeout;
|
|
312
393
|
readonly #clearTimer: typeof clearTimeout;
|
|
313
394
|
#shutDown = false;
|
|
@@ -337,6 +418,7 @@ export class CloudCredentialRuntime {
|
|
|
337
418
|
send: (frame) => deps.send(frame),
|
|
338
419
|
closeTaskGate: (assignment) => this.#close(assignment),
|
|
339
420
|
openTaskGate: (assignment, view) => this.#openGate(assignment, view),
|
|
421
|
+
credentialMaterialChanged: (assignment, view) => this.#personCredentialsChanged(assignment, view),
|
|
340
422
|
installExternal: (request) => this.#track(this.#install(request)),
|
|
341
423
|
// From a grant's acceptance until its own reader is active, the env
|
|
342
424
|
// replica refuses the run's frames for the cloud to retry: none is
|
|
@@ -379,6 +461,7 @@ export class CloudCredentialRuntime {
|
|
|
379
461
|
throw new CredentialLeaseUnavailableError("a newer run of this task has begun");
|
|
380
462
|
}
|
|
381
463
|
this.#floors.set(assignment.taskId, assignment.runEpoch);
|
|
464
|
+
if (!options.recovery) this.#personInterrupted.delete(assignment.taskId);
|
|
382
465
|
this.#interruptTokens(assignment.taskId, {});
|
|
383
466
|
// A newer run's start ends an older run's recovery; an ordinary start of
|
|
384
467
|
// the run being recovered takes its startup over.
|
|
@@ -418,6 +501,11 @@ export class CloudCredentialRuntime {
|
|
|
418
501
|
*/
|
|
419
502
|
interruptStarts(taskId: string, which: { exact?: number; below?: number } = {}): void {
|
|
420
503
|
this.#interruptTokens(taskId, which);
|
|
504
|
+
const assignment = this.#assignments.get(taskId);
|
|
505
|
+
if (assignment && (which.exact === undefined || assignment.runEpoch === which.exact) &&
|
|
506
|
+
(which.below === undefined || assignment.runEpoch < which.below)) {
|
|
507
|
+
this.#interruptPersonCredentials(assignment);
|
|
508
|
+
}
|
|
421
509
|
// A recovery of the run ends with it, ended start token or not.
|
|
422
510
|
const recovered = this.#recovered.get(taskId);
|
|
423
511
|
if (
|
|
@@ -441,6 +529,8 @@ export class CloudCredentialRuntime {
|
|
|
441
529
|
/** A local Stop: the task's recovery, whatever its run, ends now. One
|
|
442
530
|
* only exists for the run this runner owned at its start. */
|
|
443
531
|
interruptRecovery(taskId: string): void {
|
|
532
|
+
const assignment = this.#assignments.get(taskId);
|
|
533
|
+
if (assignment) this.#interruptPersonCredentials(assignment);
|
|
444
534
|
const recovered = this.#recovered.get(taskId);
|
|
445
535
|
if (recovered) this.#endRecovery(taskId, recovered.runEpoch);
|
|
446
536
|
}
|
|
@@ -776,41 +866,50 @@ export class CloudCredentialRuntime {
|
|
|
776
866
|
if (recovered.running === null) this.#recovered.delete(taskId);
|
|
777
867
|
}
|
|
778
868
|
|
|
779
|
-
/** A recovered
|
|
780
|
-
*
|
|
869
|
+
/** A recovered runtime is ready only after setup succeeds. Transient
|
|
870
|
+
* reconstruction/exec failures retry; Stop, cleanup and takeover fence
|
|
871
|
+
* every attempt and its retry delay. */
|
|
781
872
|
#runRecoveredSetup(assignment: Readonly<TaskAssignment>): void {
|
|
782
873
|
const recovered = this.#recovered.get(assignment.taskId);
|
|
783
874
|
if (!recovered || recovered.runEpoch !== assignment.runEpoch || recovered.running !== null) return;
|
|
784
|
-
if (recovered.controller.signal.aborted) return;
|
|
875
|
+
if (recovered.controller.signal.aborted || this.#open.get(assignment.taskId) !== assignment.runEpoch) return;
|
|
785
876
|
if (recovered.retry !== null) this.#clearTimer(recovered.retry);
|
|
786
877
|
recovered.retry = null;
|
|
787
878
|
const setup = recovered.setup;
|
|
788
|
-
recovered.setup = null;
|
|
789
879
|
if (setup === null) {
|
|
790
880
|
this.#recovered.delete(assignment.taskId);
|
|
791
881
|
return;
|
|
792
882
|
}
|
|
793
|
-
const
|
|
883
|
+
const hold = holdPersonCredentialGate(assignment);
|
|
884
|
+
const signal = AbortSignal.any([recovered.controller.signal, hold.signal]);
|
|
885
|
+
let complete = false;
|
|
794
886
|
recovered.running = this.#track(
|
|
795
887
|
Promise.resolve()
|
|
796
|
-
.then(() => {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
888
|
+
.then(async () => {
|
|
889
|
+
signal.throwIfAborted();
|
|
890
|
+
await setup(signal);
|
|
891
|
+
signal.throwIfAborted();
|
|
892
|
+
complete = true;
|
|
893
|
+
})
|
|
894
|
+
.catch(() => {
|
|
895
|
+
if (!signal.aborted) {
|
|
896
|
+
console.warn(`[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: recovered runtime setup failed; retrying`);
|
|
897
|
+
}
|
|
803
898
|
})
|
|
804
|
-
.catch((error: unknown) =>
|
|
805
|
-
console.warn(
|
|
806
|
-
`[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: recovered runtime setup failed: ${
|
|
807
|
-
error instanceof Error ? error.message : String(error)
|
|
808
|
-
}`,
|
|
809
|
-
),
|
|
810
|
-
)
|
|
811
899
|
.finally(() => {
|
|
900
|
+
hold.release();
|
|
812
901
|
recovered.running = null;
|
|
813
|
-
if (this.#recovered.get(assignment.taskId)
|
|
902
|
+
if (this.#recovered.get(assignment.taskId) !== recovered) return;
|
|
903
|
+
if (complete || recovered.controller.signal.aborted || this.#shutDown) {
|
|
904
|
+
this.#recovered.delete(assignment.taskId);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const base = this.#deps.recoveryRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
|
|
908
|
+
const delay = Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** Math.min(recovered.attempts++, 20));
|
|
909
|
+
recovered.retry = this.#setTimer(() => {
|
|
910
|
+
recovered.retry = null;
|
|
911
|
+
this.#runRecoveredSetup(assignment);
|
|
912
|
+
}, delay);
|
|
814
913
|
}),
|
|
815
914
|
);
|
|
816
915
|
}
|
|
@@ -983,10 +1082,72 @@ export class CloudCredentialRuntime {
|
|
|
983
1082
|
// Nothing which follows publishes work synchronously. If Files itself
|
|
984
1083
|
// fails, the registry compensates by closing the earlier gates.
|
|
985
1084
|
this.#deps.files?.open(assignment, view);
|
|
1085
|
+
this.#personCredentialsChanged(assignment, view);
|
|
986
1086
|
this.#publishMcpMembership(assignment.taskId, view);
|
|
987
1087
|
this.#runRecoveredSetup(assignment);
|
|
988
1088
|
}
|
|
989
1089
|
|
|
1090
|
+
#personCredentialsChanged(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
|
|
1091
|
+
const reconcile = this.#deps.personCredentialsReady;
|
|
1092
|
+
if (!reconcile || this.#shutDown || this.#open.get(assignment.taskId) !== assignment.runEpoch) return;
|
|
1093
|
+
if ((this.#personInterrupted.get(assignment.taskId) ?? -1) >= assignment.runEpoch) return;
|
|
1094
|
+
const previous = this.#personCredentialSnapshots.get(assignment.taskId);
|
|
1095
|
+
const snapshot = personCredentialSnapshot(view);
|
|
1096
|
+
if (samePersonCredentialSnapshot(previous, snapshot)) return;
|
|
1097
|
+
this.#personCredentialSnapshots.set(assignment.taskId, snapshot);
|
|
1098
|
+
this.#personReconciles.get(assignment.taskId)?.controller.abort();
|
|
1099
|
+
const lanes = changedPersonCredentialLanes(previous, snapshot);
|
|
1100
|
+
if (lanes.length === 0) return;
|
|
1101
|
+
const entry = { runEpoch: assignment.runEpoch, controller: new AbortController() };
|
|
1102
|
+
const hold = holdPersonCredentialGate(assignment);
|
|
1103
|
+
const signal = AbortSignal.any([entry.controller.signal, hold.signal]);
|
|
1104
|
+
this.#personReconciles.set(assignment.taskId, entry);
|
|
1105
|
+
this.#background(
|
|
1106
|
+
Promise.resolve().then(async () => {
|
|
1107
|
+
let attempts = 0;
|
|
1108
|
+
while (!signal.aborted) {
|
|
1109
|
+
try {
|
|
1110
|
+
await reconcile(assignment, lanes, signal);
|
|
1111
|
+
signal.throwIfAborted();
|
|
1112
|
+
return;
|
|
1113
|
+
} catch {
|
|
1114
|
+
if (signal.aborted) return;
|
|
1115
|
+
if (attempts === 0) {
|
|
1116
|
+
console.warn(`[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: person credential files not ready; retrying`);
|
|
1117
|
+
}
|
|
1118
|
+
const base = this.#deps.personReconcileRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
|
|
1119
|
+
await this.#retryDelay(Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** Math.min(attempts++, 20)), signal);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}).finally(() => {
|
|
1123
|
+
hold.release();
|
|
1124
|
+
if (this.#personReconciles.get(assignment.taskId) === entry) this.#personReconciles.delete(assignment.taskId);
|
|
1125
|
+
}),
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
#interruptPersonCredentials(assignment: Readonly<TaskAssignment>): void {
|
|
1130
|
+
this.#personInterrupted.set(assignment.taskId, Math.max(assignment.runEpoch, this.#personInterrupted.get(assignment.taskId) ?? -1));
|
|
1131
|
+
const snapshot = this.#personCredentialSnapshots.get(assignment.taskId);
|
|
1132
|
+
if (snapshot && snapshot.runEpoch <= assignment.runEpoch) this.#personCredentialSnapshots.delete(assignment.taskId);
|
|
1133
|
+
const reconcile = this.#personReconciles.get(assignment.taskId);
|
|
1134
|
+
if (reconcile && reconcile.runEpoch <= assignment.runEpoch) reconcile.controller.abort();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** A retry never holds cleanup for its backoff interval. */
|
|
1138
|
+
#retryDelay(delay: number, signal: AbortSignal): Promise<void> {
|
|
1139
|
+
return new Promise((resolve) => {
|
|
1140
|
+
const finish = (): void => {
|
|
1141
|
+
this.#clearTimer(timer);
|
|
1142
|
+
signal.removeEventListener("abort", finish);
|
|
1143
|
+
resolve();
|
|
1144
|
+
};
|
|
1145
|
+
const timer = this.#setTimer(finish, delay);
|
|
1146
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
1147
|
+
if (signal.aborted) finish();
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
990
1151
|
#envServedChanged(taskId: string): void {
|
|
991
1152
|
try {
|
|
992
1153
|
this.#deps.envServedChanged?.(taskId);
|
|
@@ -1030,8 +1191,11 @@ export class CloudCredentialRuntime {
|
|
|
1030
1191
|
if (open !== undefined && open <= assignment.runEpoch) {
|
|
1031
1192
|
this.#open.delete(assignment.taskId);
|
|
1032
1193
|
this.#served.delete(assignment.taskId);
|
|
1194
|
+
this.#personCredentialSnapshots.delete(assignment.taskId);
|
|
1033
1195
|
this.#envServedChanged(assignment.taskId);
|
|
1034
1196
|
}
|
|
1197
|
+
const person = this.#personReconciles.get(assignment.taskId);
|
|
1198
|
+
if (person && person.runEpoch <= assignment.runEpoch) person.controller.abort();
|
|
1035
1199
|
for (const entry of this.#holds.get(assignment.taskId) ?? []) {
|
|
1036
1200
|
if (entry.runEpoch <= assignment.runEpoch) {
|
|
1037
1201
|
attempt(() => entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials were withdrawn")));
|
|
@@ -1134,7 +1298,9 @@ export class CloudCredentialRuntime {
|
|
|
1134
1298
|
const recovered = this.#recovered.get(request.taskId);
|
|
1135
1299
|
const setups: Promise<void>[] = [];
|
|
1136
1300
|
if (request.credentialIds === undefined && recovered && recovered.runEpoch <= request.runEpoch && recovered.running) {
|
|
1137
|
-
|
|
1301
|
+
// The gate hold already cancelled this attempt. A transient full
|
|
1302
|
+
// cleanup may reacquire the same run's lease, so retain its setup for
|
|
1303
|
+
// that next open. Stop/takeover cancel recovery at their own boundary.
|
|
1138
1304
|
setups.push(recovered.running);
|
|
1139
1305
|
}
|
|
1140
1306
|
const consumer = consumerCleanupRequest(request);
|