@runuai/host 0.9.167 → 0.9.169

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.
@@ -0,0 +1,16 @@
1
+ /** Immutable write-only task secret writer. Never return a value or provider error. */
2
+ import { createHash, createHmac } from "node:crypto";
3
+ import { isTaskSecretStagingPut, type TaskSecretStagingPut } from "../src/task-secrets";
4
+ import { configuredCredentialStore, type CredentialStore } from "./credential-store";
5
+ import { masterKey } from "./secrets";
6
+ export function taskSecretParameter(taskId: string, name: string, stagingId: string): string {
7
+ return `/uai/lease/tasks/${taskId}/secrets/${createHash("sha256").update(name).digest("hex")}/${stagingId}`;
8
+ }
9
+ export async function writeTaskSecretStaging(frame: TaskSecretStagingPut, options: { store?: CredentialStore | null; hmacKey?: Buffer } = {}): Promise<void> {
10
+ if (!isTaskSecretStagingPut(frame)) throw new Error("invalid task secret staging request");
11
+ const store = Object.hasOwn(options, "store") ? options.store : configuredCredentialStore();
12
+ if (!store?.putImmutable) throw new Error("task secret writer unavailable");
13
+ const envelope = JSON.stringify({ v: 1, kind: "task-secret", orgId: frame.orgId, taskId: frame.taskId, name: frame.name, stagingId: frame.stagingId, value: frame.value });
14
+ const mac = createHmac("sha256", options.hmacKey ?? masterKey()).update("uai.task-secret.staging.v1\0").update(envelope).digest("hex");
15
+ await store.putImmutable(taskSecretParameter(frame.taskId, frame.name, frame.stagingId), envelope, { org: frame.orgId, task: frame.taskId, uai_staging_mac: mac });
16
+ }
package/lib/task-token.ts CHANGED
@@ -13,6 +13,8 @@ export interface TaskTokenPayload {
13
13
  agentId?: string;
14
14
  /** ADR-157 §3: the agent's roster generation; the cloud requires it to match. */
15
15
  generation?: string;
16
+ /** Server-issued lead context, frozen into a lead process token by the host. */
17
+ leadRunId?: string;
16
18
  }
17
19
 
18
20
  const PREFIX = "uai_";
@@ -34,6 +36,7 @@ export function signTaskToken(payload: TaskTokenPayload, secret: string): string
34
36
  p: payload.permissions,
35
37
  ...(payload.agentId ? { a: payload.agentId } : {}),
36
38
  ...(payload.generation ? { g: payload.generation } : {}),
39
+ ...(payload.leadRunId ? { l: payload.leadRunId } : {}),
37
40
  iat: Math.floor(Date.now() / 1000),
38
41
  }),
39
42
  ),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.167",
4
- "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
3
+ "version": "0.9.169",
4
+ "description": "Uai host \u2014 runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
7
7
  "homepage": "https://github.com/runuai/uai#readme",
@@ -80,6 +80,18 @@
80
80
  "./engine-usage": {
81
81
  "types": "./src/engine-usage.ts",
82
82
  "default": "./src/engine-usage.ts"
83
+ },
84
+ "./workspace-path-policy": {
85
+ "types": "./src/workspace-path-policy.ts",
86
+ "default": "./src/workspace-path-policy.ts"
87
+ },
88
+ "./task-secrets": {
89
+ "types": "./src/task-secrets.ts",
90
+ "default": "./src/task-secrets.ts"
91
+ },
92
+ "./task-env-names": {
93
+ "types": "./src/task-env-names.ts",
94
+ "default": "./src/task-env-names.ts"
83
95
  }
84
96
  },
