@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.
@@ -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, the same credential revision, not sealed. A cleanup seals
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
- interface TaskPersonCredentials {
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: { github: Slot<LeasedGithub>; ssh: Slot<LeasedSshKey> };
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<T extends GithubLeasedValue | SshLeasedValue, V extends { credentialId: string; revision: string }>(
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 (credential.availability !== "available" || !credential.material) return { kind: "unavailable", ...identity };
119
- return { kind: "present", value: { ...identity, ...value(credential.material) } as V };
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): { github: Slot<LeasedGithub>; ssh: Slot<LeasedSshKey> } {
147
+ function slotsOf(view: TaskCredentialView): PersonCredentialSlots {
123
148
  return {
124
- github: slotOf<GithubLeasedValue, LeasedGithub>(view.github, (material) => ({ token: material.token })),
125
- ssh: slotOf<SshLeasedValue, LeasedSshKey>(view.ssh, (material) => ({
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
- /** The same credential at the same revision, both readable. */
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
- return (
135
- a.kind === "present" &&
136
- b.kind === "present" &&
137
- a.value.credentialId === b.value.credentialId &&
138
- a.value.revision === b.value.revision
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, the revision or the gate moved. */
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 revision is still the task's.
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 (live.kind !== "present" || live.value.revision !== revision) throw new PersonCredentialWriteRefused();
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 (live.kind !== "present" || live.value.revision !== revision) throw new PersonCredentialWriteRefused();
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
  }
@@ -202,7 +202,12 @@ import {
202
202
  rememberLeasedEngineRuntime,
203
203
  } from "./credential-cleanup-inventory";
204
204
  import { leasedMcpAclEntries } from "./credential-consumers/mcp";
205
- import { ENGINE_CREDENTIAL_FILES, ENGINE_ENV_CREDENTIAL_KEYS } from "../src/cloud-credentials";
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
- /** Reserved seam for a future explicit restricted-role control. */
2591
+ /** The engine-enforced launch profile for one agent, if any. */
2595
2592
  private executionProfileFor(
2596
- _channel: Channel,
2597
- _agentId: string,
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
- ? this.executionProfileFor(channel, agent.id) === "communicator"
2624
+ ? profile === "communicator"
2616
2625
  ? RESTRICTED_SECRETARY_SESSION_ATTACH_KEY
2617
- : SECRETARY_SESSION_ATTACH_KEY
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) === undefined
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
- "Your Secretary role is prompt-guided, not tool-restricted. You have the",
6835
- "same normal tools, approved MCP connections, model choice, and persona",
6836
- "permissions as any other task agent. Your default job is to communicate,",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.153",
3
+ "version": "0.9.155",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -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 { CredentialLeaseUnavailableError, type TaskAssignment } from "../lib/cloud-credential-leases";
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 { writeTaskGithub, writeTaskSshKey } from "../lib/credential-consumers/github";
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
- for (const write of [writeTaskGithub, writeTaskSshKey]) {
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
  };