@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/agent-cli.ts +223 -0
- package/lib/agents/claude.ts +12 -2
- package/lib/agents/codex.ts +16 -2
- package/lib/agents/proc.ts +9 -0
- package/lib/agents/registry.ts +11 -1
- package/lib/agents/types.ts +18 -0
- package/lib/command-db.ts +6 -2
- package/lib/git-diff.ts +67 -16
- package/lib/orchestrator.ts +153 -18
- package/lib/preview-sidecar.ts +157 -0
- package/lib/skills.ts +263 -0
- package/lib/task-diff.ts +75 -20
- package/lib/task-token.ts +40 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +22 -26
- package/src/index.ts +6 -0
- package/src/main.ts +149 -22
- package/src/protocol.ts +27 -0
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
|
|
@@ -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
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/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
|
|
425
|
-
if (!
|
|
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,
|
|
443
|
+
handleHttpTunnelOpen(wsSocket, frame, target);
|
|
438
444
|
return;
|
|
439
445
|
}
|
|
440
446
|
|
|
441
|
-
handleRawTunnelOpen(wsSocket, frame,
|
|
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
|
-
|
|
453
|
+
target: UpstreamAddr,
|
|
448
454
|
): void {
|
|
449
455
|
let acked = false;
|
|
450
456
|
const upstream = httpRequest(
|
|
451
457
|
{
|
|
452
|
-
host:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
):
|
|
651
|
+
): Promise<UpstreamAddr | null> {
|
|
594
652
|
const task = getHostTask(frame.taskId);
|
|
595
653
|
if (!task) return null;
|
|
596
|
-
if (frame.target === "editor")
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
}
|