@runuai/host 0.2.8 → 0.4.0

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/task-diff.ts CHANGED
@@ -4,9 +4,20 @@ import { join } from "node:path";
4
4
 
5
5
  import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
6
6
  import { taskWorkspaceDir } from "./env";
7
- import { parseGitDiff, runGitDiff, runGitDiffInContainer } from "./git-diff";
7
+ import {
8
+ parseGitDiff,
9
+ runGitDiff,
10
+ runGitDiffInContainer,
11
+ runGitDiffUntracked,
12
+ runGitDiffUntrackedInContainer,
13
+ } from "./git-diff";
8
14
  import { getHostTask } from "./runtime-state";
9
15
 
16
+ // Cap how many untracked files we render as new-file patches. Each is a
17
+ // separate (in-container) git spawn, so a pathological untracked dir that
18
+ // slips past .gitignore shouldn't turn the diff into thousands of execs.
19
+ const MAX_UNTRACKED = 200;
20
+
10
21
  export async function buildTaskDiff(
11
22
  input: TaskDiffInput,
12
23
  ): Promise<TaskDiffResult> {
@@ -23,15 +34,40 @@ export async function buildTaskDiff(
23
34
  const containerCwd = `/workspace/${project.slug}`;
24
35
  if (!containerName && !existsSync(cwd)) continue;
25
36
 
26
- // Base branch is derived from the worktree's upstream (the worktree was
27
- // created off `origin/<defaultBranch>` at task-up; ADR-022 derives and
28
- // persists the default branch on the worktree, not the project record).
37
+ // Base branch is the remote's default branch (where task branches are cut
38
+ // from at task-up) NOT the worktree's @{upstream}, which flips to
39
+ // origin/<task-branch> once the branch is pushed and would then hide all
40
+ // committed work (see resolveBase).
29
41
  const base = resolveBase(containerName, cwd, containerCwd, project.slug);
30
- const range = `${base}..HEAD`;
42
+ // Diff the whole branch against base, including UNCOMMITTED work: compare
43
+ // the merge-base (where the branch diverged) to the WORKING TREE, not HEAD.
44
+ // The merge-base keeps base's own later commits from showing up reversed if
45
+ // it advanced; falling back to `base` itself if merge-base can't resolve.
46
+ const mergeBase =
47
+ runGitText(containerName, cwd, containerCwd, [
48
+ "merge-base",
49
+ base,
50
+ "HEAD",
51
+ ]) ?? base;
31
52
  try {
32
- const text = containerName
33
- ? await runGitDiffInContainer(containerName, containerCwd, range)
34
- : await runGitDiff(cwd, range);
53
+ // Tracked changes (committed + staged + unstaged): a commit-ish vs the
54
+ // working tree, so omitting `..HEAD` is the point.
55
+ let text = containerName
56
+ ? await runGitDiffInContainer(containerName, containerCwd, mergeBase)
57
+ : await runGitDiff(cwd, mergeBase);
58
+
59
+ // Untracked files: `git diff <commit>` skips them, so list (honouring
60
+ // .gitignore) and append each as a /dev/null new-file patch.
61
+ const untracked = listUntracked(containerName, cwd, containerCwd);
62
+ for (const file of untracked) {
63
+ const patch = containerName
64
+ ? await runGitDiffUntrackedInContainer(containerName, containerCwd, file)
65
+ : await runGitDiffUntracked(cwd, file);
66
+ if (patch.trim().length === 0) continue;
67
+ if (text.length > 0 && !text.endsWith("\n")) text += "\n";
68
+ text += patch;
69
+ }
70
+
35
71
  out.push({
36
72
  id: project.id,
37
73
  name: project.slug,
@@ -55,10 +91,16 @@ export async function buildTaskDiff(
55
91
  }
56
92
 
57
93
  /**
58
- * Resolve the diff base for a worktree. Prefer the configured upstream
59
- * (`@{upstream}`); fall back to the remote's default branch
60
- * (`origin/HEAD`), then `origin/main`. Runs git in-container when the task
61
- * is live so credential behavior stays inside the sandbox (see git-diff).
94
+ * Resolve the diff base for a worktree: the remote's default branch
95
+ * (`origin/HEAD`, set at task-up via `remote set-head`), falling back to
96
+ * `origin/main`. Runs git in-container when the task is live so credential
97
+ * behavior stays inside the sandbox (see git-diff).
98
+ *
99
+ * We deliberately do NOT use the worktree's `@{upstream}`. At task-up it is
100
+ * `origin/<defaultBranch>`, but once the task branch is pushed (e.g. a PR is
101
+ * opened) its upstream flips to `origin/<task-branch>` — then
102
+ * `merge-base(upstream, HEAD) ≈ HEAD`, so the diff would show only uncommitted
103
+ * work instead of the whole branch.
62
104
  */
63
105
  function resolveBase(
64
106
  containerName: string | null,
@@ -66,14 +108,6 @@ function resolveBase(
66
108
  containerCwd: string,
67
109
  _slug: string,
68
110
  ): string {
69
- const upstream = runGitText(
70
- containerName,
71
- hostCwd,
72
- containerCwd,
73
- ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
74
- );
75
- if (upstream) return upstream;
76
-
77
111
  const head = runGitText(
78
112
  containerName,
79
113
  hostCwd,
@@ -89,6 +123,27 @@ function stripOriginPrefix(ref: string): string {
89
123
  return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
90
124
  }
91
125
 
126
+ /**
127
+ * List untracked files in the worktree, honouring .gitignore
128
+ * (`--exclude-standard`), capped at {@link MAX_UNTRACKED}. Empty on error.
129
+ */
130
+ function listUntracked(
131
+ containerName: string | null,
132
+ hostCwd: string,
133
+ containerCwd: string,
134
+ ): string[] {
135
+ const raw = runGitText(containerName, hostCwd, containerCwd, [
136
+ "ls-files",
137
+ "--others",
138
+ "--exclude-standard",
139
+ ]);
140
+ if (!raw) return [];
141
+ return raw
142
+ .split("\n")
143
+ .filter((line) => line.length > 0)
144
+ .slice(0, MAX_UNTRACKED);
145
+ }
146
+
92
147
  /**
93
148
  * Run a read-only git command (host or in-container) and return trimmed
94
149
  * stdout, or null on any failure. Used for base-branch resolution where a
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Task-token minting (ADR-048) — the host mints the token the in-container `uai`
3
+ * CLI uses; the cloud verifies it (lib/task-token.ts). The signing key is the
4
+ * TASK's ephemeral `cliSecret`, issued by the cloud at task-up (not a static
5
+ * shared secret). Algorithm MUST stay identical to the cloud side.
6
+ */
7
+ import { createHmac } from "node:crypto";
8
+
9
+ export interface TaskTokenPayload {
10
+ taskId: string;
11
+ userId: string;
12
+ permissions: string[];
13
+ agentId?: string;
14
+ }
15
+
16
+ const PREFIX = "uai_";
17
+
18
+ function b64url(buf: Buffer): string {
19
+ return buf
20
+ .toString("base64")
21
+ .replace(/\+/g, "-")
22
+ .replace(/\//g, "_")
23
+ .replace(/=+$/, "");
24
+ }
25
+
26
+ export function signTaskToken(payload: TaskTokenPayload, secret: string): string {
27
+ const body = b64url(
28
+ Buffer.from(
29
+ JSON.stringify({
30
+ t: payload.taskId,
31
+ u: payload.userId,
32
+ p: payload.permissions,
33
+ ...(payload.agentId ? { a: payload.agentId } : {}),
34
+ iat: Math.floor(Date.now() / 1000),
35
+ }),
36
+ ),
37
+ );
38
+ const sig = b64url(createHmac("sha256", secret).update(body).digest());
39
+ return `${PREFIX}${body}.${sig}`;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.8",
3
+ "version": "0.4.0",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -309,11 +309,11 @@ fi
309
309
  done < <(jq -c '.[]' <<<"$projects_json")
310
310
  printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
311
311
  printf ' ports:\n'
312
+ # code-server (the Editor tunnel) is the only auto-published port. Preview
313
+ # ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —
314
+ # each preview the user enables is reached lazily through a per-(task,preview)
315
+ # sidecar attached to the compose network, so nothing is exposed by default.
312
316
  printf ' - "127.0.0.1::8080"\n'
313
- while IFS= read -r cport; do
314
- [ -n "$cport" ] || continue
315
- printf ' - "127.0.0.1::%s"\n' "$cport"
316
- done < <(jq -r '.[].containerPort' <<<"$union_preview_ports_json")
317
317
  printf ' environment:\n'
318
318
  # Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
319
319
  # uses the interactive subscription/keychain path, so it needs a token from
@@ -345,6 +345,19 @@ fi
345
345
  esac
346
346
  printf ' %s: "${%s:-}"\n' "$ekey" "$ekey"
347
347
  done < <(jq -r '.[]' <<<"$union_env_keys_json")
348
+ # Preview-URL env vars (ADR-025): cloud-computed PUBLIC preview URLs exposed
349
+ # under operator-chosen names (e.g. EXPO_PACKAGER_PROXY_URL). Non-secret, so
350
+ # written LITERALLY (unlike the ${KEY:-} pass-throughs above). `preview_env`
351
+ # is a JSON object {VAR: url} on the task row, one entry per preview port that
352
+ # set `urlEnv`. Emitted last so it wins over any same-named declared key.
353
+ preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
354
+ while IFS= read -r pe_entry; do
355
+ [ -n "$pe_entry" ] || continue
356
+ pe_key=$(jq -r '.key' <<<"$pe_entry")
357
+ pe_val=$(jq -r '.value' <<<"$pe_entry")
358
+ [ -n "$pe_key" ] || continue
359
+ printf ' %s: "%s"\n' "$pe_key" "$pe_val"
360
+ done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
348
361
  printf 'volumes:\n'
349
362
  printf ' %s:\n' "$ASDF_VOLUME"
350
363
  printf ' external: true\n'
@@ -441,29 +454,12 @@ if [ -z "$code_server_port" ]; then
441
454
  log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
442
455
  fi
443
456
 
457
+ # ADR-043 Stage 2: preview ports are no longer published at task-up, so there is
458
+ # nothing to discover here. `preview_ports` stays empty and every enabled preview
459
+ # is reached through its sidecar (started lazily on first access via the cloud
460
+ # tunnel). `union_preview_ports_json` is still surfaced to the cloud (GET task
461
+ # lists the declared/ad-hoc names the user can turn on).
444
462
  preview_ports_runtime_json="[]"
445
- if [ "$(jq 'length' <<<"$union_preview_ports_json")" -gt 0 ]; then
446
- preview_port_lines=""
447
- while IFS= read -r preview_obj; do
448
- [ -n "$preview_obj" ] || continue
449
- preview_name=$(jq -r '.name' <<<"$preview_obj")
450
- preview_container_port=$(jq -r '.containerPort' <<<"$preview_obj")
451
- preview_host_port=$(mapped_host_port "$app_container" "$preview_container_port")
452
- if [ -z "$preview_host_port" ]; then
453
- log "warning: could not discover preview port ${preview_name}:${preview_container_port} for ${app_container}"
454
- continue
455
- fi
456
- preview_port_lines="${preview_port_lines}$(jq -nc \
457
- --arg name "$preview_name" \
458
- --arg hp "$preview_host_port" \
459
- '{name:$name,hostPort:($hp|tonumber)}')
460
- "
461
- done < <(jq -c '.[]' <<<"$union_preview_ports_json")
462
-
463
- if [ -n "$preview_port_lines" ]; then
464
- preview_ports_runtime_json=$(printf '%s' "$preview_port_lines" | jq -s -c '.')
465
- fi
466
- fi
467
463
 
468
464
  step "DB_UPDATE_FAILED" "mark task running"
469
465
  cs_sql="code_server_port=NULL"
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { agent, AgentError } from "../lib/agent";
2
2
  import { cloneRepo } from "../lib/repo-clone";
3
3
  import { getOrchestrator } from "../lib/orchestrator";
4
+ import { storeTaskCliSecret } from "../lib/agent-cli";
4
5
  import { setupTaskGithub, clearRefresh } from "../lib/github-tokens";
5
6
  import { readAttachment, writeAttachment } from "../lib/attachments";
6
7
  import { appendTranscript as writeTranscript } from "../lib/transcript";
@@ -107,6 +108,11 @@ export const hostCommands: HostCommands = {
107
108
  input.ownerName ?? null,
108
109
  input.projects.map((p) => p.slug),
109
110
  );
111
+ // ADR-048: persist the cloud-issued per-task cli secret (host-private) so the
112
+ // `uai` CLI's per-agent tokens can be minted now + after a host restart.
113
+ if (input.task.cliSecret) {
114
+ storeTaskCliSecret(input.task.id, input.task.cliSecret);
115
+ }
110
116
  recordHostEvent(input.task.id, "task.created");
111
117
  const result = await wrapAgent(ctx, "taskUp", () => agent.taskUp(input));
112
118
  if (result.ok) {
package/src/main.ts CHANGED
@@ -49,6 +49,12 @@ import {
49
49
  } from "./paths";
50
50
  import { dockerMemoryBytes, startUiServer } from "./ui/server";
51
51
  import { parsePreviewPortRuntimes } from "../lib/preview-ports";
52
+ import { dockerCli } from "../lib/docker-exec";
53
+ import {
54
+ ensurePreviewSidecar,
55
+ invalidatePreviewSidecar,
56
+ stopPreviewSidecars,
57
+ } from "../lib/preview-sidecar";
52
58
  import { newId } from "../lib/ulid";
53
59
  import {
54
60
  capabilities as agentKindCapabilities,
@@ -262,7 +268,7 @@ function connect(): void {
262
268
  void handleCommand(socket, frame);
263
269
  break;
264
270
  case "tunnel.open":
265
- handleTunnelOpen(socket, frame);
271
+ void handleTunnelOpen(socket, frame);
266
272
  break;
267
273
  case "tunnel.data":
268
274
  pendingBinaryTunnelId = frame.tunnelId;
@@ -417,12 +423,12 @@ async function handleCommand(
417
423
  }
418
424
  }
419
425
 
420
- function handleTunnelOpen(
426
+ async function handleTunnelOpen(
421
427
  wsSocket: WebSocket,
422
428
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
423
- ): void {
424
- const port = resolveTunnelPort(frame);
425
- if (!port) {
429
+ ): Promise<void> {
430
+ const target = await resolveTunnelTarget(frame);
431
+ if (!target) {
426
432
  send(wsSocket, {
427
433
  kind: "tunnel.ack",
428
434
  tunnelId: frame.tunnelId,
@@ -434,23 +440,23 @@ function handleTunnelOpen(
434
440
  }
435
441
 
436
442
  if (!frame.upgrade) {
437
- handleHttpTunnelOpen(wsSocket, frame, port);
443
+ handleHttpTunnelOpen(wsSocket, frame, target);
438
444
  return;
439
445
  }
440
446
 
441
- handleRawTunnelOpen(wsSocket, frame, port);
447
+ handleRawTunnelOpen(wsSocket, frame, target);
442
448
  }
443
449
 
444
450
  function handleHttpTunnelOpen(
445
451
  wsSocket: WebSocket,
446
452
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
447
- port: number,
453
+ target: UpstreamAddr,
448
454
  ): void {
449
455
  let acked = false;
450
456
  const upstream = httpRequest(
451
457
  {
452
- host: "127.0.0.1",
453
- port,
458
+ host: target.host,
459
+ port: target.port,
454
460
  method: frame.reqLine.method,
455
461
  path: frame.reqLine.url,
456
462
  headers: requestHeaders(frame.reqLine.headers),
@@ -485,6 +491,13 @@ function handleHttpTunnelOpen(
485
491
 
486
492
  upstream.on("error", (err) => {
487
493
  tunnels.delete(frame.tunnelId);
494
+ // A connect failure to a cached container IP likely means the container was
495
+ // recreated (resume) and got a new IP — drop the cache so the next request
496
+ // re-resolves (ADR-036).
497
+ invalidateContainerIpByAddr(target.host);
498
+ // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
499
+ // the next request recreates it (no-op for published / container-IP targets).
500
+ invalidatePreviewSidecar(target.port);
488
501
  if (!acked) {
489
502
  send(wsSocket, {
490
503
  kind: "tunnel.ack",
@@ -511,7 +524,7 @@ function handleHttpTunnelOpen(
511
524
  function handleRawTunnelOpen(
512
525
  wsSocket: WebSocket,
513
526
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
514
- port: number,
527
+ target: UpstreamAddr,
515
528
  ): void {
516
529
  const upstream = new Socket();
517
530
  tunnels.set(frame.tunnelId, upstream);
@@ -547,6 +560,10 @@ function handleRawTunnelOpen(
547
560
  });
548
561
 
549
562
  upstream.on("error", (err) => {
563
+ invalidateContainerIpByAddr(target.host);
564
+ // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
565
+ // the next request recreates it (no-op for published / container-IP targets).
566
+ invalidatePreviewSidecar(target.port);
550
567
  if (!acked) {
551
568
  send(wsSocket, {
552
569
  kind: "tunnel.ack",
@@ -574,7 +591,7 @@ function handleRawTunnelOpen(
574
591
  });
575
592
  });
576
593
 
577
- upstream.connect(port, "127.0.0.1");
594
+ upstream.connect(target.port, target.host);
578
595
  }
579
596
 
580
597
  function closeTunnel(
@@ -588,17 +605,85 @@ function closeTunnel(
588
605
  send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
589
606
  }
590
607
 
591
- function resolveTunnelPort(
608
+ /** Where a tunnel's bytes go: `127.0.0.1:<published>` for the editor, or a
609
+ * container's `<ip>:<containerPort>` for a preview (ADR-036). */
610
+ interface UpstreamAddr {
611
+ host: string;
612
+ port: number;
613
+ }
614
+
615
+ // Container IP cache (ADR-036): `docker inspect` is too slow for the request
616
+ // hot path, so cache the app container's Docker-network IP per compose project.
617
+ // Short TTL + invalidate-on-connect-error so a resumed container's new IP heals.
618
+ const CONTAINER_IP_TTL_MS = 60_000;
619
+ const containerIpCache = new Map<string, { ip: string; ts: number }>();
620
+
621
+ async function resolveContainerIp(composeProject: string): Promise<string | null> {
622
+ const container = `${composeProject}-app-1`;
623
+ const cached = containerIpCache.get(container);
624
+ if (cached && Date.now() - cached.ts < CONTAINER_IP_TTL_MS) return cached.ip;
625
+ const res = await dockerCli(
626
+ [
627
+ "inspect",
628
+ "-f",
629
+ "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
630
+ container,
631
+ ],
632
+ { timeoutMs: 5_000 },
633
+ );
634
+ if (res.status !== 0) return null;
635
+ const ip = res.stdout.trim();
636
+ if (!ip) return null;
637
+ containerIpCache.set(container, { ip, ts: Date.now() });
638
+ return ip;
639
+ }
640
+
641
+ /** Drop any cached container IP equal to `addr` (no-op for `127.0.0.1`). */
642
+ function invalidateContainerIpByAddr(addr: string): void {
643
+ if (addr === "127.0.0.1") return;
644
+ for (const [key, val] of containerIpCache) {
645
+ if (val.ip === addr) containerIpCache.delete(key);
646
+ }
647
+ }
648
+
649
+ async function resolveTunnelTarget(
592
650
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
593
- ): number | null {
651
+ ): Promise<UpstreamAddr | null> {
594
652
  const task = getHostTask(frame.taskId);
595
653
  if (!task) return null;
596
- if (frame.target === "editor") return task.codeServerPort ?? null;
597
- if (!frame.name) return null;
598
- const preview = parsePreviewPortRuntimes(task.previewPorts).find(
599
- (port) => port.name === frame.name,
600
- );
601
- return preview?.hostPort ?? null;
654
+ if (frame.target === "editor") {
655
+ return task.codeServerPort
656
+ ? { host: "127.0.0.1", port: task.codeServerPort }
657
+ : null;
658
+ }
659
+ // Preview. Prefer the PUBLISHED host port for declared previews (published at
660
+ // task-up): a 127.0.0.1 port that's reachable on every backend, incl.
661
+ // macOS/OrbStack where the container bridge IP is NOT host-routable (ADR-043).
662
+ if (frame.name) {
663
+ const declared = parsePreviewPortRuntimes(task.previewPorts).find(
664
+ (port) => port.name === frame.name,
665
+ );
666
+ if (declared) return { host: "127.0.0.1", port: declared.hostPort };
667
+ // Ad-hoc port (not published): proxy via a node-proxy sidecar that publishes
668
+ // a 127.0.0.1 port forwarding to <app>:<containerPort> (ADR-043).
669
+ if (frame.containerPort && task.composeProject) {
670
+ const hostPort = await ensurePreviewSidecar({
671
+ taskId: frame.taskId,
672
+ composeProject: task.composeProject,
673
+ name: frame.name,
674
+ containerPort: frame.containerPort,
675
+ });
676
+ if (hostPort) return { host: "127.0.0.1", port: hostPort };
677
+ }
678
+ }
679
+ // Linux fallback (pre-ADR-043 / non-macOS host where bridge IPs route): proxy
680
+ // straight to the container IP. Unreachable on macOS/OrbStack — the
681
+ // connect-error path returns 502 there.
682
+ if (frame.containerPort && task.composeProject) {
683
+ const ip = await resolveContainerIp(task.composeProject);
684
+ if (ip) return { host: ip, port: frame.containerPort };
685
+ }
686
+ return null;
602
687
  }
603
688
 
604
689
  function serializeRequest(reqLine: {
@@ -727,8 +812,12 @@ function dispatchCommand(
727
812
  switch (command) {
728
813
  case "taskUp":
729
814
  return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
730
- case "taskDown":
731
- return hostCommands.taskDown(ctx, expectTaskDownInput(args, 0));
815
+ case "taskDown": {
816
+ const downInput = expectTaskDownInput(args, 0);
817
+ // ADR-043: tear down this task's preview sidecars. Best-effort.
818
+ void stopPreviewSidecars(downInput.taskId).catch(() => {});
819
+ return hostCommands.taskDown(ctx, downInput);
820
+ }
732
821
  case "taskStatus":
733
822
  return hostCommands.taskStatus(ctx, expectString(args, 0));
734
823
  case "channelEnsure":
@@ -821,6 +910,10 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
821
910
  target: frame.target,
822
911
  taskId: frame.taskId,
823
912
  name: typeof frame.name === "string" ? frame.name : undefined,
913
+ // ADR-043: cloud-resolved in-container port for ad-hoc previews; feeds the
914
+ // node-proxy sidecar (NOT the unreachable bridge IP — see resolveTunnelTarget).
915
+ containerPort:
916
+ typeof frame.containerPort === "number" ? frame.containerPort : undefined,
824
917
  reqLine: frame.reqLine,
825
918
  upgrade: frame.upgrade,
826
919
  };
@@ -987,12 +1080,35 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
987
1080
  kind: expectStringValue(row.kind, "agent.kind"),
988
1081
  };
989
1082
  if (typeof row.model === "string") out.model = row.model;
1083
+ // effort was previously dropped here even though the protocol + adapters
1084
+ // carry it — fixed alongside skills (ADR-046).
1085
+ if (typeof row.effort === "string") out.effort = row.effort;
990
1086
  if (typeof row.defaultPrompt === "string") {
991
1087
  out.defaultPrompt = row.defaultPrompt;
992
1088
  }
993
1089
  if (typeof row.initialPrompt === "string") {
994
1090
  out.initialPrompt = row.initialPrompt;
995
1091
  }
1092
+ if (Array.isArray(row.skills)) {
1093
+ const skills = row.skills
1094
+ .filter(
1095
+ (s): s is Record<string, unknown> =>
1096
+ typeof s === "object" && s !== null,
1097
+ )
1098
+ .map((s) => ({
1099
+ name: typeof s.name === "string" ? s.name : "",
1100
+ type: typeof s.type === "string" ? s.type : "",
1101
+ value: typeof s.value === "string" ? s.value : "",
1102
+ source: typeof s.source === "string" ? s.source : null,
1103
+ subpath: typeof s.subpath === "string" ? s.subpath : null,
1104
+ }))
1105
+ .filter((s) => s.name && s.type);
1106
+ if (skills.length > 0) out.skills = skills;
1107
+ }
1108
+ if (Array.isArray(row.permissions)) {
1109
+ const perms = row.permissions.filter((p): p is string => typeof p === "string");
1110
+ if (perms.length > 0) out.permissions = perms;
1111
+ }
996
1112
  return out;
997
1113
  });
998
1114
  }
@@ -1139,6 +1255,17 @@ function expectTaskCommandTask(value: unknown): TaskCommandTask {
1139
1255
  expectStringValue(id, `task.reviewerOrder[${i}]`),
1140
1256
  );
1141
1257
  }
1258
+ if (
1259
+ row.previewEnv &&
1260
+ typeof row.previewEnv === "object" &&
1261
+ !Array.isArray(row.previewEnv)
1262
+ ) {
1263
+ const pe: Record<string, string> = {};
1264
+ for (const [k, v] of Object.entries(row.previewEnv as Record<string, unknown>)) {
1265
+ if (typeof v === "string") pe[k] = v;
1266
+ }
1267
+ if (Object.keys(pe).length > 0) out.previewEnv = pe;
1268
+ }
1142
1269
  return out;
1143
1270
  }
1144
1271
 
package/src/protocol.ts CHANGED
@@ -48,6 +48,17 @@ export interface TaskAgent {
48
48
  effort?: string;
49
49
  defaultPrompt?: string;
50
50
  initialPrompt?: string;
51
+ /** ADR-046 reference material resolved from the agent's persona + team.
52
+ * ADR-047: `type: "package"` carries `source` ('git'|'cli') + `subpath`. */
53
+ skills?: Array<{
54
+ name: string;
55
+ type: string;
56
+ value: string;
57
+ source?: string | null;
58
+ subpath?: string | null;
59
+ }>;
60
+ /** ADR-048: the source persona's flat permission list for the `uai` CLI. */
61
+ permissions?: string[];
51
62
  }
52
63
 
53
64
  /**
@@ -112,9 +123,20 @@ export interface TaskCommandTask {
112
123
  slug: string;
113
124
  branch: string;
114
125
  status: string;
126
+ /** ADR-048: the task's ephemeral `uai` CLI secret — the host signs per-agent
127
+ * tokens with it. Cloud-issued at task-up; never a static shared secret. */
128
+ cliSecret?: string;
115
129
  globalContext?: string;
116
130
  reviewerOrder?: string[];
117
131
  agents: TaskAgent[];
132
+ /**
133
+ * Cloud-computed PUBLIC preview URLs to inject into the container env at
134
+ * task-up, keyed by the operator-chosen var name (e.g.
135
+ * `EXPO_PACKAGER_PROXY_URL`). Non-secret — written literally into the task's
136
+ * compose file. Only present on task-up commands, when a preview port set
137
+ * `urlEnv` and the cloud has a preview base domain configured.
138
+ */
139
+ previewEnv?: Record<string, string>;
118
140
  }
119
141
 
120
142
  /**
@@ -299,6 +321,11 @@ export type CloudToHost =
299
321
  target: TunnelTarget;
300
322
  taskId: string;
301
323
  name?: string;
324
+ // The in-container port to reach for a preview (ADR-036). The cloud
325
+ // resolves it from the task's declared + ad-hoc preview ports; the host
326
+ // proxies to <container-ip>:<containerPort> over the Docker network.
327
+ // Absent for the editor target (which uses the published codeServerPort).
328
+ containerPort?: number;
302
329
  reqLine: TunnelReqLine;
303
330
  upgrade: boolean;
304
331
  }