@runuai/host 0.9.167 → 0.9.168

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.168",
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,9 @@
1
1
  #!/usr/bin/env tsx
2
+ import type { AssignmentDelivery, DeliveryOrigin } from "./protocol";
2
3
 
3
4
  import "./load-env";
5
+ import { TASK_SECRETS_FEATURE, isTaskSecretStagingPut } from "./task-secrets";
6
+ import { writeTaskSecretStaging } from "../lib/task-secret-staging";
4
7
 
5
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
9
  import {
@@ -113,6 +116,7 @@ import {
113
116
  parseLeaseGrantV1Frame,
114
117
  parseLeaseStaleFrame,
115
118
  parseLeaseUnavailableFrame,
119
+ HOST_AGENT_FILES_FEATURE,
116
120
  HOST_TASK_FILES_LEASE_FEATURE,
117
121
  } from "./cloud-credentials";
118
122
  import { captureEngineAccount } from "../lib/capture-flow";
@@ -301,6 +305,7 @@ import {
301
305
  HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
302
306
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
303
307
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
308
+ LIVE_TASK_PROJECTS_PROTOCOL_FEATURE,
304
309
  AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
305
310
  isEngineAccountScope,
306
311
  isTaskMachineRetireAckFrame,
@@ -318,6 +323,8 @@ import {
318
323
  MAX_REMOTE_HOST_MESSAGE_BYTES,
319
324
  MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE,
320
325
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
326
+ ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
327
+ LEAD_EVIDENCE_PROTOCOL_FEATURE,
321
328
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
322
329
  type CloudToHost,
323
330
  type McpOp,
@@ -364,6 +371,10 @@ import {
364
371
  type TaskCommandProject,
365
372
  type TaskCommandTask,
366
373
  type TaskDiffInput,
374
+ type TaskProjectActivateInput,
375
+ type TaskProjectOpFence,
376
+ type TaskProjectPrepareInput,
377
+ type TaskProjectStatusInput,
367
378
  type TaskDownInput,
368
379
  type TaskLaunchInput,
369
380
  type TaskStopInput,
@@ -1469,6 +1480,9 @@ function buildCapabilities(): HostCapabilities {
1469
1480
  return {
1470
1481
  version: packageVersion(),
1471
1482
  protocolFeatures: [
1483
+ "host-workspace-file-read-v1",
1484
+ ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
1485
+ LEAD_EVIDENCE_PROTOCOL_FEATURE,
1472
1486
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
1473
1487
  EVENT_REPLAY_PROTOCOL_FEATURE,
1474
1488
  GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
@@ -1489,6 +1503,8 @@ function buildCapabilities(): HostCapabilities {
1489
1503
  HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
1490
1504
  ENGINE_USAGE_PROTOCOL_FEATURE,
1491
1505
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
1506
+ LIVE_TASK_PROJECTS_PROTOCOL_FEATURE,
1507
+ ...(credentialsMode() === "active" || signInBoxMode() ? [TASK_SECRETS_FEATURE] : []),
1492
1508
  AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
1493
1509
  // The echo adapter cannot execute the in-task CLI. Advertising typed
1494
1510
  // dispatch in mock mode would let the composer create a Secretary that
@@ -1504,6 +1520,9 @@ function buildCapabilities(): HostCapabilities {
1504
1520
  taskFilesMachineRetirementReady()
1505
1521
  ? [HOST_TASK_FILES_LEASE_FEATURE]
1506
1522
  : []),
1523
+ // ADR-161 §1.11: this image's `uai files` reads cloud links. The cloud
1524
+ // captures the advertisement at container creation.
1525
+ HOST_AGENT_FILES_FEATURE,
1507
1526
  // ADR-154: only the sign-in box writes cloud credentials.
1508
1527
  ...(signInBoxMode()
1509
1528
  ? [
@@ -3624,6 +3643,18 @@ async function connect(): Promise<void> {
3624
3643
  );
3625
3644
  break;
3626
3645
  }
3646
+ case "task.secret.staging.put": {
3647
+ if (!signInBoxMode() || !isTaskSecretStagingPut(frame)) break;
3648
+ void managedMutationDrain.track(async () => {
3649
+ try {
3650
+ await writeTaskSecretStaging(frame);
3651
+ send(socket, { kind: "task.secret.staging.ack", opId: frame.opId, stagingId: frame.stagingId, ok: true });
3652
+ } catch {
3653
+ send(socket, { kind: "task.secret.staging.ack", opId: frame.opId, stagingId: frame.stagingId, ok: false });
3654
+ }
3655
+ });
3656
+ break;
3657
+ }
3627
3658
  case "env.staging.put": {
3628
3659
  // Only the write-only sign-in box owns immutable staging paths. An
3629
3660
  // owned runner or task sidecar must never accept plaintext here.
@@ -4502,6 +4533,8 @@ async function dispatchCommand(
4502
4533
  expectString(args, 0),
4503
4534
  expectString(args, 1),
4504
4535
  expectString(args, 2),
4536
+ args[3] as DeliveryOrigin | undefined,
4537
+ args[4] as AssignmentDelivery | undefined,
4505
4538
  );
4506
4539
  case "channelResolvePermission":
4507
4540
  return hostCommands.channelResolvePermission(
@@ -4515,11 +4548,23 @@ async function dispatchCommand(
4515
4548
  return hostCommands.cloneRepo(ctx, expectCloneRepoInput(args, 0));
4516
4549
  case "taskDiff":
4517
4550
  return hostCommands.taskDiff(ctx, expectTaskDiffInput(args, 0));
4551
+ case "taskProjectPrepare":
4552
+ return hostCommands.taskProjectPrepare(ctx, expectTaskProjectPrepareInput(args, 0));
4553
+ case "taskProjectActivate":
4554
+ return hostCommands.taskProjectActivate(ctx, expectTaskProjectActivateInput(args, 0));
4555
+ case "taskProjectCompensate":
4556
+ return hostCommands.taskProjectCompensate(ctx, expectTaskProjectFence(args[0], "task project compensate input"));
4557
+ case "taskProjectStatus":
4558
+ return hostCommands.taskProjectStatus(ctx, expectTaskProjectStatusInput(args, 0));
4518
4559
  case "attachmentWrite":
4519
4560
  return hostCommands.attachmentWrite(
4520
4561
  ctx,
4521
4562
  expectAttachmentWriteInput(args, 0),
4522
4563
  );
4564
+ case "workspaceFileRead": {
4565
+ const input = expectRecord(args[0], "workspace file read input");
4566
+ return hostCommands.workspaceFileRead(ctx, { taskId: expectStringValue(input.taskId, "taskId"), path: expectStringValue(input.path, "path"), maxBytes: expectNumberArg([input.maxBytes], 0) });
4567
+ }
4523
4568
  case "attachmentRead":
4524
4569
  return hostCommands.attachmentRead(
4525
4570
  ctx,
@@ -4969,6 +5014,7 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
4969
5014
  ) {
4970
5015
  return frame;
4971
5016
  }
5017
+ if (isTaskSecretStagingPut(frame)) return frame;
4972
5018
  if (isProjectEnvStagingPutFrame(frame)) return frame;
4973
5019
  // Sidecar replica authority has a separate durable store and apply lane.
4974
5020
  // Exact guards keep every attempt, cursor and immutable value reference;
@@ -5265,6 +5311,7 @@ function rejectFrameDuringShutdown(
5265
5311
  return true;
5266
5312
  case "env.var.remove":
5267
5313
  case "env.binding.set":
5314
+ case "task.secret.staging.put":
5268
5315
  case "env.staging.put":
5269
5316
  case "env.reconcile.begin":
5270
5317
  case "env.reconciled":
@@ -5431,6 +5478,7 @@ function rejectCredentialFrameOffOwnCredentials(socket: WebSocket, frame: CloudT
5431
5478
  return true;
5432
5479
  case "env.var.remove":
5433
5480
  case "env.binding.set":
5481
+ case "task.secret.staging.put":
5434
5482
  case "env.staging.put":
5435
5483
  case "env.reconcile.begin":
5436
5484
  case "env.reconciled":
@@ -5496,8 +5544,13 @@ const HOST_COMMAND_NAMES = {
5496
5544
  channelResolvePermission: true,
5497
5545
  cloneRepo: true,
5498
5546
  taskDiff: true,
5547
+ taskProjectPrepare: true,
5548
+ taskProjectActivate: true,
5549
+ taskProjectCompensate: true,
5550
+ taskProjectStatus: true,
5499
5551
  attachmentWrite: true,
5500
5552
  attachmentRead: true,
5553
+ workspaceFileRead: true,
5501
5554
  filesOp: true,
5502
5555
  sidecarAssign: true,
5503
5556
  channelInterrupt: true,
@@ -5683,6 +5736,77 @@ function expectTaskDiffInput(args: unknown[], index: number): TaskDiffInput {
5683
5736
  };
5684
5737
  }
5685
5738
 
5739
+ function expectPositiveSafeInteger(value: unknown, label: string): number {
5740
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
5741
+ throw new Error(`invalid command args: expected positive integer ${label}`);
5742
+ }
5743
+ return value as number;
5744
+ }
5745
+
5746
+ function expectTaskProjectFence(value: unknown, label: string): TaskProjectOpFence {
5747
+ const input = expectRecord(value, label);
5748
+ return {
5749
+ taskId: expectStringValue(input.taskId, "taskId"),
5750
+ runEpoch: expectPositiveSafeInteger(input.runEpoch, "runEpoch"),
5751
+ operationId: expectStringValue(input.operationId, "operationId"),
5752
+ projectSetRevision: expectPositiveSafeInteger(input.projectSetRevision, "projectSetRevision"),
5753
+ };
5754
+ }
5755
+
5756
+ function expectTaskProjectPrepareInput(args: unknown[], index: number): TaskProjectPrepareInput {
5757
+ const input = expectRecord(args[index], "task project prepare input");
5758
+ const project = expectRecord(input.project, "task project");
5759
+ return {
5760
+ ...expectTaskProjectFence(input, "task project prepare input"),
5761
+ branch: expectStringValue(input.branch, "branch"),
5762
+ project: {
5763
+ id: expectStringValue(project.id, "project.id"),
5764
+ slug: expectStringValue(project.slug, "project.slug"),
5765
+ repoUrl: expectStringValue(project.repoUrl, "project.repoUrl"),
5766
+ ...(typeof project.toolVersions === "string" ? { toolVersions: project.toolVersions } : {}),
5767
+ },
5768
+ };
5769
+ }
5770
+
5771
+ function expectTaskProjectActivateInput(args: unknown[], index: number): TaskProjectActivateInput {
5772
+ const input = expectRecord(args[index], "task project activate input");
5773
+ let grant: TaskProjectActivateInput["grant"] = null;
5774
+ if (input.grant !== null) {
5775
+ const row = expectRecord(input.grant, "task project grant");
5776
+ grant = {
5777
+ manifestRevision: expectPositiveSafeInteger(row.manifestRevision, "grant.manifestRevision"),
5778
+ manifestDigest: expectStringValue(row.manifestDigest, "grant.manifestDigest"),
5779
+ targetGeneration: expectPositiveSafeInteger(row.targetGeneration, "grant.targetGeneration"),
5780
+ };
5781
+ }
5782
+ return {
5783
+ ...expectTaskProjectFence(input, "task project activate input"),
5784
+ project: expectTaskCommandProject(input.project),
5785
+ previewEnv: expectStringRecord(input.previewEnv ?? {}, "previewEnv"),
5786
+ grant,
5787
+ };
5788
+ }
5789
+
5790
+ function expectStringRecord(value: unknown, label: string): Record<string, string> {
5791
+ const record = expectRecord(value, label);
5792
+ const out: Record<string, string> = {};
5793
+ for (const [key, entry] of Object.entries(record)) {
5794
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid command args: ${label} name ${key}`);
5795
+ out[key] = expectStringValue(entry, `${label}.${key}`);
5796
+ }
5797
+ return out;
5798
+ }
5799
+
5800
+ function expectTaskProjectStatusInput(args: unknown[], index: number): TaskProjectStatusInput {
5801
+ const input = expectRecord(args[index], "task project status input");
5802
+ return {
5803
+ taskId: expectStringValue(input.taskId, "taskId"),
5804
+ runEpoch: expectPositiveSafeInteger(input.runEpoch, "runEpoch"),
5805
+ operationId: expectStringValue(input.operationId, "operationId"),
5806
+ projectId: expectStringValue(input.projectId, "projectId"),
5807
+ };
5808
+ }
5809
+
5686
5810
  function expectAttachmentWriteInput(
5687
5811
  args: unknown[],
5688
5812
  index: number,
@@ -5768,6 +5892,7 @@ function expectChannelEnsureInput(
5768
5892
  // Both are optional for wire compatibility with older clouds.
5769
5893
  const mode = parseChannelMode(input.mode);
5770
5894
  if (mode !== undefined) out.mode = mode;
5895
+ if (typeof input.leadRunId === "string") out.leadRunId = input.leadRunId;
5771
5896
  if (typeof input.secretaryAgentId === "string") {
5772
5897
  out.secretaryAgentId = input.secretaryAgentId;
5773
5898
  }