@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.
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +4 -0
- package/lib/agents/claude.ts +8 -0
- package/lib/agents/codex.ts +11 -0
- package/lib/agents/registry.ts +11 -1
- package/lib/command-db.ts +6 -2
- package/lib/git-diff.ts +67 -16
- package/lib/github-tokens.ts +149 -32
- package/lib/orchestrator.ts +59 -16
- package/lib/preview-sidecar.ts +157 -0
- package/lib/task-diff.ts +75 -20
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +22 -26
- package/src/main.ts +148 -25
- package/src/protocol.ts +28 -4
|
@@ -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 {
|
|
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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
59
|
-
* (
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
package/scripts/agent/task-up.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
411
|
-
if (!
|
|
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,
|
|
443
|
+
handleHttpTunnelOpen(wsSocket, frame, target);
|
|
424
444
|
return;
|
|
425
445
|
}
|
|
426
446
|
|
|
427
|
-
handleRawTunnelOpen(wsSocket, frame,
|
|
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
|
-
|
|
453
|
+
target: UpstreamAddr,
|
|
434
454
|
): void {
|
|
435
455
|
let acked = false;
|
|
436
456
|
const upstream = httpRequest(
|
|
437
457
|
{
|
|
438
|
-
host:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
):
|
|
651
|
+
): Promise<UpstreamAddr | null> {
|
|
580
652
|
const task = getHostTask(frame.taskId);
|
|
581
653
|
if (!task) return null;
|
|
582
|
-
if (frame.target === "editor")
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|