@runuai/host 0.9.154 → 0.9.156

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 CHANGED
@@ -210,7 +210,14 @@ export function agentCliEnv(
210
210
  return {
211
211
  ...runtimeAuthority,
212
212
  UAI_TASK_TOKEN: signTaskToken(
213
- { taskId, userId: ownerUserId, permissions, agentId: agent.id },
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"));
@@ -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
- : CLAUDE_FULL_ACCESS_ARGS),
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).
@@ -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
  });
@@ -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 its
92
- * own, so availability and the fallback above say nothing: the kinds the
93
- * cloud can lease an account for are advertised, and only those. Null while
94
- * the runner runs its tasks on its own credentials. Installed by main.ts.
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
 
@@ -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
  });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Bounded recovery for Docker's local-network address pool.
3
+ *
4
+ * Compose allocates one bridge network per local task. A task that fails after
5
+ * creating its network can remain resumable in `error`, and Docker retains the
6
+ * empty network. Enough such networks eventually make every later task fail
7
+ * with "all predefined address pools have been fully subnetted".
8
+ *
9
+ * This sweep is deliberately narrower than `docker network prune`:
10
+ *
11
+ * - the exact task/project identity must be present in trusted host state;
12
+ * - the task must not be starting or running;
13
+ * - Docker must independently report no attached container; and
14
+ * - deletion happens inside that task's lifecycle slot, so Resume/Stop cannot
15
+ * interleave with the proof.
16
+ *
17
+ * Any unreadable or malformed observation means "do nothing", never
18
+ * permission to delete.
19
+ */
20
+
21
+ import type { HostTask } from "../db/schema";
22
+ import { dockerCli, type DockerResult } from "./docker-exec";
23
+ import { getHostTask } from "./runtime-state";
24
+ import { isSafeHostTaskId } from "./task-identity";
25
+ import { isTerminal } from "./task-status";
26
+ import { parseTaskEnvironmentLocator } from "./task-environment/types";
27
+
28
+ const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
29
+ const NETWORK_LIST_FORMAT =
30
+ `{{.ID}}\t{{.Name}}\t{{.Label "${COMPOSE_PROJECT_LABEL}"}}`;
31
+ const MAX_NETWORK_LIST_BYTES = 2 * 1024 * 1024;
32
+ const MAX_NETWORK_INSPECT_BYTES = 1024 * 1024;
33
+ const DOCKER_TIMEOUT_MS = 10_000;
34
+
35
+ interface ComposeNetworkCandidate {
36
+ id: string;
37
+ name: string;
38
+ composeProject: string;
39
+ taskId: string;
40
+ }
41
+
42
+ export interface ComposeNetworkLifecycle {
43
+ runTaskLifecycle<T>(
44
+ taskId: string,
45
+ operation: () => Promise<T>,
46
+ ): Promise<T>;
47
+ }
48
+
49
+ export interface ComposeNetworkRecoveryDeps {
50
+ docker(args: string[], options?: {
51
+ timeoutMs?: number;
52
+ maxOutputBytes?: number;
53
+ }): Promise<DockerResult>;
54
+ task(taskId: string): HostTask | null;
55
+ }
56
+
57
+ export interface ComposeNetworkRecoveryResult {
58
+ inventory: "complete" | "unavailable" | "invalid";
59
+ candidates: number;
60
+ removed: number;
61
+ }
62
+
63
+ const defaultDeps: ComposeNetworkRecoveryDeps = {
64
+ docker: dockerCli,
65
+ task: getHostTask,
66
+ };
67
+
68
+ function parseCandidate(
69
+ line: string,
70
+ ): ComposeNetworkCandidate | "unrelated" | null {
71
+ const fields = line.split("\t");
72
+ if (fields.length !== 3) return null;
73
+ const [id, name, composeProject] = fields as [string, string, string];
74
+ if (!/^[a-f0-9]{12,64}$/.test(id)) return null;
75
+ if (!composeProject.startsWith("task-")) return "unrelated";
76
+ const taskId = composeProject.slice("task-".length);
77
+ if (!isSafeHostTaskId(taskId) || taskId !== taskId.toLowerCase()) return null;
78
+ if (name !== `${composeProject}_default`) return null;
79
+ return { id, name, composeProject, taskId };
80
+ }
81
+
82
+ function parseCandidates(stdout: string): ComposeNetworkCandidate[] | null {
83
+ const candidates: ComposeNetworkCandidate[] = [];
84
+ const ids = new Set<string>();
85
+ for (const line of stdout.split(/\r?\n/)) {
86
+ if (line.length === 0) continue;
87
+ const candidate = parseCandidate(line);
88
+ if (candidate === "unrelated") continue;
89
+ if (candidate === null || ids.has(candidate.id)) return null;
90
+ ids.add(candidate.id);
91
+ candidates.push(candidate);
92
+ }
93
+ return candidates;
94
+ }
95
+
96
+ function taskOwnsReclaimableNetwork(
97
+ task: HostTask | null,
98
+ candidate: ComposeNetworkCandidate,
99
+ ): boolean {
100
+ if (
101
+ task === null ||
102
+ task.taskId !== candidate.taskId ||
103
+ (task.environmentProvider !== null &&
104
+ task.environmentProvider !== "docker-compose") ||
105
+ (task.statusMirror !== "error" &&
106
+ task.statusMirror !== "stopped" &&
107
+ !(typeof task.statusMirror === "string" && isTerminal(task.statusMirror)))
108
+ ) {
109
+ return false;
110
+ }
111
+ if (task.composeProject === candidate.composeProject) return true;
112
+ if (!task.environmentLocator) return false;
113
+ try {
114
+ const locator = parseTaskEnvironmentLocator(task.environmentLocator);
115
+ if (
116
+ locator.schemaVersion !== 1 ||
117
+ locator.provider !== "docker-compose" ||
118
+ !locator.value ||
119
+ typeof locator.value !== "object" ||
120
+ Array.isArray(locator.value)
121
+ ) {
122
+ return false;
123
+ }
124
+ const value = locator.value as Record<string, unknown>;
125
+ return (
126
+ value.schemaVersion === 2 &&
127
+ value.taskId === candidate.taskId &&
128
+ value.composeProject === candidate.composeProject &&
129
+ value.containerName === `${candidate.composeProject}-app-1`
130
+ );
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ function inspectedEmptyNetwork(
137
+ stdout: string,
138
+ candidate: ComposeNetworkCandidate,
139
+ ): { fullId: string } | null {
140
+ let parsed: unknown;
141
+ try {
142
+ parsed = JSON.parse(stdout);
143
+ } catch {
144
+ return null;
145
+ }
146
+ if (!Array.isArray(parsed) || parsed.length !== 1) return null;
147
+ const network = parsed[0];
148
+ if (!network || typeof network !== "object" || Array.isArray(network)) {
149
+ return null;
150
+ }
151
+ const record = network as Record<string, unknown>;
152
+ const labels = record.Labels;
153
+ const containers = record.Containers;
154
+ if (
155
+ typeof record.Id !== "string" ||
156
+ !/^[a-f0-9]{64}$/.test(record.Id) ||
157
+ !record.Id.startsWith(candidate.id) ||
158
+ record.Name !== candidate.name ||
159
+ record.Driver !== "bridge" ||
160
+ record.Scope !== "local" ||
161
+ !labels ||
162
+ typeof labels !== "object" ||
163
+ Array.isArray(labels) ||
164
+ (labels as Record<string, unknown>)[COMPOSE_PROJECT_LABEL] !==
165
+ candidate.composeProject ||
166
+ !containers ||
167
+ typeof containers !== "object" ||
168
+ Array.isArray(containers) ||
169
+ Object.keys(containers).length !== 0
170
+ ) {
171
+ return null;
172
+ }
173
+ return { fullId: record.Id };
174
+ }
175
+
176
+ async function reclaimCandidate(
177
+ candidate: ComposeNetworkCandidate,
178
+ deps: ComposeNetworkRecoveryDeps,
179
+ ): Promise<boolean> {
180
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
181
+ return false;
182
+ }
183
+ const inspected = await deps.docker(
184
+ ["network", "inspect", candidate.id],
185
+ {
186
+ timeoutMs: DOCKER_TIMEOUT_MS,
187
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
188
+ },
189
+ );
190
+ if (inspected.status !== 0 || inspected.outputTruncated) return false;
191
+ const empty = inspectedEmptyNetwork(inspected.stdout, candidate);
192
+ if (empty === null) return false;
193
+
194
+ // `.Containers` is the network's own endpoint inventory. Independently ask
195
+ // Docker's all-container inventory as a second positive emptiness proof.
196
+ const attached = await deps.docker(
197
+ ["ps", "-aq", "--filter", `network=${empty.fullId}`],
198
+ {
199
+ timeoutMs: DOCKER_TIMEOUT_MS,
200
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
201
+ },
202
+ );
203
+ if (
204
+ attached.status !== 0 ||
205
+ attached.outputTruncated ||
206
+ attached.stdout.trim().length !== 0
207
+ ) {
208
+ return false;
209
+ }
210
+
211
+ const removed = await deps.docker(["network", "rm", empty.fullId], {
212
+ timeoutMs: DOCKER_TIMEOUT_MS,
213
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
214
+ });
215
+ return removed.status === 0 && !removed.outputTruncated;
216
+ }
217
+
218
+ /**
219
+ * Remove only positively-owned, empty task networks. This is best-effort host
220
+ * hygiene: an unavailable inventory never blocks a task that may still fit in
221
+ * Docker's pool, and it never authorizes deletion.
222
+ */
223
+ export async function reclaimUnusedUaiComposeNetworks(
224
+ lifecycle: ComposeNetworkLifecycle,
225
+ deps: ComposeNetworkRecoveryDeps = defaultDeps,
226
+ ): Promise<ComposeNetworkRecoveryResult> {
227
+ const listed = await deps.docker(
228
+ [
229
+ "network",
230
+ "ls",
231
+ "--filter",
232
+ `label=${COMPOSE_PROJECT_LABEL}`,
233
+ "--format",
234
+ NETWORK_LIST_FORMAT,
235
+ ],
236
+ {
237
+ timeoutMs: DOCKER_TIMEOUT_MS,
238
+ maxOutputBytes: MAX_NETWORK_LIST_BYTES,
239
+ },
240
+ );
241
+ if (listed.status !== 0 || listed.outputTruncated) {
242
+ return { inventory: "unavailable", candidates: 0, removed: 0 };
243
+ }
244
+ const candidates = parseCandidates(listed.stdout);
245
+ if (candidates === null) {
246
+ return { inventory: "invalid", candidates: 0, removed: 0 };
247
+ }
248
+
249
+ let removed = 0;
250
+ for (const candidate of candidates) {
251
+ // Avoid queueing behind a live start merely to rediscover in-slot that it
252
+ // is not reclaimable. This observation grants no authority; the complete
253
+ // ownership/status check is repeated after the lifecycle slot is held.
254
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
255
+ continue;
256
+ }
257
+ await lifecycle.runTaskLifecycle(candidate.taskId, async () => {
258
+ // The trusted row and Docker evidence are deliberately read only after
259
+ // entering the candidate task's lifecycle slot.
260
+ if (await reclaimCandidate(candidate, deps)) removed += 1;
261
+ });
262
+ }
263
+ return { inventory: "complete", candidates: candidates.length, removed };
264
+ }
@@ -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,
@@ -714,6 +714,24 @@ export function standardImageVolumeMaintenanceCommandForTest(
714
714
  `ensure_real_dir "$node_root" node-root; ` +
715
715
  `ensure_real_dir "$shims_root" shims-root; ` +
716
716
  `ensure_real_dir "$corepack_root" corepack-root; ` +
717
+ // Legacy images could seed individual shims as root even though the
718
+ // directory itself was later repaired. asdf 0.14 then prints permission
719
+ // errors while reshim quietly leaves the old executable behind; task
720
+ // materialization can appear successful but the next CLI launch is stale
721
+ // or non-executable. The trusted root maintenance transaction repairs only
722
+ // the flat, regular-file shim set before dropping to node for every asdf
723
+ // mutation. Anything else is ambiguous volume content and fails closed.
724
+ `repair_shim_metadata() { ` +
725
+ `unsafe_shim_entry="$(/usr/bin/find "$shims_root" -xdev -mindepth 1 ` +
726
+ `-maxdepth 1 ! -type f -print -quit)"; ` +
727
+ `[ -z "$unsafe_shim_entry" ] || fail unsafe-shim-entry; ` +
728
+ `/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
729
+ `-exec /usr/bin/chown node:node -- {} + ` +
730
+ `|| fail repair-shim-owner; ` +
731
+ `/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
732
+ `-exec /usr/bin/chmod 0755 -- {} + ` +
733
+ `|| fail repair-shim-mode; }; ` +
734
+ `repair_shim_metadata; ` +
717
735
  `candidate_is_safe() { ` +
718
736
  `lexical_candidate="$(/usr/bin/realpath -m -s -- "$candidate")" ` +
719
737
  `|| return 1; [ "$lexical_candidate" = "$node_root/$default_node" ] ` +
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.154",
3
+ "version": "0.9.156",
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>",
@@ -2607,8 +2607,27 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2607
2607
  fi
2608
2608
  else
2609
2609
  step "COMPOSE_UP_FAILED" "docker compose up -d"
2610
- printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
2611
- -f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build >/dev/null
2610
+ compose_up_error=""
2611
+ # Keep the diagnostic in memory only, but bound it: a broken plugin or
2612
+ # daemon must not make task-up retain an arbitrarily large stderr stream.
2613
+ # Docker's allocation error is emitted at the end of Compose's failure.
2614
+ if ! compose_up_error=$(printf '%s\n' "$compose_env_override" \
2615
+ | docker compose -p "$compose_project" \
2616
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build \
2617
+ 2>&1 >/dev/null | tail -c 65536); then
2618
+ [ -z "$compose_up_error" ] || printf '%s\n' "$compose_up_error" >&2
2619
+ require_docker_after_failed_step "docker compose up -d"
2620
+ if printf '%s\n' "$compose_up_error" \
2621
+ | grep -Fq 'all predefined address pools have been fully subnetted'; then
2622
+ emit_err "COMPOSE_UP_FAILED" \
2623
+ "Docker has no subnet left for this task. Uai already reclaimed every empty task network it could prove safe. Close an unused stopped/error task, or enlarge Docker's non-overlapping default address pools and restart Docker, then press Resume." \
2624
+ "docker compose up -d"
2625
+ fi
2626
+ emit_err "COMPOSE_UP_FAILED" \
2627
+ "Uai could not start this task's Docker Compose stack. Fix the Docker error above, then press Resume." \
2628
+ "docker compose up -d"
2629
+ fi
2630
+ unset compose_up_error
2612
2631
  fi
2613
2632
 
2614
2633
  # Rendered YAML is not runtime evidence: Compose could reuse unexpected state,
@@ -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
@@ -126,6 +126,7 @@ import {
126
126
  waitForContainerRuntimeOperational,
127
127
  } from "../lib/container-runtime";
128
128
  import { vnodeAdmissionProblem } from "../lib/vnode-pressure";
129
+ import { reclaimUnusedUaiComposeNetworks } from "../lib/compose-network-recovery";
129
130
  import {
130
131
  machineTasksEnabled,
131
132
  provisionTaskEnvironment,
@@ -616,6 +617,41 @@ async function taskUpOn(
616
617
  );
617
618
  }
618
619
  }
