@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
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* is recorded in the cleanup inventory before anything is written to it.
|
|
24
24
|
* - Writes for one task run one at a time, and each exec is admitted at the
|
|
25
25
|
* provider's last moment against the task's live state: the same
|
|
26
|
-
* assignment,
|
|
26
|
+
* assignment, credential identity, revision and material version, not
|
|
27
|
+
* sealed. A cleanup seals
|
|
27
28
|
* (or drops the credentials it names), then waits for every write under
|
|
28
29
|
* way to end, so nothing it is about to delete can be written again after.
|
|
29
30
|
*/
|
|
@@ -64,6 +65,16 @@ export interface LeasedSshKey {
|
|
|
64
65
|
publicKey: string;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
export type PersonCredentialLane = "github" | "ssh";
|
|
69
|
+
|
|
70
|
+
/** Writer-only authority: clone callers do not need to know the version. */
|
|
71
|
+
type VersionedCredential<T> = T & { version: number };
|
|
72
|
+
|
|
73
|
+
interface PersonCredentialSlots {
|
|
74
|
+
github: Slot<VersionedCredential<LeasedGithub>>;
|
|
75
|
+
ssh: Slot<VersionedCredential<LeasedSshKey>>;
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
/** A manifest lane: not named, named but unreadable, or in hand. */
|
|
68
79
|
type Slot<T extends { credentialId: string; revision: string }> =
|
|
69
80
|
| { kind: "absent" }
|
|
@@ -85,14 +96,12 @@ interface GitHold {
|
|
|
85
96
|
release(): void;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
|
|
99
|
+
/** The inherited slots are what the registry's gate opened on: the exact
|
|
100
|
+
* committed view, absent while closed. Only these serve holds and writers. */
|
|
101
|
+
interface TaskPersonCredentials extends PersonCredentialSlots {
|
|
89
102
|
assignment: TaskAssignment;
|
|
90
|
-
/** What the registry's gate opened on (the exact committed view): the
|
|
91
|
-
* only thing holds and writers read. Absent while closed. */
|
|
92
|
-
github: Slot<LeasedGithub>;
|
|
93
|
-
ssh: Slot<LeasedSshKey>;
|
|
94
103
|
/** What the last install proposed: never served until a gate opens on it. */
|
|
95
|
-
candidate:
|
|
104
|
+
candidate: PersonCredentialSlots;
|
|
96
105
|
/** The registry's gate for this run: nothing is held or written while
|
|
97
106
|
* closed. */
|
|
98
107
|
open: boolean;
|
|
@@ -109,33 +118,59 @@ interface TaskPersonCredentials {
|
|
|
109
118
|
|
|
110
119
|
const byTask = new Map<string, TaskPersonCredentials>();
|
|
111
120
|
|
|
112
|
-
function slotOf<
|
|
121
|
+
function slotOf<
|
|
122
|
+
T extends GithubLeasedValue | SshLeasedValue,
|
|
123
|
+
V extends { credentialId: string; revision: string; version: number },
|
|
124
|
+
>(
|
|
113
125
|
credential: TaskStaticCredential<T> | null,
|
|
114
|
-
value: (material: Readonly<T>) => Omit<V, "credentialId" | "revision">,
|
|
126
|
+
value: (material: Readonly<T>) => Omit<V, "credentialId" | "revision" | "version">,
|
|
115
127
|
): Slot<V> {
|
|
116
128
|
if (!credential) return { kind: "absent" };
|
|
117
129
|
const identity = { credentialId: credential.manifest.credentialId, revision: credential.manifest.revision };
|
|
118
|
-
if (
|
|
119
|
-
|
|
130
|
+
if (
|
|
131
|
+
credential.availability !== "available" ||
|
|
132
|
+
!credential.material ||
|
|
133
|
+
credential.observedVersion === null
|
|
134
|
+
) {
|
|
135
|
+
return { kind: "unavailable", ...identity };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind: "present",
|
|
139
|
+
value: {
|
|
140
|
+
...identity,
|
|
141
|
+
version: credential.observedVersion,
|
|
142
|
+
...value(credential.material),
|
|
143
|
+
} as V,
|
|
144
|
+
};
|
|
120
145
|
}
|
|
121
146
|
|
|
122
|
-
function slotsOf(view: TaskCredentialView):
|
|
147
|
+
function slotsOf(view: TaskCredentialView): PersonCredentialSlots {
|
|
123
148
|
return {
|
|
124
|
-
github: slotOf<GithubLeasedValue, LeasedGithub
|
|
125
|
-
ssh: slotOf<SshLeasedValue, LeasedSshKey
|
|
149
|
+
github: slotOf<GithubLeasedValue, VersionedCredential<LeasedGithub>>(view.github, (material) => ({ token: material.token })),
|
|
150
|
+
ssh: slotOf<SshLeasedValue, VersionedCredential<LeasedSshKey>>(view.ssh, (material) => ({
|
|
126
151
|
privateKey: material.privateKey,
|
|
127
152
|
publicKey: material.publicKey,
|
|
128
153
|
})),
|
|
129
154
|
};
|
|
130
155
|
}
|
|
131
156
|
|
|
132
|
-
|
|
157
|
+
function identityOf(slot: Slot<CredentialIdentity>): CredentialIdentity | null {
|
|
158
|
+
return slot.kind === "present"
|
|
159
|
+
? { credentialId: slot.value.credentialId, revision: slot.value.revision }
|
|
160
|
+
: slot.kind === "unavailable"
|
|
161
|
+
? { credentialId: slot.credentialId, revision: slot.revision }
|
|
162
|
+
: null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The same named credential at the same revision, readable or not. */
|
|
133
166
|
function sameIdentity<T extends CredentialIdentity>(a: Slot<T>, b: Slot<T>): boolean {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
167
|
+
const left = identityOf(a);
|
|
168
|
+
const right = identityOf(b);
|
|
169
|
+
return Boolean(
|
|
170
|
+
left &&
|
|
171
|
+
right &&
|
|
172
|
+
left.credentialId === right.credentialId &&
|
|
173
|
+
left.revision === right.revision,
|
|
139
174
|
);
|
|
140
175
|
}
|
|
141
176
|
|
|
@@ -194,12 +229,6 @@ export async function cleanupPersonCredentials(request: CredentialCleanupRequest
|
|
|
194
229
|
removed.credentialId === identity.credentialId &&
|
|
195
230
|
(removed.revision === null || removed.revision === identity.revision),
|
|
196
231
|
));
|
|
197
|
-
const identityOf = (slot: Slot<CredentialIdentity>): CredentialIdentity | null =>
|
|
198
|
-
slot.kind === "present"
|
|
199
|
-
? { credentialId: slot.value.credentialId, revision: slot.value.revision }
|
|
200
|
-
: slot.kind === "unavailable"
|
|
201
|
-
? { credentialId: slot.credentialId, revision: slot.revision }
|
|
202
|
-
: null;
|
|
203
232
|
if (request.mode === "full") {
|
|
204
233
|
current.sealed = true;
|
|
205
234
|
current.open = false;
|
|
@@ -286,6 +315,44 @@ export interface TaskGitCredentialHold {
|
|
|
286
315
|
release(): void;
|
|
287
316
|
}
|
|
288
317
|
|
|
318
|
+
/**
|
|
319
|
+
* A task-runtime reconciliation's hold on the exact open person-credential
|
|
320
|
+
* gate. Unlike a clone's hold, it does not require either credential to be
|
|
321
|
+
* readable and exposes no material. Cleanup aborts it and waits for release,
|
|
322
|
+
* so a retry delay or runtime reconstruction cannot outlive the run whose
|
|
323
|
+
* files it is about to write.
|
|
324
|
+
*/
|
|
325
|
+
export interface TaskPersonCredentialGateHold {
|
|
326
|
+
signal: AbortSignal;
|
|
327
|
+
release(): void;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function holdPersonCredentialGate(
|
|
331
|
+
assignment: TaskAssignment,
|
|
332
|
+
): TaskPersonCredentialGateHold {
|
|
333
|
+
const state = liveState(assignment);
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
let settle!: () => void;
|
|
336
|
+
const released = new Promise<void>((resolve) => {
|
|
337
|
+
settle = resolve;
|
|
338
|
+
});
|
|
339
|
+
let releasedOnce = false;
|
|
340
|
+
const hold: GitHold = {
|
|
341
|
+
controller,
|
|
342
|
+
github: identityOf(state.github),
|
|
343
|
+
ssh: identityOf(state.ssh),
|
|
344
|
+
released,
|
|
345
|
+
release: () => {
|
|
346
|
+
if (releasedOnce) return;
|
|
347
|
+
releasedOnce = true;
|
|
348
|
+
state.holds.delete(hold);
|
|
349
|
+
settle();
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
state.holds.add(hold);
|
|
353
|
+
return { signal: controller.signal, release: hold.release };
|
|
354
|
+
}
|
|
355
|
+
|
|
289
356
|
/**
|
|
290
357
|
* Hold the owner's GitHub sign-in and SSH key for one host-side git
|
|
291
358
|
* operation of the exact assignment, while the registry's gate is open on
|
|
@@ -361,7 +428,7 @@ export const TASK_SSH_ALLOWED_SIGNERS_PATH = "/home/node/.ssh/allowed_signers";
|
|
|
361
428
|
export const TASK_SSH_SIGNING_GITCONFIG_PATH = "/home/node/.ssh/uai-signing.gitconfig";
|
|
362
429
|
/** SSH as git's transport to GitHub, while the task has no GitHub sign-in. */
|
|
363
430
|
export const TASK_SSH_TRANSPORT_GITCONFIG_PATH = "/home/node/.ssh/uai-transport.gitconfig";
|
|
364
|
-
/** The writer's step was refused: the run,
|
|
431
|
+
/** The writer's step was refused: the run, material or gate moved. */
|
|
365
432
|
export class PersonCredentialWriteRefused extends Error {
|
|
366
433
|
constructor() {
|
|
367
434
|
super("the task's credentials changed or were cleaned up; the write was not made");
|
|
@@ -505,11 +572,18 @@ export function writeTaskGithub(environment: Environment, assignment: TaskAssign
|
|
|
505
572
|
const slot = current().github;
|
|
506
573
|
if (slot.kind === "absent") return false;
|
|
507
574
|
if (slot.kind === "unavailable") throw new CredentialLeaseUnavailableError("the GitHub sign-in could not be read");
|
|
508
|
-
const { credentialId, revision, token } = slot.value;
|
|
509
|
-
// Admitted only while this exact
|
|
575
|
+
const { credentialId, revision, version, token } = slot.value;
|
|
576
|
+
// Admitted only while this exact material version is still the task's.
|
|
510
577
|
const admit = (): void => {
|
|
511
578
|
const live = current().github;
|
|
512
|
-
if (
|
|
579
|
+
if (
|
|
580
|
+
live.kind !== "present" ||
|
|
581
|
+
live.value.credentialId !== credentialId ||
|
|
582
|
+
live.value.revision !== revision ||
|
|
583
|
+
live.value.version !== version
|
|
584
|
+
) {
|
|
585
|
+
throw new PersonCredentialWriteRefused();
|
|
586
|
+
}
|
|
513
587
|
};
|
|
514
588
|
inventory(environment, assignment, "github", credentialId, withStaged(TASK_GH_HOSTS_PATH, TASK_GH_GITCONFIG_PATH));
|
|
515
589
|
const hosts = await run(environment, ["/bin/sh", "-c", GH_LOGIN_SCRIPT], `${token}\n`, "gh auth login", admit, "root");
|
|
@@ -548,10 +622,17 @@ export function writeTaskSshKey(environment: Environment, assignment: TaskAssign
|
|
|
548
622
|
const slot = current().ssh;
|
|
549
623
|
if (slot.kind === "absent") return false;
|
|
550
624
|
if (slot.kind === "unavailable") throw new CredentialLeaseUnavailableError("the SSH key could not be read");
|
|
551
|
-
const { credentialId, revision, privateKey, publicKey } = slot.value;
|
|
625
|
+
const { credentialId, revision, version, privateKey, publicKey } = slot.value;
|
|
552
626
|
const admit = (): void => {
|
|
553
627
|
const live = current().ssh;
|
|
554
|
-
if (
|
|
628
|
+
if (
|
|
629
|
+
live.kind !== "present" ||
|
|
630
|
+
live.value.credentialId !== credentialId ||
|
|
631
|
+
live.value.revision !== revision ||
|
|
632
|
+
live.value.version !== version
|
|
633
|
+
) {
|
|
634
|
+
throw new PersonCredentialWriteRefused();
|
|
635
|
+
}
|
|
555
636
|
};
|
|
556
637
|
inventory(
|
|
557
638
|
environment,
|
|
@@ -589,6 +670,33 @@ export function writeTaskSshKey(environment: Environment, assignment: TaskAssign
|
|
|
589
670
|
});
|
|
590
671
|
}
|
|
591
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Reconcile the task-visible person credentials in their coupled order.
|
|
675
|
+
* GitHub chooses HTTPS and removes SSH transport; SSH then observes whether
|
|
676
|
+
* GitHub is present and either keeps HTTPS or restores SSH transport. Every
|
|
677
|
+
* individual exec remains fenced by the exact run and material version.
|
|
678
|
+
* Absent lanes rely on the coordinator having removed their inventoried
|
|
679
|
+
* files. When GitHub is removed, callers must select the surviving SSH lane
|
|
680
|
+
* even if its credential has not changed, so transport is restored too.
|
|
681
|
+
*/
|
|
682
|
+
export async function writeTaskPersonCredentials(
|
|
683
|
+
environment: Environment,
|
|
684
|
+
assignment: TaskAssignment,
|
|
685
|
+
lanes: readonly PersonCredentialLane[] = ["github", "ssh"],
|
|
686
|
+
signal?: AbortSignal,
|
|
687
|
+
): Promise<void> {
|
|
688
|
+
const selected = new Set(lanes);
|
|
689
|
+
for (const [lane, write] of [
|
|
690
|
+
["github", writeTaskGithub],
|
|
691
|
+
["ssh", writeTaskSshKey],
|
|
692
|
+
] as const) {
|
|
693
|
+
if (!selected.has(lane)) continue;
|
|
694
|
+
signal?.throwIfAborted();
|
|
695
|
+
await write(environment, assignment);
|
|
696
|
+
}
|
|
697
|
+
signal?.throwIfAborted();
|
|
698
|
+
}
|
|
699
|
+
|
|
592
700
|
function sameAssignment(a: TaskAssignment, b: TaskAssignment): boolean {
|
|
593
701
|
return a.taskId === b.taskId && a.runEpoch === b.runEpoch;
|
|
594
702
|
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -202,7 +202,12 @@ import {
|
|
|
202
202
|
rememberLeasedEngineRuntime,
|
|
203
203
|
} from "./credential-cleanup-inventory";
|
|
204
204
|
import { leasedMcpAclEntries } from "./credential-consumers/mcp";
|
|
205
|
-
import {
|
|
205
|
+
import {
|
|
206
|
+
CREDENTIAL_FREE_KINDS,
|
|
207
|
+
ENGINE_ENV_CREDENTIAL_KEYS,
|
|
208
|
+
LEASED_ENGINE_KINDS,
|
|
209
|
+
MANAGED_ENGINE_KIND,
|
|
210
|
+
} from "../src/cloud-credentials";
|
|
206
211
|
import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
|
|
207
212
|
import {
|
|
208
213
|
reactivateTaskGatewayTask,
|
|
@@ -232,6 +237,7 @@ import {
|
|
|
232
237
|
MAX_AGENT_ID_CHARS,
|
|
233
238
|
MAX_SECRETARY_DISPATCH_ID_CHARS,
|
|
234
239
|
SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
|
|
240
|
+
isBuiltInAstronaut,
|
|
235
241
|
} from "../src/protocol";
|
|
236
242
|
import {
|
|
237
243
|
isEnvironmentTransportProvider,
|
|
@@ -249,6 +255,10 @@ export type HostEventSubscriber = (event: HostEvent) => void;
|
|
|
249
255
|
const SECRETARY_SESSION_ATTACH_KEY = "secretary-normal-tools-v1";
|
|
250
256
|
const RESTRICTED_SECRETARY_SESSION_ATTACH_KEY =
|
|
251
257
|
"secretary-communicator-v1";
|
|
258
|
+
// ADR-157: the built-in Astronaut's runner was launched without native
|
|
259
|
+
// subagents. Its own generation keeps a normal-tools runner (or an Astronaut
|
|
260
|
+
// launched before the block) from ever being attached in its place.
|
|
261
|
+
const ASTRONAUT_SESSION_ATTACH_KEY = "astronaut-no-subagents-v1";
|
|
252
262
|
|
|
253
263
|
// ---------------------------------------------------------------------------
|
|
254
264
|
// Channel — one task's live conversation.
|
|
@@ -554,14 +564,6 @@ const LEASED_CREDENTIAL_BLANKS: Readonly<Record<string, string>> = Object.freeze
|
|
|
554
564
|
[...new Set(Object.values(ENGINE_ENV_CREDENTIAL_KEYS).flat())].map((name) => [name, ""]),
|
|
555
565
|
),
|
|
556
566
|
);
|
|
557
|
-
/** ADR-128: the Managed Engine. It needs no AI account (it runs on Uai's
|
|
558
|
-
* gateway), but its agent's task token is its bearer: on a leased task its
|
|
559
|
-
* launches are admitted as that token (ADR-154 §6). */
|
|
560
|
-
const MANAGED_ENGINE_KIND = "uai";
|
|
561
|
-
/** ADR-154 §10: kinds whose agents carry no credential at all; every other
|
|
562
|
-
* kind outside the leased lanes (Kimi) stays down on a leased task until
|
|
563
|
-
* its lane exists. */
|
|
564
|
-
const CREDENTIAL_FREE_KINDS: ReadonlySet<string> = new Set(["mock"]);
|
|
565
567
|
/** ADR-154 §8: how soon a failed rewrite of a task's account homes is tried
|
|
566
568
|
* again. */
|
|
567
569
|
const LEASED_SYNC_RETRY_MS = 30_000;
|
|
@@ -634,11 +636,6 @@ function sameLeasedIdentity(a: LeasedIdentity, b: LeasedIdentity): boolean {
|
|
|
634
636
|
return a.subject === b.subject && a.runEpoch === b.runEpoch;
|
|
635
637
|
}
|
|
636
638
|
|
|
637
|
-
/** The engine kinds whose agents run on an AI account from the lease. */
|
|
638
|
-
const LEASED_ENGINE_KINDS: ReadonlySet<string> = new Set([
|
|
639
|
-
...Object.keys(ENGINE_ENV_CREDENTIAL_KEYS),
|
|
640
|
-
...Object.keys(ENGINE_CREDENTIAL_FILES),
|
|
641
|
-
]);
|
|
642
639
|
/** A burned respawn budget resets after this quiet period (see reconcile). */
|
|
643
640
|
const RESPAWN_COOLDOWN_MS = 10 * 60_000;
|
|
644
641
|
/** ADR-053: gap between browser-install retries. Ensures fire every couple of
|
|
@@ -2591,11 +2588,22 @@ export class Orchestrator {
|
|
|
2591
2588
|
}
|
|
2592
2589
|
}
|
|
2593
2590
|
|
|
2594
|
-
/**
|
|
2591
|
+
/** The engine-enforced launch profile for one agent, if any. */
|
|
2595
2592
|
private executionProfileFor(
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
): "communicator" | undefined {
|
|
2593
|
+
channel: Channel,
|
|
2594
|
+
agentId: string,
|
|
2595
|
+
): "communicator" | "astronaut" | undefined {
|
|
2596
|
+
// ADR-157: the built-in Astronaut keeps normal tools but runs without
|
|
2597
|
+
// native subagents, so every helper it uses is an accounted crew agent.
|
|
2598
|
+
// The adapter factory refuses an engine that cannot enforce that.
|
|
2599
|
+
if (
|
|
2600
|
+
isBuiltInAstronaut(
|
|
2601
|
+
channel,
|
|
2602
|
+
channel.roster.find((agent) => agent.id === agentId),
|
|
2603
|
+
)
|
|
2604
|
+
) {
|
|
2605
|
+
return "astronaut";
|
|
2606
|
+
}
|
|
2599
2607
|
// Secretary is an organisational role, not a sandbox. The communicator
|
|
2600
2608
|
// profile remains implemented by adapters for a future explicit opt-in,
|
|
2601
2609
|
// but no task gets tool denial merely by selecting Secretary mode.
|
|
@@ -2611,18 +2619,22 @@ export class Orchestrator {
|
|
|
2611
2619
|
// The restricted profile is dormant today, but giving it the normal-tools
|
|
2612
2620
|
// generation would make a future opt-out able to attach a runner launched
|
|
2613
2621
|
// under tool denial.
|
|
2622
|
+
const profile = this.executionProfileFor(channel, agent.id);
|
|
2614
2623
|
const policy = this.isSecretary(channel, agent.id)
|
|
2615
|
-
?
|
|
2624
|
+
? profile === "communicator"
|
|
2616
2625
|
? RESTRICTED_SECRETARY_SESSION_ATTACH_KEY
|
|
2617
|
-
:
|
|
2626
|
+
: profile === "astronaut"
|
|
2627
|
+
? ASTRONAUT_SESSION_ATTACH_KEY
|
|
2628
|
+
: SECRETARY_SESSION_ATTACH_KEY
|
|
2618
2629
|
: undefined;
|
|
2619
2630
|
const model = agent.model ?? "";
|
|
2620
2631
|
const effort = agent.effort ?? "";
|
|
2632
|
+
const generation = agent.tokenGeneration ?? "";
|
|
2621
2633
|
// Preserve legacy null/policy-only attachment for agents that choose no
|
|
2622
2634
|
// launch flags. Explicit model/effort gets a stable key so a host restart
|
|
2623
2635
|
// cannot attach a runner created under different settings.
|
|
2624
2636
|
const envMode = hostTaskEnvMode(task);
|
|
2625
|
-
if (envMode === "baked" && !model && !effort) return policy;
|
|
2637
|
+
if (envMode === "baked" && !model && !effort && !generation) return policy;
|
|
2626
2638
|
return JSON.stringify([
|
|
2627
2639
|
envMode === "baked" ? "roster-launch-v1" : "roster-launch-v2",
|
|
2628
2640
|
...(envMode === "loader" ? [envMode] : []),
|
|
@@ -2630,6 +2642,10 @@ export class Orchestrator {
|
|
|
2630
2642
|
model,
|
|
2631
2643
|
effort,
|
|
2632
2644
|
policy ?? "",
|
|
2645
|
+
// ADR-157 §3: a runner keeps the token it was launched with, so one
|
|
2646
|
+
// launched for an earlier agent under this handle never attaches to a
|
|
2647
|
+
// later one. Absent without a generation, so no existing key changes.
|
|
2648
|
+
...(generation ? [generation] : []),
|
|
2633
2649
|
]);
|
|
2634
2650
|
}
|
|
2635
2651
|
|
|
@@ -2640,9 +2656,11 @@ export class Orchestrator {
|
|
|
2640
2656
|
}
|
|
2641
2657
|
|
|
2642
2658
|
private isPromptLevelSecretary(channel: Channel, agentId: string): boolean {
|
|
2659
|
+
// The Astronaut profile only removes native subagents; the Astronaut is still
|
|
2660
|
+
// a normal-tools, prompt-level Secretary (CLI dispatch, its own token).
|
|
2643
2661
|
return (
|
|
2644
2662
|
this.isSecretary(channel, agentId) &&
|
|
2645
|
-
this.executionProfileFor(channel, agentId)
|
|
2663
|
+
this.executionProfileFor(channel, agentId) !== "communicator"
|
|
2646
2664
|
);
|
|
2647
2665
|
}
|
|
2648
2666
|
|
|
@@ -6435,6 +6453,12 @@ function rosterLaunchChangeReason(
|
|
|
6435
6453
|
if (previous.kind !== next.kind) changed.push("engine");
|
|
6436
6454
|
if ((previous.model ?? "") !== (next.model ?? "")) changed.push("model");
|
|
6437
6455
|
if ((previous.effort ?? "") !== (next.effort ?? "")) changed.push("effort");
|
|
6456
|
+
// ADR-157 §3: the session holds a token minted for its generation, so a new
|
|
6457
|
+
// one is a new agent under the handle (or one an older cloud release lost),
|
|
6458
|
+
// and only a fresh launch carries a token the cloud accepts.
|
|
6459
|
+
if ((previous.tokenGeneration ?? "") !== (next.tokenGeneration ?? "")) {
|
|
6460
|
+
changed.push("token generation");
|
|
6461
|
+
}
|
|
6438
6462
|
return changed.length > 0 ? `${changed.join("/")} change` : null;
|
|
6439
6463
|
}
|
|
6440
6464
|
|
|
@@ -6821,6 +6845,35 @@ export function buildSystemPreamble(
|
|
|
6821
6845
|
'An epoch-ms `{"__uai":"input","ts":…}` line precedes each',
|
|
6822
6846
|
"unchanged CLI frame, so the adjacent pair identifies a wake input.",
|
|
6823
6847
|
];
|
|
6848
|
+
// ADR-157: the built-in Astronaut, holding tasks.agents.manage, runs its own
|
|
6849
|
+
// crew. The cloud refuses the same commands from anyone else, including a
|
|
6850
|
+
// Secretary persona that holds the grant. It always runs under the Astronaut
|
|
6851
|
+
// profile (§7), so its tools are not an ordinary Secretary's.
|
|
6852
|
+
const isAstronaut = isBuiltInAstronaut({ mode, secretaryAgentId }, agent);
|
|
6853
|
+
const crewBrief =
|
|
6854
|
+
isAstronaut && (agent.permissions ?? []).includes("tasks.agents.manage")
|
|
6855
|
+
? [
|
|
6856
|
+
"## Your crew",
|
|
6857
|
+
"",
|
|
6858
|
+
"You choose this task's crew. `node " + CONTAINER_CLI_PATH + " crew list` shows the",
|
|
6859
|
+
"personas you can hire, the engines and models this task can run (with the",
|
|
6860
|
+
"managed engine's prices), the plan's room, and the current roster. Change it",
|
|
6861
|
+
"one agent at a time, always with a reason the humans will read:",
|
|
6862
|
+
"- `crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] --why \"…\"`",
|
|
6863
|
+
"- `crew model @agent [--engine <kind>] [--model <m>] [--effort <e>] --why \"…\"`",
|
|
6864
|
+
" (at least one of the three; `default` as the model or effort clears it to",
|
|
6865
|
+
" the engine's own default)",
|
|
6866
|
+
"- `crew remove @agent --why \"…\"`",
|
|
6867
|
+
"Use the smallest crew the job needs, often none: do small work yourself and",
|
|
6868
|
+
"hire only when parallel work or a different skill or model pays for itself.",
|
|
6869
|
+
"A new agent is idle until you `dispatch` to it, so brief it then. Remove an",
|
|
6870
|
+
"agent when its part is done; its handle is not reused, so hiring that",
|
|
6871
|
+
"persona again gives it a new one. A model change restarts that agent and drops",
|
|
6872
|
+
"its prompt cache, so change models between steps, not mid-step. Your own",
|
|
6873
|
+
"engine and model are the human's choice; you cannot change or remove yourself.",
|
|
6874
|
+
"",
|
|
6875
|
+
]
|
|
6876
|
+
: [];
|
|
6824
6877
|
const secretaryRoleBrief = isSecretary
|
|
6825
6878
|
? [
|
|
6826
6879
|
"## Secretary role",
|
|
@@ -6831,9 +6884,20 @@ export function buildSystemPreamble(
|
|
|
6831
6884
|
"Synthesize the",
|
|
6832
6885
|
"crew's replies for the human instead of forwarding a pile of raw updates.",
|
|
6833
6886
|
"",
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6887
|
+
...(isAstronaut
|
|
6888
|
+
? [
|
|
6889
|
+
"Your role is prompt-guided. You have the normal tools, approved MCP",
|
|
6890
|
+
"connections and persona permissions of any task agent, except native",
|
|
6891
|
+
"subagents: helpers your engine starts on its own would work outside the",
|
|
6892
|
+
"crew, unpriced and unseen by the humans, so they are switched off. Work",
|
|
6893
|
+
"for another agent goes to a crew agent through `dispatch`. Your default",
|
|
6894
|
+
"job is to communicate,",
|
|
6895
|
+
]
|
|
6896
|
+
: [
|
|
6897
|
+
"Your Secretary role is prompt-guided, not tool-restricted. You have the",
|
|
6898
|
+
"same normal tools, approved MCP connections, model choice, and persona",
|
|
6899
|
+
"permissions as any other task agent. Your default job is to communicate,",
|
|
6900
|
+
]),
|
|
6837
6901
|
"coordinate, and dispatch implementation work rather than doing it yourself.",
|
|
6838
6902
|
"If the human explicitly asks YOU to make a change, you may use those tools",
|
|
6839
6903
|
"and do it directly; otherwise hand mutations to a crew agent.",
|
|
@@ -7072,6 +7136,7 @@ export function buildSystemPreamble(
|
|
|
7072
7136
|
...sessionInboxBrief,
|
|
7073
7137
|
...(sessionInboxBrief.length > 0 ? [""] : []),
|
|
7074
7138
|
...secretaryRoleBrief,
|
|
7139
|
+
...crewBrief,
|
|
7075
7140
|
...channelConventionsBrief,
|
|
7076
7141
|
...(replyCardBrief.length > 0 ? ["", ...replyCardBrief] : []),
|
|
7077
7142
|
...checkInTranscriptBrief,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore GitHub/SSH files when a running task's cloud credentials become
|
|
3
|
+
* usable, after recovery or a live change. The persisted provider locator
|
|
4
|
+
* is the only runtime authority; a missing runtime is a retryable failure.
|
|
5
|
+
* Fresh task-up uses the same writer on its newly provisioned handle.
|
|
6
|
+
*/
|
|
7
|
+
import type { HostTask } from "../db/schema";
|
|
8
|
+
import { CredentialLeaseUnavailableError, type TaskAssignment } from "./cloud-credential-leases";
|
|
9
|
+
import { writeTaskPersonCredentials, type PersonCredentialLane } from "./credential-consumers/github";
|
|
10
|
+
import type { TaskWriteEnvironment } from "./credential-consumers/task-files";
|
|
11
|
+
import { getHostTask, readTaskRunEpochState } from "./runtime-state";
|
|
12
|
+
import { reconstructPersistedTaskEnvironment } from "./task-environment";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The composer retries a rejection while the same material is current and
|
|
16
|
+
* aborts this signal before cleanup or takeover. In particular, a fresh
|
|
17
|
+
* task that is still starting must retry: a rotation after its runtime-ready
|
|
18
|
+
* writes but before it is marked running must not be silently missed.
|
|
19
|
+
*/
|
|
20
|
+
export async function reconcileRunningTaskPersonCredentials(
|
|
21
|
+
assignment: Readonly<TaskAssignment>,
|
|
22
|
+
lanes: readonly PersonCredentialLane[],
|
|
23
|
+
signal: AbortSignal,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const current = (): HostTask => {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
const task = getHostTask(assignment.taskId);
|
|
28
|
+
const run = readTaskRunEpochState(assignment.taskId);
|
|
29
|
+
if (
|
|
30
|
+
task === null ||
|
|
31
|
+
task.statusMirror !== "running" ||
|
|
32
|
+
task.runEpoch !== assignment.runEpoch ||
|
|
33
|
+
run === null ||
|
|
34
|
+
!run.assignmentOpen ||
|
|
35
|
+
run.highestRunEpoch !== assignment.runEpoch ||
|
|
36
|
+
run.runtimeEstablishedEpoch !== assignment.runEpoch
|
|
37
|
+
) {
|
|
38
|
+
throw new CredentialLeaseUnavailableError("the task's running credential assignment is not current");
|
|
39
|
+
}
|
|
40
|
+
return task;
|
|
41
|
+
};
|
|
42
|
+
const task = current();
|
|
43
|
+
const assertCurrent = (): void => {
|
|
44
|
+
const live = current();
|
|
45
|
+
if (
|
|
46
|
+
live.environmentProvider !== task.environmentProvider ||
|
|
47
|
+
live.environmentLocator !== task.environmentLocator ||
|
|
48
|
+
live.envMode !== task.envMode
|
|
49
|
+
) {
|
|
50
|
+
throw new CredentialLeaseUnavailableError("the task's credential runtime has changed");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const environment = await reconstructPersistedTaskEnvironment(task);
|
|
54
|
+
assertCurrent();
|
|
55
|
+
if (environment === null) {
|
|
56
|
+
throw new CredentialLeaseUnavailableError("the task's credential runtime is unavailable");
|
|
57
|
+
}
|
|
58
|
+
// Keep the exact running assignment and cancellation at each provider's
|
|
59
|
+
// last exec boundary too: reconstruction or address lookup can suspend
|
|
60
|
+
// while Stop or a new run replaces the runtime.
|
|
61
|
+
const writable: TaskWriteEnvironment = {
|
|
62
|
+
descriptor: environment.descriptor,
|
|
63
|
+
exec: (request) => {
|
|
64
|
+
assertCurrent();
|
|
65
|
+
return environment.exec({
|
|
66
|
+
...request,
|
|
67
|
+
admit: () => {
|
|
68
|
+
assertCurrent();
|
|
69
|
+
request.admit?.();
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
await writeTaskPersonCredentials(writable, assignment, lanes, signal);
|
|
75
|
+
assertCurrent();
|
|
76
|
+
}
|
package/lib/task-token.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface TaskTokenPayload {
|
|
|
11
11
|
userId: string;
|
|
12
12
|
permissions: string[];
|
|
13
13
|
agentId?: string;
|
|
14
|
+
/** ADR-157 §3: the agent's roster generation; the cloud requires it to match. */
|
|
15
|
+
generation?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const PREFIX = "uai_";
|
|
@@ -31,6 +33,7 @@ export function signTaskToken(payload: TaskTokenPayload, secret: string): string
|
|
|
31
33
|
u: payload.userId,
|
|
32
34
|
p: payload.permissions,
|
|
33
35
|
...(payload.agentId ? { a: payload.agentId } : {}),
|
|
36
|
+
...(payload.generation ? { g: payload.generation } : {}),
|
|
34
37
|
iat: Math.floor(Date.now() / 1000),
|
|
35
38
|
}),
|
|
36
39
|
),
|
package/package.json
CHANGED
package/src/cloud-credentials.ts
CHANGED
|
@@ -286,6 +286,31 @@ export const ENGINE_CREDENTIAL_FILES: Readonly<Record<string, readonly string[]>
|
|
|
286
286
|
opencode: ["auth.json"],
|
|
287
287
|
};
|
|
288
288
|
|
|
289
|
+
/** The engine kinds whose agents run on an AI account from the lease. */
|
|
290
|
+
export const LEASED_ENGINE_KINDS: ReadonlySet<string> = new Set([
|
|
291
|
+
...Object.keys(ENGINE_ENV_CREDENTIAL_KEYS),
|
|
292
|
+
...Object.keys(ENGINE_CREDENTIAL_FILES),
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
/** ADR-128: the Managed Engine. It needs no AI account (it runs on Uai's
|
|
296
|
+
* gateway), but its agent's task token is its bearer: on a leased task its
|
|
297
|
+
* launches are admitted as that token (ADR-154 §6). */
|
|
298
|
+
export const MANAGED_ENGINE_KIND = "uai";
|
|
299
|
+
|
|
300
|
+
/** ADR-154 §10: kinds whose agents carry no credential at all; every other
|
|
301
|
+
* kind outside the leased lanes (Kimi) stays down on a leased task until
|
|
302
|
+
* its lane exists. */
|
|
303
|
+
export const CREDENTIAL_FREE_KINDS: ReadonlySet<string> = new Set(["mock"]);
|
|
304
|
+
|
|
305
|
+
/** ADR-154 §10: every kind a task on cloud credentials can run, and so every
|
|
306
|
+
* kind a runner on them advertises. The Managed Engine is one, though it
|
|
307
|
+
* leases nothing. Kimi, with no lane yet, is not. */
|
|
308
|
+
export const CLOUD_CREDENTIAL_KINDS: ReadonlySet<string> = new Set([
|
|
309
|
+
...LEASED_ENGINE_KINDS,
|
|
310
|
+
MANAGED_ENGINE_KIND,
|
|
311
|
+
...CREDENTIAL_FREE_KINDS,
|
|
312
|
+
]);
|
|
313
|
+
|
|
289
314
|
interface LeasedValueBase {
|
|
290
315
|
v: 1;
|
|
291
316
|
part: "lease";
|
package/src/index.ts
CHANGED
|
@@ -15,14 +15,14 @@ import {
|
|
|
15
15
|
whileOnOwnCredentials,
|
|
16
16
|
type LegacyStartPermit,
|
|
17
17
|
} from "../lib/cloud-credential-host";
|
|
18
|
-
import {
|
|
18
|
+
import type { TaskAssignment } from "../lib/cloud-credential-leases";
|
|
19
19
|
import {
|
|
20
20
|
StartInterruptedError,
|
|
21
21
|
type CloudCredentialRuntime,
|
|
22
22
|
type TaskCredentialStart,
|
|
23
23
|
type TaskUpHold,
|
|
24
24
|
} from "../lib/cloud-credential-runtime";
|
|
25
|
-
import {
|
|
25
|
+
import { writeTaskPersonCredentials } from "../lib/credential-consumers/github";
|
|
26
26
|
import { agentClisReady, ensureStandardImage } from "../lib/standard-image";
|
|
27
27
|
import { cloneRepo } from "../lib/repo-clone";
|
|
28
28
|
import { handleFilesOp } from "../lib/shared-files";
|
|
@@ -315,10 +315,7 @@ function leasedTaskUp(
|
|
|
315
315
|
signal,
|
|
316
316
|
);
|
|
317
317
|
}
|
|
318
|
-
|
|
319
|
-
if (signal.aborted) throw new CredentialLeaseUnavailableError("the task's credentials were withdrawn");
|
|
320
|
-
await write(environment, start.assignment);
|
|
321
|
-
}
|
|
318
|
+
await writeTaskPersonCredentials(environment, start.assignment, undefined, signal);
|
|
322
319
|
},
|
|
323
320
|
...(filesV1 ? { filesV1: true as const } : {}),
|
|
324
321
|
};
|