@runuai/host 0.2.6 → 0.3.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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ADR-043: per-(task, preview) node-proxy sidecar for ad-hoc preview ports.
3
+ *
4
+ * Ad-hoc preview ports aren't Docker-published, and the app container's bridge
5
+ * IP isn't host-routable on macOS/OrbStack (EHOSTUNREACH). So proxy through a
6
+ * tiny sidecar: a container on the task's compose network that PUBLISHES a
7
+ * `127.0.0.1` host port (reachable on every backend) and forwards to
8
+ * `<app-container>:<containerPort>`. The sidecar runs the task's OWN image
9
+ * (always local — no registry pull) under `node` with a ~5-line `net` TCP proxy;
10
+ * node streams give backpressure + handle WebSockets (Metro/HMR).
11
+ *
12
+ * Hot path is cache-only (NO docker call per request — that thrashed under a
13
+ * browser's concurrent requests). The cached port is trusted until a tunnel
14
+ * connect fails, which invalidates it (mirrors the container-IP cache). Declared
15
+ * ports keep their published-port path; only ad-hoc ports use a sidecar.
16
+ */
17
+ import { dockerCli } from "./docker-exec";
18
+
19
+ /** Port the proxy listens on inside the sidecar (published to a random host port). */
20
+ const INNER_PORT = 9000;
21
+ const LABEL = "com.runuai.preview-sidecar.task";
22
+
23
+ /** (taskId,name,containerPort) -> published 127.0.0.1 host port of a live sidecar. */
24
+ const cache = new Map<string, number>();
25
+ /** In-flight creates, so concurrent requests for the same key share one sidecar. */
26
+ const inflight = new Map<string, Promise<number | null>>();
27
+
28
+ function cacheKey(taskId: string, name: string, containerPort: number): string {
29
+ return `${taskId} ${name} ${containerPort}`;
30
+ }
31
+
32
+ function sidecarName(taskId: string, name: string): string {
33
+ return `uai-preview-${taskId}-${name}`;
34
+ }
35
+
36
+ function proxyScript(appContainer: string, containerPort: number): string {
37
+ // Bidirectional TCP pipe: 0.0.0.0:INNER_PORT <-> appContainer:containerPort.
38
+ return (
39
+ `const net=require('net');` +
40
+ `net.createServer(c=>{` +
41
+ `const u=net.connect(${containerPort},${JSON.stringify(appContainer)});` +
42
+ `c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());` +
43
+ `c.pipe(u);u.pipe(c);` +
44
+ `}).listen(${INNER_PORT},'0.0.0.0');`
45
+ );
46
+ }
47
+
48
+ async function imageOf(appContainer: string): Promise<string | null> {
49
+ const r = await dockerCli(
50
+ ["inspect", "-f", "{{.Config.Image}}", appContainer],
51
+ { timeoutMs: 5_000 },
52
+ );
53
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
54
+ }
55
+
56
+ /** `docker port <name>` -> the published 127.0.0.1 host port, or null. */
57
+ async function publishedPort(name: string): Promise<number | null> {
58
+ const r = await dockerCli(["port", name, String(INNER_PORT)], {
59
+ timeoutMs: 5_000,
60
+ });
61
+ const m = r.stdout.match(/127\.0\.0\.1:(\d+)/);
62
+ return r.status === 0 && m ? Number(m[1]) : null;
63
+ }
64
+
65
+ async function createSidecar(args: {
66
+ taskId: string;
67
+ composeProject: string;
68
+ name: string;
69
+ containerPort: number;
70
+ }): Promise<number | null> {
71
+ const name = sidecarName(args.taskId, args.name);
72
+ const appContainer = `${args.composeProject}-app-1`;
73
+
74
+ // Reuse a still-running sidecar from a previous host process (--rm keeps it up
75
+ // across a host restart; the in-memory cache was cleared). `docker port` is an
76
+ // exact-name lookup, so no name-filter regex pitfalls.
77
+ const existing = await publishedPort(name);
78
+ if (existing !== null) return existing;
79
+
80
+ const image = await imageOf(appContainer);
81
+ if (!image) return null;
82
+
83
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
84
+ const run = await dockerCli(
85
+ [
86
+ "run", "-d", "--rm",
87
+ "--name", name,
88
+ "--network", `${args.composeProject}_default`,
89
+ "--label", `${LABEL}=${args.taskId}`,
90
+ "-p", `127.0.0.1::${INNER_PORT}`,
91
+ "--entrypoint", "node",
92
+ image,
93
+ "-e", proxyScript(appContainer, args.containerPort),
94
+ ],
95
+ { timeoutMs: 20_000 },
96
+ );
97
+ if (run.status !== 0) return null;
98
+
99
+ const hostPort = await publishedPort(name);
100
+ if (hostPort === null) {
101
+ await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
102
+ return null;
103
+ }
104
+ return hostPort;
105
+ }
106
+
107
+ /**
108
+ * Ensure a sidecar is proxying `<app>:<containerPort>` for (taskId, name);
109
+ * returns the `127.0.0.1` host port it publishes, or null on failure. Cache-only
110
+ * on the hot path; a cache miss creates the sidecar (deduped across concurrent
111
+ * callers).
112
+ */
113
+ export async function ensurePreviewSidecar(args: {
114
+ taskId: string;
115
+ composeProject: string;
116
+ name: string;
117
+ containerPort: number;
118
+ }): Promise<number | null> {
119
+ const key = cacheKey(args.taskId, args.name, args.containerPort);
120
+ const cached = cache.get(key);
121
+ if (cached !== undefined) return cached;
122
+
123
+ let pending = inflight.get(key);
124
+ if (!pending) {
125
+ pending = createSidecar(args)
126
+ .then((port) => {
127
+ if (port !== null) cache.set(key, port);
128
+ return port;
129
+ })
130
+ .finally(() => inflight.delete(key));
131
+ inflight.set(key, pending);
132
+ }
133
+ return pending;
134
+ }
135
+
136
+ /** Drop any cached sidecar entry on `hostPort` (a tunnel connect failed). No-op
137
+ * for published-declared / container-IP targets, which aren't in this cache. */
138
+ export function invalidatePreviewSidecar(hostPort: number): void {
139
+ for (const [key, port] of cache) {
140
+ if (port === hostPort) cache.delete(key);
141
+ }
142
+ }
143
+
144
+ /** Stop + remove all preview sidecars for a task (called on task-down). */
145
+ export async function stopPreviewSidecars(taskId: string): Promise<void> {
146
+ for (const key of [...cache.keys()]) {
147
+ if (key.startsWith(`${taskId} `)) cache.delete(key);
148
+ }
149
+ const r = await dockerCli(
150
+ ["ps", "-aq", "--filter", `label=${LABEL}=${taskId}`],
151
+ { timeoutMs: 5_000 },
152
+ );
153
+ if (r.status !== 0) return;
154
+ for (const id of r.stdout.split("\n").map((s) => s.trim()).filter(Boolean)) {
155
+ await dockerCli(["rm", "-f", id], { timeoutMs: 10_000 });
156
+ }
157
+ }
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.6",
3
+ "version": "0.3.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/main.ts CHANGED
@@ -25,8 +25,10 @@ import {
25
25
  import { getHostTask } from "../lib/runtime-state";
26
26
  import { getOrchestrator } from "../lib/orchestrator";
27
27
  import {
28
+ connectedUserIds,
28
29
  onConnectClear,
29
30
  onConnectSet,
31
+ onGithubChange,
30
32
  setAuthExpiredHandler,
31
33
  } from "../lib/github-tokens";
32
34
  import {
@@ -47,6 +49,12 @@ import {
47
49
  } from "./paths";
48
50
  import { dockerMemoryBytes, startUiServer } from "./ui/server";
49
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";
50
58
  import { newId } from "../lib/ulid";
51
59
  import {
52
60
  capabilities as agentKindCapabilities,
@@ -166,6 +174,7 @@ function buildCapabilities(): HostCapabilities {
166
174
  return {
167
175
  agentKinds: agentKindCapabilities(),
168
176
  runtimes: standardRuntimes(),
177
+ githubUsers: connectedUserIds(),
169
178
  };
170
179
  }
171
180
 
@@ -181,6 +190,9 @@ function sendCapabilities(): void {
181
190
  }
182
191
 
183
192
  onRegistryChange(() => sendCapabilities());
193
+ // Re-advertise when a gh token is added/removed (ADR-033) so the host page's
194
+ // per-host connected state updates promptly.
195
+ onGithubChange(() => sendCapabilities());
184
196
 
185
197
  function connect(): void {
186
198
  if (stopping || fatal) return;
@@ -256,7 +268,7 @@ function connect(): void {
256
268
  void handleCommand(socket, frame);
257
269
  break;
258
270
  case "tunnel.open":
259
- handleTunnelOpen(socket, frame);
271
+ void handleTunnelOpen(socket, frame);
260
272
  break;
261
273
  case "tunnel.data":
262
274
  pendingBinaryTunnelId = frame.tunnelId;
@@ -284,7 +296,15 @@ function connect(): void {
284
296
  break;
285
297
  }
286
298
  case "gh.connect.clear":
287
- onConnectClear(frame.userId);
299
+ // Delete-then-revoke (ADR-033) is async + best-effort; ack immediately
300
+ // so the UI isn't gated on the GitHub revoke round-trip. The .catch is
301
+ // a belt over onConnectClear's own try/catch — a fire-and-forget
302
+ // rejection must never crash the host.
303
+ void onConnectClear(frame.userId).catch((err) =>
304
+ console.warn(
305
+ `[github] connect.clear failed: ${err instanceof Error ? err.message : err}`,
306
+ ),
307
+ );
288
308
  send(socket, { kind: "gh.connect.ack", userId: frame.userId, ok: true });
289
309
  break;
290
310
  case "ssh.key.get":
@@ -403,12 +423,12 @@ async function handleCommand(
403
423
  }
404
424
  }
405
425
 
406
- function handleTunnelOpen(
426
+ async function handleTunnelOpen(
407
427
  wsSocket: WebSocket,
408
428
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
409
- ): void {
410
- const port = resolveTunnelPort(frame);
411
- if (!port) {
429
+ ): Promise<void> {
430
+ const target = await resolveTunnelTarget(frame);
431
+ if (!target) {
412
432
  send(wsSocket, {
413
433
  kind: "tunnel.ack",
414
434
  tunnelId: frame.tunnelId,
@@ -420,23 +440,23 @@ function handleTunnelOpen(
420
440
  }
421
441
 
422
442
  if (!frame.upgrade) {
423
- handleHttpTunnelOpen(wsSocket, frame, port);
443
+ handleHttpTunnelOpen(wsSocket, frame, target);
424
444
  return;
425
445
  }
426
446
 
427
- handleRawTunnelOpen(wsSocket, frame, port);
447
+ handleRawTunnelOpen(wsSocket, frame, target);
428
448
  }
429
449
 
430
450
  function handleHttpTunnelOpen(
431
451
  wsSocket: WebSocket,
432
452
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
433
- port: number,
453
+ target: UpstreamAddr,
434
454
  ): void {
435
455
  let acked = false;
436
456
  const upstream = httpRequest(
437
457
  {
438
- host: "127.0.0.1",
439
- port,
458
+ host: target.host,
459
+ port: target.port,
440
460
  method: frame.reqLine.method,
441
461
  path: frame.reqLine.url,
442
462
  headers: requestHeaders(frame.reqLine.headers),
@@ -471,6 +491,13 @@ function handleHttpTunnelOpen(
471
491
 
472
492
  upstream.on("error", (err) => {
473
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);
474
501
  if (!acked) {
475
502
  send(wsSocket, {
476
503
  kind: "tunnel.ack",
@@ -497,7 +524,7 @@ function handleHttpTunnelOpen(
497
524
  function handleRawTunnelOpen(
498
525
  wsSocket: WebSocket,
499
526
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
500
- port: number,
527
+ target: UpstreamAddr,
501
528
  ): void {
502
529
  const upstream = new Socket();
503
530
  tunnels.set(frame.tunnelId, upstream);
@@ -533,6 +560,10 @@ function handleRawTunnelOpen(
533
560
  });
534
561
 
535
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);
536
567
  if (!acked) {
537
568
  send(wsSocket, {
538
569
  kind: "tunnel.ack",
@@ -560,7 +591,7 @@ function handleRawTunnelOpen(
560
591
  });
561
592
  });
562
593
 
563
- upstream.connect(port, "127.0.0.1");
594
+ upstream.connect(target.port, target.host);
564
595
  }
565
596
 
566
597
  function closeTunnel(
@@ -574,17 +605,85 @@ function closeTunnel(
574
605
  send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
575
606
  }
576
607
 
577
- 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(
578
650
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
579
- ): number | null {
651
+ ): Promise<UpstreamAddr | null> {
580
652
  const task = getHostTask(frame.taskId);
581
653
  if (!task) return null;
582
- if (frame.target === "editor") return task.codeServerPort ?? null;
583
- if (!frame.name) return null;
584
- const preview = parsePreviewPortRuntimes(task.previewPorts).find(
585
- (port) => port.name === frame.name,
586
- );
587
- 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;
588
687
  }
589
688
 
590
689
  function serializeRequest(reqLine: {
@@ -713,8 +812,12 @@ function dispatchCommand(
713
812
  switch (command) {
714
813
  case "taskUp":
715
814
  return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
716
- case "taskDown":
717
- 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
+ }
718
821
  case "taskStatus":
719
822
  return hostCommands.taskStatus(ctx, expectString(args, 0));
720
823
  case "channelEnsure":
@@ -807,6 +910,10 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
807
910
  target: frame.target,
808
911
  taskId: frame.taskId,
809
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,
810
917
  reqLine: frame.reqLine,
811
918
  upgrade: frame.upgrade,
812
919
  };
@@ -830,7 +937,9 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
830
937
  typeof frame.installationId === "number" &&
831
938
  typeof frame.githubLogin === "string" &&
832
939
  (frame.targetType === "User" || frame.targetType === "Organization") &&
833
- typeof frame.refreshToken === "string"
940
+ // Exactly one token kind: accessToken (ADR-033) or refreshToken (ADR-027).
941
+ (typeof frame.accessToken === "string" ||
942
+ typeof frame.refreshToken === "string")
834
943
  ) {
835
944
  return {
836
945
  kind: "gh.connect.set",
@@ -838,7 +947,10 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
838
947
  installationId: frame.installationId,
839
948
  githubLogin: frame.githubLogin,
840
949
  targetType: frame.targetType,
841
- refreshToken: frame.refreshToken,
950
+ accessToken:
951
+ typeof frame.accessToken === "string" ? frame.accessToken : undefined,
952
+ refreshToken:
953
+ typeof frame.refreshToken === "string" ? frame.refreshToken : undefined,
842
954
  refreshTokenExpiresAt:
843
955
  typeof frame.refreshTokenExpiresAt === "number"
844
956
  ? frame.refreshTokenExpiresAt
@@ -1120,6 +1232,17 @@ function expectTaskCommandTask(value: unknown): TaskCommandTask {
1120
1232
  expectStringValue(id, `task.reviewerOrder[${i}]`),
1121
1233
  );
1122
1234
  }
1235
+ if (
1236
+ row.previewEnv &&
1237
+ typeof row.previewEnv === "object" &&
1238
+ !Array.isArray(row.previewEnv)
1239
+ ) {
1240
+ const pe: Record<string, string> = {};
1241
+ for (const [k, v] of Object.entries(row.previewEnv as Record<string, unknown>)) {
1242
+ if (typeof v === "string") pe[k] = v;
1243
+ }
1244
+ if (Object.keys(pe).length > 0) out.previewEnv = pe;
1245
+ }
1123
1246
  return out;
1124
1247
  }
1125
1248