@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/agent-cli.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-container `uai` CLI materialisation (ADR-048).
|
|
3
|
+
*
|
|
4
|
+
* At task-up the host writes two files into the task workspace (bind-mounted at
|
|
5
|
+
* /workspace, like skills — no docker cp/exec needed):
|
|
6
|
+
* - `.uai/cli.mjs` — the self-contained agent CLI (Node ESM, fetch-only).
|
|
7
|
+
* - `.uai/uai.json` — { apiUrl, token, permissions } the CLI reads.
|
|
8
|
+
*
|
|
9
|
+
* The token is minted here (host-side) carrying the task id, its owner user, and
|
|
10
|
+
* the UNION of the roster's permissions; the cloud verifies it. Agents invoke it
|
|
11
|
+
* as `node /workspace/.uai/cli.mjs <cmd>` (surfaced in the system preamble).
|
|
12
|
+
*/
|
|
13
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { env, taskWorkspaceDir } from "./env";
|
|
17
|
+
import { signTaskToken } from "./task-token";
|
|
18
|
+
import type { RosterAgent } from "./agents/types";
|
|
19
|
+
|
|
20
|
+
/** Container path of the CLI — what the preamble tells agents to run. */
|
|
21
|
+
export const CONTAINER_CLI_PATH = "/workspace/.uai/cli.mjs";
|
|
22
|
+
|
|
23
|
+
// The task's cli secret lives HOST-PRIVATE (never the container), so it survives
|
|
24
|
+
// host restarts without a DB migration. The cloud re-sends it on each task-up.
|
|
25
|
+
function taskSecretPath(taskId: string): string {
|
|
26
|
+
return resolve(env.dataDir, "task-secrets", taskId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Persist the cloud-issued per-task cli secret (ADR-048). Best-effort. */
|
|
30
|
+
export function storeTaskCliSecret(taskId: string, secret: string): void {
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(resolve(env.dataDir, "task-secrets"), { recursive: true });
|
|
33
|
+
writeFileSync(taskSecretPath(taskId), secret, { mode: 0o600 });
|
|
34
|
+
} catch {
|
|
35
|
+
// best-effort — the CLI is simply unavailable if we can't persist it
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the per-task cli secret, or null if we don't have it. */
|
|
40
|
+
export function loadTaskCliSecret(taskId: string): string | null {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(taskSecretPath(taskId), "utf8").trim() || null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The union of a roster's permissions (ADR-048) — the task's effective powers. */
|
|
49
|
+
export function rosterPermissions(roster: RosterAgent[]): string[] {
|
|
50
|
+
return Array.from(new Set(roster.flatMap((a) => a.permissions ?? [])));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Derive the cloud HTTPS API base from the host's cloud WSS url. */
|
|
54
|
+
export function apiUrlFromCloudUrl(cloudUrl: string | undefined): string | null {
|
|
55
|
+
if (!cloudUrl) return null;
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(cloudUrl);
|
|
58
|
+
const proto = u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
|
|
59
|
+
return `${proto}//${u.host}`;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write the CLI script + shared config (apiUrl only — NO token) into the task
|
|
67
|
+
* workspace. Each agent's OWN token is injected per-agent via its docker exec
|
|
68
|
+
* env (agentCliEnv), so no shared file ever holds a token. Best-effort +
|
|
69
|
+
* idempotent. No-op (false) if there's no api url or the roster has zero
|
|
70
|
+
* permissions — nothing the CLI could do.
|
|
71
|
+
*/
|
|
72
|
+
export function writeAgentCli(
|
|
73
|
+
taskId: string,
|
|
74
|
+
roster: RosterAgent[],
|
|
75
|
+
apiUrl: string | null,
|
|
76
|
+
): boolean {
|
|
77
|
+
if (!apiUrl || rosterPermissions(roster).length === 0) return false;
|
|
78
|
+
try {
|
|
79
|
+
const dir = resolve(taskWorkspaceDir(taskId), ".uai");
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
writeFileSync(resolve(dir, "uai.json"), `${JSON.stringify({ apiUrl }, null, 2)}\n`);
|
|
82
|
+
const cliPath = resolve(dir, "cli.mjs");
|
|
83
|
+
writeFileSync(cliPath, CLI_SOURCE);
|
|
84
|
+
try {
|
|
85
|
+
chmodSync(cliPath, 0o755);
|
|
86
|
+
} catch {
|
|
87
|
+
// best-effort
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
|
|
97
|
+
* task token (carrying only ITS permissions) + the API url. Empty when the agent
|
|
98
|
+
* has no permissions (or no owner/url) — its `uai` CLI then reports "not
|
|
99
|
+
* available" rather than borrowing anyone else's powers.
|
|
100
|
+
*/
|
|
101
|
+
export function agentCliEnv(
|
|
102
|
+
taskId: string,
|
|
103
|
+
agent: RosterAgent,
|
|
104
|
+
ownerUserId: string | null,
|
|
105
|
+
apiUrl: string | null,
|
|
106
|
+
cliSecret: string | null,
|
|
107
|
+
): Record<string, string> {
|
|
108
|
+
const permissions = agent.permissions ?? [];
|
|
109
|
+
if (!ownerUserId || !apiUrl || !cliSecret || permissions.length === 0) return {};
|
|
110
|
+
return {
|
|
111
|
+
UAI_TASK_TOKEN: signTaskToken(
|
|
112
|
+
{ taskId, userId: ownerUserId, permissions, agentId: agent.id },
|
|
113
|
+
cliSecret,
|
|
114
|
+
),
|
|
115
|
+
UAI_API_URL: apiUrl,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// The CLI itself — kept as a self-contained ESM string so the host writes ONE
|
|
121
|
+
// file with no dependencies (Node 22 in the image provides global fetch).
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const CLI_SOURCE = `#!/usr/bin/env node
|
|
125
|
+
// uai — in-task agent CLI (ADR-048). Talks to the cloud agent API as the task.
|
|
126
|
+
import { readFileSync } from "node:fs";
|
|
127
|
+
import { dirname, join } from "node:path";
|
|
128
|
+
import { fileURLToPath } from "node:url";
|
|
129
|
+
|
|
130
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
131
|
+
let cfg = {};
|
|
132
|
+
try { cfg = JSON.parse(readFileSync(join(here, "uai.json"), "utf8")); } catch {}
|
|
133
|
+
// Per-agent token comes from the env (each agent's docker exec carries only its
|
|
134
|
+
// own — ADR-048); apiUrl from env or the shared config file.
|
|
135
|
+
const TOKEN = process.env.UAI_TASK_TOKEN || cfg.token;
|
|
136
|
+
const API_URL = process.env.UAI_API_URL || cfg.apiUrl;
|
|
137
|
+
if (!TOKEN || !API_URL) {
|
|
138
|
+
console.error("uai: not available for this agent — no permissions granted (or not inside a task).");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseFlags(args) {
|
|
143
|
+
const flags = {}; const rest = [];
|
|
144
|
+
for (let i = 0; i < args.length; i++) {
|
|
145
|
+
const a = args[i];
|
|
146
|
+
if (a.startsWith("--")) { const k = a.slice(2); const v = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : "true"; flags[k] = v; }
|
|
147
|
+
else rest.push(a);
|
|
148
|
+
}
|
|
149
|
+
return { flags, rest };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function api(method, path, body) {
|
|
153
|
+
const res = await fetch(API_URL + path, {
|
|
154
|
+
method,
|
|
155
|
+
headers: { authorization: "Bearer " + TOKEN, "content-type": "application/json" },
|
|
156
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
157
|
+
});
|
|
158
|
+
const text = await res.text();
|
|
159
|
+
let json; try { json = JSON.parse(text); } catch { json = text; }
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const msg = (json && json.error && json.error.message) || text || res.statusText;
|
|
162
|
+
console.error("uai: " + res.status + " " + msg);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
return json;
|
|
166
|
+
}
|
|
167
|
+
function out(v) { console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2)); }
|
|
168
|
+
|
|
169
|
+
const argv = process.argv.slice(2);
|
|
170
|
+
const [group, action, ...rest] = argv;
|
|
171
|
+
const { flags, rest: pos } = parseFlags(rest);
|
|
172
|
+
|
|
173
|
+
async function main() {
|
|
174
|
+
const key = group + " " + (action || "");
|
|
175
|
+
switch (key) {
|
|
176
|
+
case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
|
|
177
|
+
case "people list": out(await api("GET", "/api/agent/people")); break;
|
|
178
|
+
case "task list": out((await api("GET", "/api/agent/tasks")).tasks); break;
|
|
179
|
+
case "memory search": {
|
|
180
|
+
const q = pos.join(" ") || flags.q || "";
|
|
181
|
+
const author = flags.as ? "&author=" + encodeURIComponent(flags.as) : "";
|
|
182
|
+
out((await api("GET", "/api/agent/memory?q=" + encodeURIComponent(q) + author)).memories);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "memory save": {
|
|
186
|
+
const text = pos.join(" ") || flags.text || "";
|
|
187
|
+
if (!text) { console.error("uai: memory save needs text"); process.exit(1); }
|
|
188
|
+
out((await api("POST", "/api/agent/memory", {
|
|
189
|
+
text,
|
|
190
|
+
author: flags.as || null,
|
|
191
|
+
projectId: flags.project || null,
|
|
192
|
+
tags: flags.tags ? String(flags.tags).split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
193
|
+
})).memory);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "task create": {
|
|
197
|
+
out((await api("POST", "/api/agent/tasks", {
|
|
198
|
+
name: flags.name,
|
|
199
|
+
prompt: flags.prompt,
|
|
200
|
+
projects: flags.projects ? String(flags.projects).split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
201
|
+
team: flags.team || null,
|
|
202
|
+
agents: flags.agents ? String(flags.agents).split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
203
|
+
parent: flags.parent || null,
|
|
204
|
+
})).task);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "whoami ": case "whoami undefined": out({ apiUrl: API_URL }); break;
|
|
208
|
+
default:
|
|
209
|
+
console.error([
|
|
210
|
+
"uai — agent CLI. Commands:",
|
|
211
|
+
" uai projects list",
|
|
212
|
+
" uai people list",
|
|
213
|
+
" uai task list",
|
|
214
|
+
" uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
|
|
215
|
+
" uai memory search <query>",
|
|
216
|
+
" uai memory save <text> [--project id] [--tags a,b]",
|
|
217
|
+
" uai whoami",
|
|
218
|
+
].join("\\n"));
|
|
219
|
+
process.exit(argv.length ? 1 : 0);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
main().catch((e) => { console.error("uai: " + (e && e.message ? e.message : e)); process.exit(1); });
|
|
223
|
+
`;
|
package/lib/agents/claude.ts
CHANGED
|
@@ -200,6 +200,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
200
200
|
agent: RosterAgent;
|
|
201
201
|
containerName: string;
|
|
202
202
|
systemPreamble: string;
|
|
203
|
+
agentEnv?: Record<string, string>;
|
|
203
204
|
}) {
|
|
204
205
|
this.agentId = args.agent.id;
|
|
205
206
|
|
|
@@ -234,6 +235,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
234
235
|
"claude",
|
|
235
236
|
cliArgs,
|
|
236
237
|
["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
238
|
+
args.agentEnv ?? {},
|
|
237
239
|
);
|
|
238
240
|
this.proc = new LineProcess({
|
|
239
241
|
command,
|
|
@@ -338,6 +340,14 @@ register({
|
|
|
338
340
|
defaultModel: CLAUDE_DEFAULT_MODEL,
|
|
339
341
|
supportedEfforts: () => [...CLAUDE_EFFORTS],
|
|
340
342
|
defaultEffort: CLAUDE_DEFAULT_EFFORT,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
+
// Usable only when a Claude credential is in the env (injected into task
|
|
344
|
+
// containers at task-up). Gates advertisement (ADR-044 P2).
|
|
345
|
+
available: () =>
|
|
346
|
+
Boolean(
|
|
347
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
348
|
+
process.env.ANTHROPIC_API_KEY ||
|
|
349
|
+
process.env.ANTHROPIC_AUTH_TOKEN,
|
|
350
|
+
),
|
|
351
|
+
create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
|
|
352
|
+
new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
|
|
343
353
|
});
|
package/lib/agents/codex.ts
CHANGED
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
* namespaces inside it anyway.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
|
|
31
35
|
import { newId } from "../ulid";
|
|
32
36
|
import { dockerExecArgs, LineProcess } from "./proc";
|
|
33
37
|
import { register } from "./registry";
|
|
@@ -223,6 +227,7 @@ export class CodexSession implements AgentSession {
|
|
|
223
227
|
agent: RosterAgent;
|
|
224
228
|
containerName: string;
|
|
225
229
|
systemPreamble: string;
|
|
230
|
+
agentEnv?: Record<string, string>;
|
|
226
231
|
}) {
|
|
227
232
|
this.agentId = args.agent.id;
|
|
228
233
|
this.systemPreamble = args.systemPreamble;
|
|
@@ -243,6 +248,8 @@ export class CodexSession implements AgentSession {
|
|
|
243
248
|
args.containerName,
|
|
244
249
|
"codex",
|
|
245
250
|
codexArgs,
|
|
251
|
+
[],
|
|
252
|
+
args.agentEnv ?? {},
|
|
246
253
|
);
|
|
247
254
|
this.proc = new LineProcess({
|
|
248
255
|
command,
|
|
@@ -517,6 +524,13 @@ register({
|
|
|
517
524
|
defaultModel: CODEX_DEFAULT_MODEL,
|
|
518
525
|
supportedEfforts: () => [...CODEX_EFFORTS],
|
|
519
526
|
defaultEffort: CODEX_DEFAULT_EFFORT,
|
|
520
|
-
|
|
521
|
-
|
|
527
|
+
// Usable only when the owner's ~/.codex login exists — task-up copies it into
|
|
528
|
+
// each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
|
|
529
|
+
// (ADR-044 P2).
|
|
530
|
+
available: () =>
|
|
531
|
+
existsSync(
|
|
532
|
+
join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
|
|
533
|
+
),
|
|
534
|
+
create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
|
|
535
|
+
new CodexSession({ agent, containerName, systemPreamble, agentEnv }),
|
|
522
536
|
});
|
package/lib/agents/proc.ts
CHANGED
|
@@ -160,11 +160,20 @@ export function dockerExecArgs(
|
|
|
160
160
|
* cloud (ADR-015). Only vars actually set on the host are forwarded.
|
|
161
161
|
*/
|
|
162
162
|
passEnv: string[] = [],
|
|
163
|
+
/**
|
|
164
|
+
* Explicit per-exec env values (`-e NAME=value`) — used for per-agent values
|
|
165
|
+
* that don't live in the host's own env, e.g. the agent's task-scoped
|
|
166
|
+
* `UAI_TASK_TOKEN` (ADR-048). Values are argv, never a shell string.
|
|
167
|
+
*/
|
|
168
|
+
explicitEnv: Record<string, string> = {},
|
|
163
169
|
): { command: string; args: string[] } {
|
|
164
170
|
const envArgs: string[] = [];
|
|
165
171
|
for (const name of passEnv) {
|
|
166
172
|
if (process.env[name]) envArgs.push("-e", name);
|
|
167
173
|
}
|
|
174
|
+
for (const [name, value] of Object.entries(explicitEnv)) {
|
|
175
|
+
envArgs.push("-e", `${name}=${value}`);
|
|
176
|
+
}
|
|
168
177
|
return {
|
|
169
178
|
command: "docker",
|
|
170
179
|
args: ["exec", "-i", ...envArgs, containerName, cli, ...cliArgs],
|
package/lib/agents/registry.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface RegisteredAdapter {
|
|
|
32
32
|
supportedEfforts(): string[];
|
|
33
33
|
/** Preferred effort when the user doesn't pick one. */
|
|
34
34
|
defaultEffort?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Whether this kind is usable on THIS host right now — i.e. its credentials
|
|
37
|
+
* are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
|
|
38
|
+
* out of `capabilities()` so the cloud's task picker doesn't offer an LLM the
|
|
39
|
+
* host can't actually run. Omit (or return true) to always advertise; the
|
|
40
|
+
* session factory stays permissive regardless.
|
|
41
|
+
*/
|
|
42
|
+
available?: () => boolean;
|
|
35
43
|
/** Builds an AgentSession for a roster entry of this kind. */
|
|
36
44
|
create: AgentSessionFactory["create"];
|
|
37
45
|
}
|
|
@@ -76,7 +84,9 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
|
|
|
76
84
|
* Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
|
|
77
85
|
*/
|
|
78
86
|
export function capabilities(): AgentKindCapability[] {
|
|
79
|
-
return list()
|
|
87
|
+
return list()
|
|
88
|
+
.filter((adapter) => adapter.available?.() ?? true)
|
|
89
|
+
.map((adapter) => {
|
|
80
90
|
const out: AgentKindCapability = {
|
|
81
91
|
kind: adapter.kind,
|
|
82
92
|
label: adapter.label,
|
package/lib/agents/types.ts
CHANGED
|
@@ -26,6 +26,19 @@ export type AgentKind = z.infer<typeof AgentKind>;
|
|
|
26
26
|
* `@<id>` mention token. `kind` selects the adapter; `model` is passed
|
|
27
27
|
* through to the CLI. There is no `role` field. `initialPrompt` (if set)
|
|
28
28
|
* drives this agent's first turn at container-ready. */
|
|
29
|
+
/** Reference material (a link or inline document) materialised onto an agent
|
|
30
|
+
* from its persona + team (ADR-046). The host writes these to a per-agent
|
|
31
|
+
* SKILL.md at session start. */
|
|
32
|
+
export const RosterSkill = z.object({
|
|
33
|
+
name: z.string().min(1),
|
|
34
|
+
type: z.string().min(1), // 'link' | 'document' | 'package' (ADR-047)
|
|
35
|
+
value: z.string(),
|
|
36
|
+
// ADR-047 package skills: 'git' | 'cli' install mode + (git) repo subpath.
|
|
37
|
+
source: z.string().nullable().optional(),
|
|
38
|
+
subpath: z.string().nullable().optional(),
|
|
39
|
+
});
|
|
40
|
+
export type RosterSkill = z.infer<typeof RosterSkill>;
|
|
41
|
+
|
|
29
42
|
export const RosterAgent = z.object({
|
|
30
43
|
id: z
|
|
31
44
|
.string()
|
|
@@ -37,6 +50,8 @@ export const RosterAgent = z.object({
|
|
|
37
50
|
effort: z.string().min(1).optional(), // CLI reasoning level, passed through
|
|
38
51
|
defaultPrompt: z.string().optional(), // persona
|
|
39
52
|
initialPrompt: z.string().optional(), // first-turn instructions, this task only
|
|
53
|
+
skills: z.array(RosterSkill).optional(), // ADR-046 reference material
|
|
54
|
+
permissions: z.array(z.string()).optional(), // ADR-048 `uai` CLI capabilities
|
|
40
55
|
});
|
|
41
56
|
export type RosterAgent = z.infer<typeof RosterAgent>;
|
|
42
57
|
|
|
@@ -129,5 +144,8 @@ export interface AgentSessionFactory {
|
|
|
129
144
|
containerName: string;
|
|
130
145
|
/** Initial briefing — project.defaultPrompt — sent on session start. */
|
|
131
146
|
systemPreamble: string;
|
|
147
|
+
/** ADR-048: extra per-agent env for the `docker exec` (e.g. this agent's
|
|
148
|
+
* own UAI_TASK_TOKEN so its `uai` CLI carries only its own permissions). */
|
|
149
|
+
agentEnv?: Record<string, string>;
|
|
132
150
|
}): Promise<AgentSession>;
|
|
133
151
|
}
|
package/lib/command-db.ts
CHANGED
|
@@ -135,12 +135,12 @@ function insertTask(db: Database.Database, task: CommandTaskRow): void {
|
|
|
135
135
|
id, owner_user_id, host_id, name, slug, branch, status,
|
|
136
136
|
global_context, reviewer_order, agents, pr_context,
|
|
137
137
|
worktree_path, compose_project, code_server_port, preview_ports,
|
|
138
|
-
locked_at, started_at, ended_at
|
|
138
|
+
preview_env, locked_at, started_at, ended_at
|
|
139
139
|
) VALUES (
|
|
140
140
|
@id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
|
|
141
141
|
@global_context, @reviewer_order, @agents, @pr_context,
|
|
142
142
|
@worktree_path, @compose_project, @code_server_port, @preview_ports,
|
|
143
|
-
@locked_at, @started_at, @ended_at
|
|
143
|
+
@preview_env, @locked_at, @started_at, @ended_at
|
|
144
144
|
)`,
|
|
145
145
|
).run(task);
|
|
146
146
|
}
|
|
@@ -166,6 +166,7 @@ interface CommandTaskRow {
|
|
|
166
166
|
compose_project: string | null;
|
|
167
167
|
code_server_port: number | null;
|
|
168
168
|
preview_ports: string;
|
|
169
|
+
preview_env: string | null;
|
|
169
170
|
locked_at: number | null;
|
|
170
171
|
started_at: number | null;
|
|
171
172
|
ended_at: number | null;
|
|
@@ -193,6 +194,7 @@ function withRuntime(
|
|
|
193
194
|
compose_project: runtime?.composeProject ?? null,
|
|
194
195
|
code_server_port: runtime?.codeServerPort ?? null,
|
|
195
196
|
preview_ports: runtime?.previewPorts ?? "[]",
|
|
197
|
+
preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
|
|
196
198
|
locked_at: runtime?.lockedAt ?? null,
|
|
197
199
|
started_at: runtime?.startedAt ?? null,
|
|
198
200
|
ended_at: runtime?.endedAt ?? null,
|
|
@@ -216,6 +218,7 @@ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
|
|
|
216
218
|
compose_project: runtime?.composeProject ?? null,
|
|
217
219
|
code_server_port: runtime?.codeServerPort ?? null,
|
|
218
220
|
preview_ports: runtime?.previewPorts ?? "[]",
|
|
221
|
+
preview_env: null,
|
|
219
222
|
locked_at: runtime?.lockedAt ?? null,
|
|
220
223
|
started_at: runtime?.startedAt ?? null,
|
|
221
224
|
ended_at: runtime?.endedAt ?? null,
|
|
@@ -262,6 +265,7 @@ CREATE TABLE IF NOT EXISTS uai_tasks (
|
|
|
262
265
|
compose_project text,
|
|
263
266
|
code_server_port integer,
|
|
264
267
|
preview_ports text NOT NULL DEFAULT '[]',
|
|
268
|
+
preview_env text,
|
|
265
269
|
pr_url text,
|
|
266
270
|
locked_at integer,
|
|
267
271
|
started_at integer,
|
package/lib/git-diff.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parse `git diff` unified output into structured per-file patches.
|
|
3
3
|
*
|
|
4
|
-
* Input is whatever `git diff
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Input is whatever `git diff <merge-base>` (committed + uncommitted) plus
|
|
5
|
+
* appended `--no-index` new-file patches for untracked files produce —
|
|
6
|
+
* multi-file, with headers, mode/rename/binary annotations, and `@@` hunks.
|
|
7
|
+
* The output is a list of `DiffFile`s the UI can render per-file with
|
|
7
8
|
* collapse/expand and syntax-highlighted hunks.
|
|
8
9
|
*
|
|
9
10
|
* Hand-rolled rather than pulled in as a dep — the unified-diff grammar
|
|
@@ -98,34 +99,84 @@ export function runGitDiffInContainer(
|
|
|
98
99
|
containerName: string,
|
|
99
100
|
cwd: string,
|
|
100
101
|
range: string,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
return runDiffCommand(
|
|
104
|
+
"docker",
|
|
105
|
+
[...dockerExecPrefix(containerName, cwd), "git", ...GIT_DIFF_ARGS, range],
|
|
106
|
+
"docker exec git diff",
|
|
107
|
+
process.env,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render a single untracked file as a new-file patch by diffing it against
|
|
113
|
+
* `/dev/null` with `--no-index`. `git diff <commit>` omits untracked files
|
|
114
|
+
* entirely, so this is how they reach the "all changes vs base" view.
|
|
115
|
+
*
|
|
116
|
+
* `--no-index` exits 1 when the inputs differ (the normal "there is a diff"
|
|
117
|
+
* case for a new file) and 0 when identical — both are success here; only
|
|
118
|
+
* code > 1 is a real error.
|
|
119
|
+
*/
|
|
120
|
+
export function runGitDiffUntracked(
|
|
121
|
+
cwd: string,
|
|
122
|
+
relPath: string,
|
|
123
|
+
): Promise<string> {
|
|
124
|
+
return runDiffCommand(
|
|
125
|
+
"git",
|
|
126
|
+
["-C", cwd, ...GIT_DIFF_ARGS, "--no-index", "--", "/dev/null", relPath],
|
|
127
|
+
"git diff --no-index",
|
|
128
|
+
{ ...process.env, ...NON_INTERACTIVE_GIT_ENV },
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** In-container variant of {@link runGitDiffUntracked}. */
|
|
134
|
+
export function runGitDiffUntrackedInContainer(
|
|
135
|
+
containerName: string,
|
|
136
|
+
cwd: string,
|
|
137
|
+
relPath: string,
|
|
101
138
|
): Promise<string> {
|
|
102
139
|
return runDiffCommand(
|
|
103
140
|
"docker",
|
|
104
141
|
[
|
|
105
|
-
|
|
106
|
-
"-e",
|
|
107
|
-
`GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
|
|
108
|
-
"-e",
|
|
109
|
-
`GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
|
|
110
|
-
"-e",
|
|
111
|
-
`GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
|
|
112
|
-
"-w",
|
|
113
|
-
cwd,
|
|
114
|
-
containerName,
|
|
142
|
+
...dockerExecPrefix(containerName, cwd),
|
|
115
143
|
"git",
|
|
116
144
|
...GIT_DIFF_ARGS,
|
|
117
|
-
|
|
145
|
+
"--no-index",
|
|
146
|
+
"--",
|
|
147
|
+
"/dev/null",
|
|
148
|
+
relPath,
|
|
118
149
|
],
|
|
119
|
-
"docker exec git diff",
|
|
150
|
+
"docker exec git diff --no-index",
|
|
120
151
|
process.env,
|
|
152
|
+
true,
|
|
121
153
|
);
|
|
122
154
|
}
|
|
123
155
|
|
|
156
|
+
/** `docker exec` argv prefix with the non-interactive git env + working dir. */
|
|
157
|
+
function dockerExecPrefix(containerName: string, cwd: string): string[] {
|
|
158
|
+
return [
|
|
159
|
+
"exec",
|
|
160
|
+
"-e",
|
|
161
|
+
`GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
|
|
162
|
+
"-e",
|
|
163
|
+
`GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
|
|
164
|
+
"-e",
|
|
165
|
+
`GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
|
|
166
|
+
"-w",
|
|
167
|
+
cwd,
|
|
168
|
+
containerName,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
function runDiffCommand(
|
|
125
173
|
command: string,
|
|
126
174
|
args: string[],
|
|
127
175
|
label: string,
|
|
128
176
|
env: NodeJS.ProcessEnv,
|
|
177
|
+
/** Treat exit code 1 as success — `git diff --no-index` returns 1 when the
|
|
178
|
+
* compared inputs differ, which for a new-file patch is the expected case. */
|
|
179
|
+
allowExitOne = false,
|
|
129
180
|
): Promise<string> {
|
|
130
181
|
return new Promise((res, rej) => {
|
|
131
182
|
// Force ASCII paths and pull a generous unified context so the UI
|
|
@@ -144,7 +195,7 @@ function runDiffCommand(
|
|
|
144
195
|
});
|
|
145
196
|
child.on("error", rej);
|
|
146
197
|
child.on("close", (code) => {
|
|
147
|
-
if (code === 0) {
|
|
198
|
+
if (code === 0 || (allowExitOne && code === 1)) {
|
|
148
199
|
res(stdout);
|
|
149
200
|
return;
|
|
150
201
|
}
|