85
97
  "scripts": {
@@ -1,3 +1,5 @@
1
+ import { MAX_TASK_SECRETS, TASK_SECRET_NAME, TASK_SECRET_STAGING_ID, type TaskSecretManifestEntry } from "./task-secrets";
2
+
1
3
  /**
2
4
  * ADR-154 — the shared contract for cloud-only credentials.
3
5
  *
@@ -31,6 +33,15 @@ export const HOST_CLOUD_CREDENTIALS_FEATURE = "host-cloud-credentials-v1";
31
33
  */
32
34
  export const HOST_TASK_FILES_LEASE_FEATURE = "host-task-files-lease-v1";
33
35
 
36
+ /**
37
+ * Advertised by a runner whose in-task `uai files` reads cloud links instead
38
+ * of a bind-mounted shared folder (ADR-161 §1.11). A task machine that still
39
+ * installs a Files session advertises the lease feature as well; the cloud
40
+ * treats "both" as a legacy session plus cloud links, and it does not mount
41
+ * the legacy folder into that container.
42
+ */
43
+ export const HOST_AGENT_FILES_FEATURE = "host-agent-files-v1";
44
+
34
45
  /**
35
46
  * Set to `1` in a Uai Cloud task machine's root-only launch state
36
47
  * (`/etc/uai/sidecar.env`) when the cloud launched it on cloud credentials
@@ -752,6 +763,8 @@ export interface LeaseManifestStatic {
752
763
 
753
764
  /** What a task may read, by name and version — never a value. */
754
765
  export interface CredentialManifest {
766
+ /** Omitted on older peers and when empty, preserving old digests. */
767
+ taskSecrets?: TaskSecretManifestEntry[];
755
768
  /** The task's projects, lead first. */
756
769
  projects: LeaseManifestProject[];
757
770
  /** Per engine kind: the owner's personal accounts, then the workspace's. */
@@ -946,12 +959,13 @@ type WireObject = Record<string, unknown>;
946
959
  function exactObject(
947
960
  value: unknown,
948
961
  required: readonly string[],
962
+ optional: readonly string[] = [],
949
963
  ): WireObject | null {
950
964
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
951
965
  const object = value as WireObject;
952
966
  const keys = Object.keys(object);
953
967
  if (
954
- keys.length !== required.length ||
968
+ keys.some(key => !required.includes(key) && !optional.includes(key)) ||
955
969
  required.some((key) => !Object.hasOwn(object, key))
956
970
  ) {
957
971
  return null;
@@ -1226,10 +1240,10 @@ function parseStatic(
1226
1240
 
1227
1241
  function parseManifest(
1228
1242
  value: unknown,
1229
- grant: { orgId: string; userId: string },
1243
+ grant: { orgId: string; userId: string; taskId: string },
1230
1244
  credentialSessions: ReadonlySet<string>,
1231
1245
  ): CredentialManifest | null {
1232
- const o = exactObject(value, ["projects", "engines", "mcp", "github", "ssh"]);
1246
+ const o = exactObject(value, ["projects", "engines", "mcp", "github", "ssh"], ["taskSecrets"]);
1233
1247
  if (
1234
1248
  !o ||
1235
1249
  !Array.isArray(o.projects) ||
@@ -1281,6 +1295,29 @@ function parseManifest(
1281
1295
  ) {
1282
1296
  return null;
1283
1297
  }
1298
+ if (o.taskSecrets !== undefined) {
1299
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(grant.taskId) || !Array.isArray(o.taskSecrets) || !o.taskSecrets.length || o.taskSecrets.length > MAX_TASK_SECRETS) return null;
1300
+ const entries: TaskSecretManifestEntry[] = [];
1301
+ for (const item of o.taskSecrets) {
1302
+ const e = exactObject(item, ["credentialId", "name", "stagingId", "generation", "parameter", "version", "mode", "domains", "placeholder", "sessionId"]);
1303
+ if (!e || typeof e.name !== "string" || !TASK_SECRET_NAME.test(e.name) ||
1304
+ typeof e.credentialId !== "string" || e.credentialId !== `task-secret:${e.name}` ||
1305
+ typeof e.stagingId !== "string" || !TASK_SECRET_STAGING_ID.test(e.stagingId) || !isPositiveInteger(e.generation) || e.version !== 1 ||
1306
+ typeof e.parameter !== "string" || (!e.parameter.startsWith(`/uai/lease/tasks/${grant.taskId}/secrets/`) || !/^[0-9a-f]{64}\/[0-9a-f-]{36}$/.test(e.parameter.slice(`/uai/lease/tasks/${grant.taskId}/secrets/`.length)) || !e.parameter.endsWith(`/${e.stagingId}`)) ||
1307
+ (e.mode !== "proxied" && e.mode !== "delivered") || !Array.isArray(e.domains) || e.domains.length > 16 ||
1308
+ !e.domains.every(d => typeof d === "string" && /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d)) ||
1309
+ (e.mode === "proxied" && !e.domains.length) || (e.mode === "delivered" && e.domains.length) ||
1310
+ typeof e.sessionId !== "string" || !credentialSessions.has(e.sessionId)) return null;
1311
+ if (e.placeholder !== null) {
1312
+ const shape = exactObject(e.placeholder, ["prefix", "totalLength"]);
1313
+ if (!shape || typeof shape.prefix !== "string" || !/^(?:[A-Za-z0-9._~-]*[_-])?$/.test(shape.prefix) ||
1314
+ !isPositiveInteger(shape.totalLength) || shape.totalLength < shape.prefix.length + 32 || shape.totalLength > 256) return null;
1315
+ }
1316
+ entries.push(e as unknown as TaskSecretManifestEntry);
1317
+ }
1318
+ if (!uniqueBy(entries, e => e.name)) return null;
1319
+ return { projects, engines, mcp, github, ssh, taskSecrets: entries };
1320
+ }
1284
1321
  return { projects, engines, mcp, github, ssh };
1285
1322
  }
1286
1323
 
@@ -1359,7 +1396,7 @@ export function parseTaskLeaseV1(value: unknown): TaskLeaseV1 | null {
1359
1396
  }
1360
1397
  const files = o.files === null ? null : parseFiles(o.files);
1361
1398
  if (o.files !== null && !files) return null;
1362
- const grant = { orgId: o.orgId, userId: o.userId };
1399
+ const grant = { orgId: o.orgId, userId: o.userId, taskId: o.taskId };
1363
1400
  const manifest = parseManifest(o.manifest, grant, credentialSessions);
1364
1401
  if (!manifest) return null;
1365
1402
  return {
@@ -0,0 +1,11 @@
1
+ /** Secret-free paths shared by account provisioning and credential safeguards. */
2
+ export const ENGINE_ACCOUNT_PATHS = {
3
+ grok: { defaultContainerDir: "/home/node/.grok", containerDirPrefix: "/home/node/.grok-acct-" },
4
+ codex: { defaultContainerDir: "/home/node/.codex", containerDirPrefix: "/home/node/.codex-acct-" },
5
+ opencode: { defaultContainerDir: "/home/node/.local/share/opencode", containerDirPrefix: "/home/node/.local/share/opencode-acct-" },
6
+ } as const;
7
+ /** Relative to the home, so safeguards cover relocated homes and symlinks too. */
8
+ export const ENGINE_ACCOUNT_PATH_RULES = Object.values(ENGINE_ACCOUNT_PATHS).flatMap((path) => [
9
+ { components: path.defaultContainerDir.split("/").slice(3), prefix: false },
10
+ { components: path.containerDirPrefix.split("/").slice(3), prefix: true },
11
+ ]);
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { assertShareablePath, normalizedWorkspacePath } from "./workspace-path-policy";
2
+ import { readBoundedFile } from "../lib/task-environment/workspace-files";
1
3
  import {
2
4
  agent,
3
5
  AgentError,
@@ -51,7 +53,7 @@ import {
51
53
  import { readAttachment, writeAttachment } from "../lib/attachments";
52
54
  import { readFileSync } from "node:fs";
53
55
  import { resolve } from "node:path";
54
- import { env, taskProjectWorktree } from "../lib/env";
56
+ import { env, taskProjectWorktree, taskWorkspaceDir } from "../lib/env";
55
57
  import { getDecryptedForProject } from "../lib/host-env";
56
58
  import {
57
59
  envSchemaNote,
@@ -63,6 +65,7 @@ import {
63
65
  appendTranscriptViaEnvironment,
64
66
  } from "../lib/transcript";
65
67
  import { buildTaskDiff } from "../lib/task-diff";
68
+ import { taskProjectOps } from "../lib/task-project-ops-host";
66
69
  import { stoppedTaskDiff } from "../lib/task-diff-sandbox";
67
70
  import {
68
71
  admitTaskRunEpoch,
@@ -2036,7 +2039,7 @@ export const hostCommands: HostCommands = {
2036
2039
  });
2037
2040
  },
2038
2041
 
2039
- async channelDeliver(ctx, taskId, agentId, text, origin) {
2042
+ async channelDeliver(ctx, taskId, agentId, text, origin, assignment) {
2040
2043
  logCommand(ctx, "channelDeliver", taskId, agentId);
2041
2044
  if (credentialsMode() === "pending") {
2042
2045
  return switchingToCloudCredentials();
@@ -2047,7 +2050,7 @@ export const hostCommands: HostCommands = {
2047
2050
  // An inbound turn is activity: the idle veto must cover the gap between
2048
2051
  // this delivery landing and the agent's first output event.
2049
2052
  getOrchestrator().markChannelActivity(taskId);
2050
- const result = await getOrchestrator().deliver(taskId, agentId, text, origin);
2053
+ const result = await getOrchestrator().deliver(taskId, agentId, text, origin, assignment);
2051
2054
  if (!result.ok) {
2052
2055
  return {
2053
2056
  ok: false,
@@ -2245,6 +2248,53 @@ export const hostCommands: HostCommands = {
2245
2248
  }
2246
2249
  },
2247
2250
 
2251
+ // ADR-161 §2.3: add a repository to a running task. Each command runs in
2252
+ // the task's live-project queue; the publication steps take its lifecycle
2253
+ // slot (lib/task-project-ops.ts).
2254
+ async taskProjectPrepare(ctx, input) {
2255
+ logCommand(ctx, "taskProjectPrepare", input.taskId, input.operationId);
2256
+ const runtimeFailure = runtimeUnavailable();
2257
+ if (runtimeFailure) return runtimeFailure;
2258
+ try {
2259
+ return ok(await taskProjectOps().prepare(input));
2260
+ } catch (err) {
2261
+ return failFromUnknown(err);
2262
+ }
2263
+ },
2264
+
2265
+ async taskProjectActivate(ctx, input) {
2266
+ logCommand(ctx, "taskProjectActivate", input.taskId, input.operationId);
2267
+ const runtimeFailure = runtimeUnavailable();
2268
+ if (runtimeFailure) return runtimeFailure;
2269
+ try {
2270
+ return ok(await taskProjectOps().activate(input));
2271
+ } catch (err) {
2272
+ return failFromUnknown(err);
2273
+ }
2274
+ },
2275
+
2276
+ async taskProjectCompensate(ctx, input) {
2277
+ logCommand(ctx, "taskProjectCompensate", input.taskId, input.operationId);
2278
+ const runtimeFailure = runtimeUnavailable();
2279
+ if (runtimeFailure) return runtimeFailure;
2280
+ try {
2281
+ return ok(await taskProjectOps().compensate(input));
2282
+ } catch (err) {
2283
+ return failFromUnknown(err);
2284
+ }
2285
+ },
2286
+
2287
+ async taskProjectStatus(ctx, input) {
2288
+ logCommand(ctx, "taskProjectStatus", input.taskId, input.operationId);
2289
+ const runtimeFailure = runtimeUnavailable();
2290
+ if (runtimeFailure) return runtimeFailure;
2291
+ try {
2292
+ return ok(await taskProjectOps().status(input));
2293
+ } catch (err) {
2294
+ return failFromUnknown(err);
2295
+ }
2296
+ },
2297
+
2248
2298
  async attachmentWrite(ctx, input) {
2249
2299
  logCommand(ctx, "attachmentWrite", input.taskId, input.filename);
2250
2300
  try {
@@ -2273,6 +2323,19 @@ export const hostCommands: HostCommands = {
2273
2323
  }
2274
2324
  },
2275
2325
 
2326
+ async workspaceFileRead(ctx, input) {
2327
+ try {
2328
+ const path = normalizedWorkspacePath(assertShareablePath(input.path));
2329
+ const maxBytes = Math.min(8 * 1024 * 1024, Math.max(0, input.maxBytes));
2330
+ const environment = await reconstructHostTaskEnvironment(input.taskId);
2331
+ const bytes = environment ? await environment.readWorkspaceFile(`${environment.descriptor.workspacePath}/${path.slice("/workspace/".length)}`, { maxBytes: maxBytes + 1 }) :
2332
+ await readBoundedFile(taskWorkspaceDir(input.taskId), path.slice("/workspace/".length).split("/"), { maxBytes: maxBytes + 1 });
2333
+ if (!bytes) return { ok: false, code: HostErrorCode.TaskNotFound, message: "no such workspace file" };
2334
+ if (bytes.byteLength > maxBytes) return { ok: false, code: HostErrorCode.PayloadTooLarge, message: "Ask the agent to uai share this file (live preview is limited to 8 MiB)." };
2335
+ return ok({ dataBase64: Buffer.from(bytes).toString("base64"), size: bytes.byteLength });
2336
+ } catch { return { ok: false, code: HostErrorCode.TaskNotFound, message: "no such workspace file" }; }
2337
+ },
2338
+
2276
2339
  async attachmentRead(ctx, input) {
2277
2340
  logCommand(ctx, "attachmentRead", input.taskId, input.filename);
2278
2341
  try {
package/src/main.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env tsx
2
+ import type { AssignmentDelivery, DeliveryOrigin } from "./protocol";
2
3
 
3
4
  import "./load-env";
5
+ import { sampleDiskPressure } from "../lib/disk-pressure";
6
+ import { TASK_SECRETS_FEATURE, isTaskSecretStagingPut } from "./task-secrets";
7
+ import { writeTaskSecretStaging } from "../lib/task-secret-staging";
4
8
 
5
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
10
  import {
@@ -113,6 +117,7 @@ import {
113
117
  parseLeaseGrantV1Frame,
114
118
  parseLeaseStaleFrame,
115
119
  parseLeaseUnavailableFrame,
120
+ HOST_AGENT_FILES_FEATURE,
116
121
  HOST_TASK_FILES_LEASE_FEATURE,
117
122
  } from "./cloud-credentials";
118
123
  import { captureEngineAccount } from "../lib/capture-flow";
@@ -301,6 +306,7 @@ import {
301
306
  HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
302
307
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
303
308
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
309
+ LIVE_TASK_PROJECTS_PROTOCOL_FEATURE,
304
310
  AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
305
311
  isEngineAccountScope,
306
312
  isTaskMachineRetireAckFrame,
@@ -318,6 +324,8 @@ import {
318
324
  MAX_REMOTE_HOST_MESSAGE_BYTES,
319
325
  MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE,
320
326
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
327
+ ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
328
+ LEAD_EVIDENCE_PROTOCOL_FEATURE,
321
329
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
322
330
  type CloudToHost,
323
331
  type McpOp,
@@ -364,6 +372,10 @@ import {
364
372
  type TaskCommandProject,
365
373
  type TaskCommandTask,
366
374
  type TaskDiffInput,
375
+ type TaskProjectActivateInput,
376
+ type TaskProjectOpFence,
377
+ type TaskProjectPrepareInput,
378
+ type TaskProjectStatusInput,
367
379
  type TaskDownInput,
368
380
  type TaskLaunchInput,
369
381
  type TaskStopInput,
@@ -1462,13 +1474,32 @@ async function startLocalUi(): Promise<void> {
1462
1474
  }
1463
1475
  }
1464
1476
 
1477
+ // Capacity fits the existing capability/status snapshot; no new messages.
1478
+ let diskSnapshot: HostCapabilities["disk"];
1479
+ let diskSampling = false;
1480
+ async function refreshDiskSnapshot(): Promise<void> {
1481
+ if (diskSampling || sidecarMode()) return;
1482
+ diskSampling = true;
1483
+ try {
1484
+ diskSnapshot = await sampleDiskPressure();
1485
+ sendCapabilities();
1486
+ } finally { diskSampling = false; }
1487
+ }
1488
+ void refreshDiskSnapshot();
1489
+ const diskTimer = setInterval(() => void refreshDiskSnapshot(), 60_000);
1490
+ diskTimer.unref?.();
1491
+
1465
1492
  /** Build the current host capability advertisement (ADR-021). */
1466
1493
  function buildCapabilities(): HostCapabilities {
1467
1494
  const runtime = containerRuntimeCapabilityState();
1468
1495
  const projectEnvBindings = projectEnvBindingCapability();
1469
1496
  return {
1470
1497
  version: packageVersion(),
1498
+ ...(diskSnapshot ? { disk: diskSnapshot } : {}),
1471
1499
  protocolFeatures: [
1500
+ "host-workspace-file-read-v1",
1501
+ ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
1502
+ LEAD_EVIDENCE_PROTOCOL_FEATURE,
1472
1503
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
1473
1504
  EVENT_REPLAY_PROTOCOL_FEATURE,
1474
1505
  GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
@@ -1489,6 +1520,8 @@ function buildCapabilities(): HostCapabilities {
1489
1520
  HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
1490
1521
  ENGINE_USAGE_PROTOCOL_FEATURE,
1491
1522
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
1523
+ LIVE_TASK_PROJECTS_PROTOCOL_FEATURE,
1524
+ ...(credentialsMode() === "active" || signInBoxMode() ? [TASK_SECRETS_FEATURE] : []),
1492
1525
  AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
1493
1526
  // The echo adapter cannot execute the in-task CLI. Advertising typed
1494
1527
  // dispatch in mock mode would let the composer create a Secretary that
@@ -1504,6 +1537,9 @@ function buildCapabilities(): HostCapabilities {
1504
1537
  taskFilesMachineRetirementReady()
1505
1538
  ? [HOST_TASK_FILES_LEASE_FEATURE]
1506
1539
  : []),
1540
+ // ADR-161 §1.11: this image's `uai files` reads cloud links. The cloud
1541
+ // captures the advertisement at container creation.
1542
+ HOST_AGENT_FILES_FEATURE,
1507
1543
  // ADR-154: only the sign-in box writes cloud credentials.
1508
1544
  ...(signInBoxMode()
1509
1545
  ? [
@@ -3624,6 +3660,18 @@ async function connect(): Promise<void> {
3624
3660
  );
3625
3661
  break;
3626
3662
  }
3663
+ case "task.secret.staging.put": {
3664
+ if (!signInBoxMode() || !isTaskSecretStagingPut(frame)) break;
3665
+ void managedMutationDrain.track(async () => {
3666
+ try {
3667
+ await writeTaskSecretStaging(frame);
3668
+ send(socket, { kind: "task.secret.staging.ack", opId: frame.opId, stagingId: frame.stagingId, ok: true });
3669
+ } catch {
3670
+ send(socket, { kind: "task.secret.staging.ack", opId: frame.opId, stagingId: frame.stagingId, ok: false });
3671
+ }
3672
+ });
3673
+ break;
3674
+ }
3627
3675
  case "env.staging.put": {
3628
3676
  // Only the write-only sign-in box owns immutable staging paths. An
3629
3677
  // owned runner or task sidecar must never accept plaintext here.
@@ -4502,6 +4550,8 @@ async function dispatchCommand(
4502
4550
  expectString(args, 0),
4503
4551
  expectString(args, 1),
4504
4552
  expectString(args, 2),
4553
+ args[3] as DeliveryOrigin | undefined,
4554
+ args[4] as AssignmentDelivery | undefined,
4505
4555
  );
4506
4556
  case "channelResolvePermission":
4507
4557
  return hostCommands.channelResolvePermission(
@@ -4515,11 +4565,23 @@ async function dispatchCommand(
4515
4565
  return hostCommands.cloneRepo(ctx, expectCloneRepoInput(args, 0));
4516
4566
  case "taskDiff":
4517
4567
  return hostCommands.taskDiff(ctx, expectTaskDiffInput(args, 0));
4568
+ case "taskProjectPrepare":
4569
+ return hostCommands.taskProjectPrepare(ctx, expectTaskProjectPrepareInput(args, 0));
4570
+ case "taskProjectActivate":
4571
+ return hostCommands.taskProjectActivate(ctx, expectTaskProjectActivateInput(args, 0));
4572
+ case "taskProjectCompensate":
4573
+ return hostCommands.taskProjectCompensate(ctx, expectTaskProjectFence(args[0], "task project compensate input"));
4574
+ case "taskProjectStatus":
4575
+ return hostCommands.taskProjectStatus(ctx, expectTaskProjectStatusInput(args, 0));
4518
4576
  case "attachmentWrite":
4519
4577
  return hostCommands.attachmentWrite(
4520
4578
  ctx,
4521
4579
  expectAttachmentWriteInput(args, 0),
4522
4580
  );
4581
+ case "workspaceFileRead": {
4582
+ const input = expectRecord(args[0], "workspace file read input");
4583
+ return hostCommands.workspaceFileRead(ctx, { taskId: expectStringValue(input.taskId, "taskId"), path: expectStringValue(input.path, "path"), maxBytes: expectNumberArg([input.maxBytes], 0) });
4584
+ }
4523
4585
  case "attachmentRead":
4524
4586
  return hostCommands.attachmentRead(
4525
4587
  ctx,
@@ -4969,6 +5031,7 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
4969
5031
  ) {
4970
5032
  return frame;
4971
5033
  }
5034
+ if (isTaskSecretStagingPut(frame)) return frame;
4972
5035
  if (isProjectEnvStagingPutFrame(frame)) return frame;
4973
5036
  // Sidecar replica authority has a separate durable store and apply lane.
4974
5037
  // Exact guards keep every attempt, cursor and immutable value reference;
@@ -5265,6 +5328,7 @@ function rejectFrameDuringShutdown(
5265
5328
  return true;
5266
5329
  case "env.var.remove":
5267
5330
  case "env.binding.set":
5331
+ case "task.secret.staging.put":
5268
5332
  case "env.staging.put":
5269
5333
  case "env.reconcile.begin":
5270
5334
  case "env.reconciled":
@@ -5431,6 +5495,7 @@ function rejectCredentialFrameOffOwnCredentials(socket: WebSocket, frame: CloudT
5431
5495
  return true;
5432
5496
  case "env.var.remove":
5433
5497
  case "env.binding.set":
5498
+ case "task.secret.staging.put":
5434
5499
  case "env.staging.put":
5435
5500
  case "env.reconcile.begin":
5436
5501
  case "env.reconciled":
@@ -5496,8 +5561,13 @@ const HOST_COMMAND_NAMES = {
5496
5561
  channelResolvePermission: true,
5497
5562
  cloneRepo: true,
5498
5563
  taskDiff: true,
5564
+ taskProjectPrepare: true,
5565
+ taskProjectActivate: true,
5566
+ taskProjectCompensate: true,
5567
+ taskProjectStatus: true,
5499
5568
  attachmentWrite: true,
5500
5569
  attachmentRead: true,
5570
+ workspaceFileRead: true,
5501
5571
  filesOp: true,
5502
5572
  sidecarAssign: true,
5503
5573
  channelInterrupt: true,
@@ -5683,6 +5753,77 @@ function expectTaskDiffInput(args: unknown[], index: number): TaskDiffInput {
5683
5753
  };
5684
5754
  }
5685
5755
 
5756
+ function expectPositiveSafeInteger(value: unknown, label: string): number {
5757
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
5758
+ throw new Error(`invalid command args: expected positive integer ${label}`);
5759
+ }
5760
+ return value as number;
5761
+ }
5762
+
5763
+ function expectTaskProjectFence(value: unknown, label: string): TaskProjectOpFence {
5764
+ const input = expectRecord(value, label);
5765
+ return {
5766
+ taskId: expectStringValue(input.taskId, "taskId"),
5767
+ runEpoch: expectPositiveSafeInteger(input.runEpoch, "runEpoch"),
5768
+ operationId: expectStringValue(input.operationId, "operationId"),
5769
+ projectSetRevision: expectPositiveSafeInteger(input.projectSetRevision, "projectSetRevision"),
5770
+ };
5771
+ }
5772
+
5773
+ function expectTaskProjectPrepareInput(args: unknown[], index: number): TaskProjectPrepareInput {
5774
+ const input = expectRecord(args[index], "task project prepare input");
5775
+ const project = expectRecord(input.project, "task project");
5776
+ return {
5777
+ ...expectTaskProjectFence(input, "task project prepare input"),
5778
+ branch: expectStringValue(input.branch, "branch"),
5779
+ project: {
5780
+ id: expectStringValue(project.id, "project.id"),
5781
+ slug: expectStringValue(project.slug, "project.slug"),
5782
+ repoUrl: expectStringValue(project.repoUrl, "project.repoUrl"),
5783
+ ...(typeof project.toolVersions === "string" ? { toolVersions: project.toolVersions } : {}),
5784
+ },
5785
+ };
5786
+ }
5787
+
5788
+ function expectTaskProjectActivateInput(args: unknown[], index: number): TaskProjectActivateInput {
5789
+ const input = expectRecord(args[index], "task project activate input");
5790
+ let grant: TaskProjectActivateInput["grant"] = null;
5791
+ if (input.grant !== null) {
5792
+ const row = expectRecord(input.grant, "task project grant");
5793
+ grant = {
5794
+ manifestRevision: expectPositiveSafeInteger(row.manifestRevision, "grant.manifestRevision"),
5795
+ manifestDigest: expectStringValue(row.manifestDigest, "grant.manifestDigest"),
5796
+ targetGeneration: expectPositiveSafeInteger(row.targetGeneration, "grant.targetGeneration"),
5797
+ };
5798
+ }
5799
+ return {
5800
+ ...expectTaskProjectFence(input, "task project activate input"),
5801
+ project: expectTaskCommandProject(input.project),
5802
+ previewEnv: expectStringRecord(input.previewEnv ?? {}, "previewEnv"),
5803
+ grant,
5804
+ };
5805
+ }
5806
+
5807
+ function expectStringRecord(value: unknown, label: string): Record<string, string> {
5808
+ const record = expectRecord(value, label);
5809
+ const out: Record<string, string> = {};
5810
+ for (const [key, entry] of Object.entries(record)) {
5811
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid command args: ${label} name ${key}`);
5812
+ out[key] = expectStringValue(entry, `${label}.${key}`);
5813
+ }
5814
+ return out;
5815
+ }
5816
+
5817
+ function expectTaskProjectStatusInput(args: unknown[], index: number): TaskProjectStatusInput {
5818
+ const input = expectRecord(args[index], "task project status input");
5819
+ return {
5820
+ taskId: expectStringValue(input.taskId, "taskId"),
5821
+ runEpoch: expectPositiveSafeInteger(input.runEpoch, "runEpoch"),
5822
+ operationId: expectStringValue(input.operationId, "operationId"),
5823
+ projectId: expectStringValue(input.projectId, "projectId"),
5824
+ };
5825
+ }
5826
+
5686
5827
  function expectAttachmentWriteInput(
5687
5828
  args: unknown[],
5688
5829
  index: number,
@@ -5768,6 +5909,7 @@ function expectChannelEnsureInput(
5768
5909
  // Both are optional for wire compatibility with older clouds.
5769
5910
  const mode = parseChannelMode(input.mode);
5770
5911
  if (mode !== undefined) out.mode = mode;
5912
+ if (typeof input.leadRunId === "string") out.leadRunId = input.leadRunId;
5771
5913
  if (typeof input.secretaryAgentId === "string") {
5772
5914
  out.secretaryAgentId = input.secretaryAgentId;
5773
5915
  }