620
+ // Every local Docker task consumes one daemon address-pool subnet. A task
621
+ // that failed after Compose created its network can retain an empty subnet
622
+ // while it waits for Resume. Reclaim only networks whose trusted host row,
623
+ // exact Docker identity and two empty-container inventories all agree. The
624
+ // sweep runs before this task's lifecycle slot so concurrent failed starts
625
+ // can never deadlock while trying to inspect one another.
626
+ if (
627
+ !sidecarMode() &&
628
+ !machineTasksEnabled() &&
629
+ pinnedContainerRuntimeProvider() === "docker"
630
+ ) {
631
+ try {
632
+ const networkRecovery = await reclaimUnusedUaiComposeNetworks(
633
+ orchestrator,
634
+ );
635
+ if (networkRecovery.removed > 0) {
636
+ console.warn(
637
+ `[host-agent] reclaimed ${String(networkRecovery.removed)} empty UAI task ` +
638
+ `network(s) before starting ${input.task.id}`,
639
+ );
640
+ } else if (networkRecovery.inventory === "invalid") {
641
+ console.warn(
642
+ "[host-agent] skipped task-network recovery because Docker returned an invalid network inventory",
643
+ );
644
+ }
645
+ } catch (error) {
646
+ // Best-effort capacity recovery must not block a task when Docker still
647
+ // has room. `docker compose up` remains the authoritative allocation.
648
+ console.warn(
649
+ `[host-agent] task-network recovery was unavailable: ${
650
+ error instanceof Error ? error.message : String(error)
651
+ }`,
652
+ );
653
+ }
654
+ }
619
655
  return orchestrator.runTaskLifecycle(input.task.id, releasingHold(cloud, async () => {
620
656
  // A direct duplicate teardown may still be draining operations that
621
657
  // target this task's stable compose/container names. Never recreate
package/src/main.ts CHANGED
@@ -92,8 +92,7 @@ import { cancelMcpCapture, completeMcpCapture, probeMcpCapture } from "../lib/mc
92
92
  import { captureGithub, mintSsh, readGithubRevision, revokeGithub } from "../lib/person-capture";
93
93
  import { handleMcpOp } from "../lib/mcp-connections";
94
94
  import {
95
- ENGINE_CREDENTIAL_FILES,
96
- ENGINE_ENV_CREDENTIAL_KEYS,
95
+ CLOUD_CREDENTIAL_KINDS,
97
96
  HOST_CLOUD_CREDENTIALS_FEATURE,
98
97
  parseCredentialsActivateResultFrame,
99
98
  runEpochFromWire,
@@ -301,6 +300,7 @@ import {
301
300
  HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
302
301
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
303
302
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
303
+ AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
304
304
  isEngineAccountScope,
305
305
  HOST_LOGS_PROTOCOL_FEATURE,
306
306
  HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
@@ -703,10 +703,6 @@ setCredentialsModeSource(() => credentialsActivation.mode());
703
703
  * so the operator turning them on reaches it within minutes. */
704
704
  const CREDENTIALS_ACTIVATION_RETRY_MS = 5 * 60_000;
705
705
  const CREDENTIALS_ACTIVATION_ANSWER_MS = 60_000;
706
- const LEASED_ENGINE_KINDS: ReadonlySet<string> = new Set([
707
- ...Object.keys(ENGINE_ENV_CREDENTIAL_KEYS),
708
- ...Object.keys(ENGINE_CREDENTIAL_FILES),
709
- ]);
710
706
  // Started here, first fired long after the bridge's state exists.
711
707
  const credentialsActivationTimer = setInterval(() => {
712
708
  if (stopping || !credentialFramesReady) return;
@@ -1398,6 +1394,7 @@ function buildCapabilities(): HostCapabilities {
1398
1394
  HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
1399
1395
  HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
1400
1396
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
1397
+ AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
1401
1398
  // The echo adapter cannot execute the in-task CLI. Advertising typed
1402
1399
  // dispatch in mock mode would let the composer create a Secretary that
1403
1400
  // has no way to wake crew.
@@ -1899,10 +1896,11 @@ function flushStoppedTaskStatuses(socket: WebSocket | null = ws): void {
1899
1896
  // ADR-131 D3: a stored account (any scope) makes a kind advertisable even
1900
1897
  // when the host's own credential is absent — see hasStoredEngineAccounts.
1901
1898
  setAdvertisementFallback((kind) => hasStoredEngineAccounts(kind));
1902
- // ADR-154: on cloud credentials the runner holds none of its own, so a kind
1903
- // is advertised when the cloud can lease an account for it (Kimi, with no
1904
- // lane yet, is not), whatever this process's env says.
1905
- setLeasedAdvertisement(() => (credentialsMode() === "active" ? LEASED_ENGINE_KINDS : null));
1899
+ // ADR-154 §10: on cloud credentials the runner holds none of its own, so it
1900
+ // advertises the kinds a leased task can run, whatever this process's env
1901
+ // says: the leased lanes and the Managed Engine, which leases nothing (Kimi,
1902
+ // with no lane yet, is left out).
1903
+ setLeasedAdvertisement(() => (credentialsMode() === "active" ? CLOUD_CREDENTIAL_KINDS : null));
1906
1904
  onRegistryChange(() => sendCapabilities());
1907
1905
  onContainerRuntimeChange((next, previous) => {
1908
1906
  if (stopping) return;
@@ -5418,6 +5416,12 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
5418
5416
  const perms = row.permissions.filter((p): p is string => typeof p === "string");
5419
5417
  if (perms.length > 0) out.permissions = perms;
5420
5418
  }
5419
+ // ADR-157: the built-in Astronaut's marker, set only by the cloud.
5420
+ if (row.builtIn === "astronaut") out.builtIn = "astronaut";
5421
+ // ADR-157 §3: the generation its task token must carry.
5422
+ if (typeof row.tokenGeneration === "string" && row.tokenGeneration.length > 0) {
5423
+ out.tokenGeneration = row.tokenGeneration;
5424
+ }
5421
5425
  return out;
5422
5426
  });
5423
5427
  }
package/src/protocol.ts CHANGED
@@ -82,6 +82,14 @@ export type HostCommandResult<T> =
82
82
  * An unknown FIELD is ignored by old readers; an unknown CODE is not.
83
83
  */
84
84
  origin?: "cloud";
85
+ /**
86
+ * With `origin: "cloud"`: the protocol feature the command's payload
87
+ * needs and this runner does not advertise, so the bridge never sent it
88
+ * (ADR-157 §3: a roster with token generations needs
89
+ * `agent-token-generation-v1`). Additive for the same reason as
90
+ * `origin`; never set by a host.
91
+ */
92
+ requiresFeature?: string;
85
93
  };
86
94
 
87
95
  /** Capability ids shared by host advertisement and fail-closed cloud gates. */
@@ -156,6 +164,12 @@ export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
156
164
  * unrelated respawn and never stops a removed one, so the cloud refuses to
157
165
  * edit or remove a running task's agents on it. */
158
166
  export const LIVE_ROSTER_EDIT_PROTOCOL_FEATURE = "live-roster-edit-v1";
167
+ /** ADR-157 §3: the host signs an agent's `tokenGeneration` into its task token
168
+ * (claim `g`). The cloud stamps a fresh generation on an agent that joins a
169
+ * running task only on a host advertising this, and refuses a token whose
170
+ * generation is not its roster entry's, so a token minted for an earlier agent
171
+ * under the same handle stays dead. */
172
+ export const AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE = "agent-token-generation-v1";
159
173
  /** ADR-153 declarations + owner bindings. Advertising this means the host
160
174
  * understands every additive env frame field, persists bindings separately
161
175
  * from values, and reports immutable repository schema observations. Keep it
@@ -2956,6 +2970,29 @@ export const MAX_HOST_TASK_INVENTORY_TASK_ID_CHARS = 128;
2956
2970
  export const ENROLLMENT_REPLAY_PROTOCOL_HEADER = "x-uai-enrollment-replay";
2957
2971
  export const ENROLLMENT_REPLAY_PROTOCOL_VERSION = "same-host-v1";
2958
2972
  export const COMMUNICATOR_EXECUTION_PROFILE = "communicator";
2973
+ /**
2974
+ * ADR-157: the built-in Astronaut. An Astronaut task has exactly one lead agent with
2975
+ * this id, marked `builtIn: "astronaut"` by the cloud when it builds the entry;
2976
+ * no client may send the marker. The lead runs under the `astronaut` execution
2977
+ * profile: normal tools, but no native subagents (Claude's Agent/Task, Codex's
2978
+ * multi-agent), so every helper it uses is an accounted crew agent.
2979
+ */
2980
+ export const ASTRONAUT_AGENT_ID = "astronaut";
2981
+ export const ASTRONAUT_BUILT_IN = "astronaut";
2982
+ export const ASTRONAUT_EXECUTION_PROFILE = "astronaut";
2983
+
2984
+ /** Whether `agent` is this task's built-in Astronaut (ADR-157 §1). */
2985
+ export function isBuiltInAstronaut(
2986
+ task: { mode?: string | null; secretaryAgentId?: string | null },
2987
+ agent: { id: string; builtIn?: string | null } | null | undefined,
2988
+ ): boolean {
2989
+ return (
2990
+ task.mode === "secretary" &&
2991
+ task.secretaryAgentId === ASTRONAUT_AGENT_ID &&
2992
+ agent?.id === ASTRONAUT_AGENT_ID &&
2993
+ agent.builtIn === ASTRONAUT_BUILT_IN
2994
+ );
2995
+ }
2959
2996
  export const MAX_AGENT_ID_CHARS = 128;
2960
2997
  export const MAX_SECRETARY_DISPATCH_RECIPIENTS = 16;
2961
2998
  export const MAX_SECRETARY_DISPATCH_INSTRUCTION_CHARS = 8_000;
@@ -3023,6 +3060,11 @@ export interface TaskAgent {
3023
3060
  }>;
3024
3061
  /** ADR-048: the source persona's flat permission list for the `uai` CLI. */
3025
3062
  permissions?: string[];
3063
+ /** ADR-157: set by the cloud on the built-in Astronaut only. */
3064
+ builtIn?: "astronaut";
3065
+ /** ADR-157 §3: this agent's token generation, stamped by the cloud when it
3066
+ * joined the running task; signed into its task token as `g`. */
3067
+ tokenGeneration?: string;
3026
3068
  }
3027
3069
 
3028
3070
  export type ContainerRuntimePreference = "auto" | "docker" | "apple-container";