@runuai/host 0.3.0 → 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.
@@ -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
+ `;
@@ -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,
@@ -346,6 +348,6 @@ register({
346
348
  process.env.ANTHROPIC_API_KEY ||
347
349
  process.env.ANTHROPIC_AUTH_TOKEN,
348
350
  ),
349
- create: async ({ agent, containerName, systemPreamble }) =>
350
- new ClaudeSession({ agent, containerName, systemPreamble }),
351
+ create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
352
+ new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
351
353
  });
@@ -227,6 +227,7 @@ export class CodexSession implements AgentSession {
227
227
  agent: RosterAgent;
228
228
  containerName: string;
229
229
  systemPreamble: string;
230
+ agentEnv?: Record<string, string>;
230
231
  }) {
231
232
  this.agentId = args.agent.id;
232
233
  this.systemPreamble = args.systemPreamble;
@@ -247,6 +248,8 @@ export class CodexSession implements AgentSession {
247
248
  args.containerName,
248
249
  "codex",
249
250
  codexArgs,
251
+ [],
252
+ args.agentEnv ?? {},
250
253
  );
251
254
  this.proc = new LineProcess({
252
255
  command,
@@ -528,6 +531,6 @@ register({
528
531
  existsSync(
529
532
  join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
530
533
  ),
531
- create: async ({ agent, containerName, systemPreamble }) =>
532
- new CodexSession({ agent, containerName, systemPreamble }),
534
+ create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
535
+ new CodexSession({ agent, containerName, systemPreamble, agentEnv }),
533
536
  });
@@ -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],
@@ -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
  }
@@ -37,6 +37,20 @@ import { getHostTask, upsertHostTask } from "./runtime-state";
37
37
  import { setupTaskGithub } from "./github-tokens";
38
38
  import { setupTaskGitIdentity } from "./git-identity";
39
39
  import { dockerCli } from "./docker-exec";
40
+ import {
41
+ containerSkillFile,
42
+ installedSkillPath,
43
+ installPackageSkills,
44
+ writeAgentSkills,
45
+ } from "./skills";
46
+ import {
47
+ CONTAINER_CLI_PATH,
48
+ agentCliEnv,
49
+ apiUrlFromCloudUrl,
50
+ loadTaskCliSecret,
51
+ writeAgentCli,
52
+ } from "./agent-cli";
53
+ import { env } from "./env";
40
54
  import type { ChannelEnsureInput, HostEvent } from "../src/protocol";
41
55
 
42
56
  export type HostEventSubscriber = (event: HostEvent) => void;
@@ -143,6 +157,9 @@ class Orchestrator {
143
157
  spec.branch,
144
158
  ),
145
159
  );
160
+ // ADR-046: materialise this agent's skills to its per-agent SKILL.md in
161
+ // the workspace (bind-mounted into the container). Best-effort.
162
+ writeAgentSkills(taskId, agent);
146
163
  // ADR-022: any agent with a non-empty initialPrompt opens a first
147
164
  // turn at container-ready. The rest stay silent until addressed.
148
165
  const firstTurn = assembleFirstTurnPrompt(
@@ -222,12 +239,28 @@ class Orchestrator {
222
239
  void setupTaskGithub(channel.taskId, task.ownerUserId);
223
240
  }
224
241
 
242
+ // ADR-047: install any package skills (native Claude Agent Skills) into the
243
+ // container BEFORE spawning agents, so they're discoverable on the first
244
+ // turn. Idempotent + best-effort (returns fast when there are none); a slow
245
+ // clone/install briefly delays start, which is acceptable for skill-bearing
246
+ // tasks. Never throws.
247
+ await installPackageSkills(channel.taskId, channel.roster);
248
+
249
+ // ADR-048: write the in-container `uai` CLI (apiUrl only, no token) into the
250
+ // workspace. Each agent's OWN task token — carrying only ITS permissions — is
251
+ // injected per-agent via its docker exec env below, so per-persona permissions
252
+ // are actually enforced. Best-effort, host-side.
253
+ const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
254
+ const cliSecret = loadTaskCliSecret(channel.taskId);
255
+ writeAgentCli(channel.taskId, channel.roster, apiUrl);
256
+
225
257
  for (const agent of channel.roster) {
226
258
  const session = await this.factory.create({
227
259
  taskId: channel.taskId,
228
260
  agent,
229
261
  containerName: channel.containerName,
230
262
  systemPreamble: channel.preambles.get(agent.id) ?? "",
263
+ agentEnv: agentCliEnv(channel.taskId, agent, task.ownerUserId, apiUrl, cliSecret),
231
264
  });
232
265
  channel.sessions.set(agent.id, session);
233
266
  session.onEvent((event) => {
@@ -825,6 +858,71 @@ export function buildSystemPreamble(
825
858
  "part of the project. Never review, edit, stage, commit, or flag it;",
826
859
  "treat it as ignored, even though git may show it as untracked.",
827
860
  "",
861
+ // ADR-046: link/document skills are materialised to a per-agent SKILL.md.
862
+ // Point the agent at its own file (package skills are handled below).
863
+ ...((agent.skills ?? []).some((s) => s.type !== "package")
864
+ ? [
865
+ "## Your skills",
866
+ "",
867
+ "Reference material (links + documents) has been prepared for you at",
868
+ `\`${containerSkillFile(agent.id)}\`. Read it when a task touches what`,
869
+ "it covers. It's yours — other agents have their own.",
870
+ "",
871
+ ]
872
+ : []),
873
+ // ADR-047: package skills are native Claude Agent Skills installed into the
874
+ // container's skills dir (Claude-only). List them so the agent knows they're
875
+ // available even if headless auto-discovery is unreliable.
876
+ ...(agent.kind === "claude" &&
877
+ (agent.skills ?? []).some((s) => s.type === "package")
878
+ ? [
879
+ "## Installed skills",
880
+ "",
881
+ "These Claude Agent Skills are installed for this task — invoke them",
882
+ "when a task touches what they cover:",
883
+ ...(agent.skills ?? [])
884
+ .filter((s) => s.type === "package")
885
+ .map((s) => `- **${s.name}** (\`${installedSkillPath(s)}\`)`),
886
+ "",
887
+ ]
888
+ : []),
889
+ // ADR-048: tell agents with permissions about their `uai` CLI.
890
+ ...((agent.permissions?.length ?? 0) > 0
891
+ ? [
892
+ "## The uai CLI",
893
+ "",
894
+ `You can query and plan through Uai by running \`node ${CONTAINER_CLI_PATH}`,
895
+ "<command>` in the shell. Your permissions:",
896
+ `\`${(agent.permissions ?? []).join(", ")}\`. Commands: \`projects list\`,`,
897
+ "`people list`, `task list`" +
898
+ (agent.permissions?.includes("tasks.create")
899
+ ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
900
+ : "") +
901
+ (agent.permissions?.includes("memory.write")
902
+ ? ", `memory save <text>`"
903
+ : "") +
904
+ (agent.permissions?.includes("memory.read")
905
+ ? ", `memory search <query>`"
906
+ : "") +
907
+ ".",
908
+ ...(agent.permissions?.includes("tasks.create")
909
+ ? [
910
+ "Tasks you create are DRAFTS — a human reviews and starts them;",
911
+ "nothing runs (or spends) until then.",
912
+ ]
913
+ : []),
914
+ ...(agent.permissions?.includes("memory.read") ||
915
+ agent.permissions?.includes("memory.write")
916
+ ? [
917
+ `Your memory handle is \`${agent.id}\` — pass \`--as ${agent.id}\` so`,
918
+ "memories are attributed to you. Recall relevant past work with",
919
+ "`memory search` before you start; save durable learnings with",
920
+ "`memory save` when you finish something worth remembering.",
921
+ ]
922
+ : []),
923
+ "",
924
+ ]
925
+ : []),
828
926
  "## Commit policy",
829
927
  "",
830
928
  "Commits are SSH-signed automatically (git is configured for it) — do",
package/lib/skills.ts ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Per-agent skills materialisation (ADR-046).
3
+ *
4
+ * A task agent's skills (reference links + documents, resolved cloud-side from
5
+ * its persona + team) are written to a per-agent `SKILL.md` inside the task
6
+ * workspace. The workspace is bind-mounted into the container at `/workspace`
7
+ * (ADR-014), so writing on the host makes the file appear in the container —
8
+ * no `docker cp`/`exec` needed (same mechanism as attachments).
9
+ *
10
+ * Each agent gets its OWN file under `.uai/agents/<id>/` so agents in a shared
11
+ * container don't see each other's material; the agent's system preamble points
12
+ * at its file (buildSystemPreamble), which is the reliable cross-CLI delivery
13
+ * (no dependency on Claude's project-skill auto-discovery in headless mode).
14
+ *
15
+ * ADR-047 adds a `package` skill type — a native Claude Agent Skill (folder +
16
+ * scripts) installed into the container's Claude skills dir via `docker exec`
17
+ * (git clone or an install command), so Claude discovers it natively. Package
18
+ * skills are Claude-only and task-level (shared by the container's agents),
19
+ * NOT written into the flat per-agent SKILL.md.
20
+ */
21
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
22
+ import { resolve } from "node:path";
23
+
24
+ import { taskWorkspaceDir } from "./env";
25
+ import { dockerCli } from "./docker-exec";
26
+ import type { RosterAgent, RosterSkill } from "./agents/types";
27
+
28
+ /** Skills that go into the flat per-agent SKILL.md — link/document only. The
29
+ * ADR-047 `package` skills install into the container's skills dir instead. */
30
+ function flatSkills(agent: RosterAgent): RosterSkill[] {
31
+ return (agent.skills ?? []).filter((s) => s.type !== "package");
32
+ }
33
+
34
+ /** The container path of an agent's SKILL.md — what the preamble points at. */
35
+ export function containerSkillFile(agentId: string): string {
36
+ return `/workspace/.uai/agents/${agentId}/SKILL.md`;
37
+ }
38
+
39
+ /** Render an agent's skills into SKILL.md markdown ("everything in one file"). */
40
+ export function renderSkillMd(agent: RosterAgent): string {
41
+ const skills = flatSkills(agent);
42
+ const lines = [
43
+ `# ${agent.label} — skills`,
44
+ "",
45
+ "Reference material prepared for you (links and documents). Consult it when",
46
+ "a task touches what it covers.",
47
+ "",
48
+ ];
49
+ for (const s of skills) {
50
+ lines.push(`## ${s.name}`, "");
51
+ if (s.type === "link") lines.push(`Link: ${s.value}`, "");
52
+ else lines.push(s.value.trim(), "");
53
+ }
54
+ return `${lines.join("\n").trimEnd()}\n`;
55
+ }
56
+
57
+ /**
58
+ * Write an agent's SKILL.md into the task workspace (idempotent: overwrites,
59
+ * or removes the file when the agent has no skills). Returns the container path
60
+ * when written, else null. Best-effort — a write failure is swallowed so a
61
+ * skills problem never blocks the agent from running.
62
+ */
63
+ export function writeAgentSkills(taskId: string, agent: RosterAgent): string | null {
64
+ const dir = resolve(taskWorkspaceDir(taskId), ".uai", "agents", agent.id);
65
+ const file = resolve(dir, "SKILL.md");
66
+ try {
67
+ if (flatSkills(agent).length === 0) {
68
+ rmSync(file, { force: true });
69
+ return null;
70
+ }
71
+ mkdirSync(dir, { recursive: true });
72
+ writeFileSync(file, renderSkillMd(agent));
73
+ return containerSkillFile(agent.id);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // ADR-047 package skills — native Claude Agent Skills installed in-container.
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const SKILL_EXEC_TIMEOUT_MS = 180_000; // clones / npx installs can be slow
84
+
85
+ /**
86
+ * In-container git-skill installer. Shallow-clones a repo, then works out where
87
+ * the actual skill(s) live and copies each into `~/.claude/skills/<name>/`:
88
+ *
89
+ * - `$2` (subpath) given → that folder is the skill.
90
+ * - else repo root has a SKILL.md → the whole repo is the skill.
91
+ * - else search the clone for SKILL.md files → install every skill folder it
92
+ * finds (a "collection" repo), each under its own folder name.
93
+ * - else exit 3 → the repo isn't a ready skill (needs a build; use cli mode).
94
+ *
95
+ * Params are positional argv ($1=url, $2=subpath, $3=name) — never interpolated
96
+ * into this text — so a repo URL / subpath can't shell-inject. `.git` is
97
+ * stripped and the temp clone removed; existing skill dirs are left untouched.
98
+ */
99
+ const GIT_INSTALL_SCRIPT = `
100
+ set -eu
101
+ url="$1"; sub="$2"; name="$3"
102
+ skills="$HOME/.claude/skills"
103
+ mkdir -p "$skills"
104
+ tmp="$(mktemp -d)"
105
+ trap 'rm -rf "$tmp"' EXIT
106
+ git clone --depth 1 --quiet "$url" "$tmp/repo"
107
+ repo="$tmp/repo"
108
+ install_one() {
109
+ src="$1"; dest="$skills/$2"
110
+ if [ -e "$dest" ]; then echo "skip $2"; return 0; fi
111
+ if [ ! -f "$src/SKILL.md" ]; then echo "no-skill-md: $src"; return 1; fi
112
+ rm -rf "$src/.git"
113
+ cp -R "$src" "$dest"
114
+ echo "installed $2"
115
+ }
116
+ if [ -n "$sub" ]; then
117
+ install_one "$repo/$sub" "$name"
118
+ elif [ -f "$repo/SKILL.md" ]; then
119
+ install_one "$repo" "$name"
120
+ else
121
+ n=0
122
+ while IFS= read -r -d '' md; do
123
+ d="$(dirname "$md")"
124
+ if install_one "$d" "$(basename "$d")"; then n=$((n+1)); fi
125
+ done < <(find "$repo" -maxdepth 4 -name SKILL.md -not -path '*/.git/*' -print0)
126
+ if [ "$n" -eq 0 ]; then echo "no-skills-found"; exit 3; fi
127
+ fi
128
+ `;
129
+
130
+ /** A url-safe folder name for a skill (the dir under ~/.claude/skills/). */
131
+ export function skillSlug(name: string): string {
132
+ return (
133
+ name
134
+ .toLowerCase()
135
+ .replace(/[^a-z0-9._-]+/g, "-")
136
+ .replace(/^[-.]+|[-.]+$/g, "")
137
+ .slice(0, 64) || "skill"
138
+ );
139
+ }
140
+
141
+ /** A short label for an installed package skill (for the preamble note). */
142
+ export function installedSkillPath(s: RosterSkill): string {
143
+ return s.source === "git"
144
+ ? `~/.claude/skills/${skillSlug(s.name)}/`
145
+ : `${s.name} (install command)`;
146
+ }
147
+
148
+ /**
149
+ * Package skills across the Claude agents in a roster, deduped by folder slug.
150
+ * Package skills are a Claude Code capability (ADR-047), so Codex agents are
151
+ * skipped; the install is task-level (one container, shared skills dir).
152
+ */
153
+ export function collectPackageSkills(agents: RosterAgent[]): RosterSkill[] {
154
+ const seen = new Set<string>();
155
+ const out: RosterSkill[] = [];
156
+ for (const a of agents) {
157
+ if (a.kind !== "claude") continue;
158
+ for (const s of a.skills ?? []) {
159
+ if (s.type !== "package" || !s.value) continue;
160
+ const key = skillSlug(s.name);
161
+ if (seen.has(key)) continue;
162
+ seen.add(key);
163
+ out.push(s);
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** Injectable docker-exec seam (mocked in tests). Runs `cmd` in `container` as
170
+ * the `node` user, optionally with a working dir. Values are argv, never a
171
+ * shell string — a URL/subpath can't shell-inject (cli mode is the exception:
172
+ * it runs the author's command via `bash -lc` by design). */
173
+ export type SkillExec = (
174
+ container: string,
175
+ cmd: string[],
176
+ cwd?: string,
177
+ ) => Promise<{ status: number | null; stderr: string }>;
178
+
179
+ const dockerExec: SkillExec = async (container, cmd, cwd) => {
180
+ const full = ["exec", "-u", "node", ...(cwd ? ["-w", cwd] : []), container, ...cmd];
181
+ const res = await dockerCli(full, { timeoutMs: SKILL_EXEC_TIMEOUT_MS });
182
+ return { status: res.status, stderr: res.stderr };
183
+ };
184
+
185
+ /**
186
+ * Install a roster's package skills into the task container (ADR-047). Runs at
187
+ * task-up (after the container is running), mirroring the git-identity/github
188
+ * `docker exec` setup: best-effort, idempotent, never blocks the agents.
189
+ *
190
+ * Returns the human-readable paths of skills that are now present (for the
191
+ * agents' preamble note). `exec` is injectable for tests.
192
+ */
193
+ export async function installPackageSkills(
194
+ taskId: string,
195
+ agents: RosterAgent[],
196
+ exec: SkillExec = dockerExec,
197
+ ): Promise<string[]> {
198
+ const pkgs = collectPackageSkills(agents);
199
+ if (pkgs.length === 0) return [];
200
+ const container = `task-${taskId}-app-1`;
201
+ const done: string[] = [];
202
+
203
+ for (const s of pkgs) {
204
+ const slug = skillSlug(s.name);
205
+ try {
206
+ if (s.source === "git") {
207
+ // Idempotent across ensureSessions re-runs: a marker records that the
208
+ // clone+install already ran, so reconnects don't re-clone.
209
+ const marker = `/home/node/.claude/skills/.uai-git-${slug}.done`;
210
+ if ((await exec(container, ["test", "-e", marker])).status === 0) {
211
+ done.push(installedSkillPath(s));
212
+ continue;
213
+ }
214
+ // subpath is de-fanged against traversal; url/subpath/name reach the
215
+ // installer as positional argv (never interpolated) — no shell inject.
216
+ const sub = (s.subpath ?? "").replace(/^[/.]+/, "").replace(/\.\.+/g, "");
217
+ const r = await exec(container, [
218
+ "bash",
219
+ "-lc",
220
+ GIT_INSTALL_SCRIPT,
221
+ "uai-skill", // $0
222
+ s.value, // $1 url
223
+ sub, // $2 subpath
224
+ slug, // $3 name
225
+ ]);
226
+ if (r.status !== 0) {
227
+ const why =
228
+ r.status === 3
229
+ ? "no SKILL.md found — the repo may need a build (try cli mode)"
230
+ : r.stderr.trim();
231
+ console.warn(`[skills] ${taskId}: git skill ${s.name} failed: ${why}`);
232
+ continue;
233
+ }
234
+ await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
235
+ await exec(container, ["touch", marker]);
236
+ console.log(`[skills] ${taskId}: installed git skill ${s.name}`);
237
+ done.push(installedSkillPath(s));
238
+ } else if (s.source === "cli") {
239
+ // Idempotent across ensureSessions re-runs (reconnects): a marker file
240
+ // records that the command already ran, since a CLI install isn't
241
+ // self-idempotent like `git clone` into an existing dir.
242
+ const marker = `/home/node/.claude/skills/.uai-cli-${slug}.done`;
243
+ if ((await exec(container, ["test", "-e", marker])).status === 0) {
244
+ done.push(installedSkillPath(s));
245
+ continue;
246
+ }
247
+ // The author's CLI drops files into .claude/skills; run it in the repo.
248
+ const c = await exec(container, ["bash", "-lc", s.value], "/workspace");
249
+ if (c.status !== 0) {
250
+ console.warn(`[skills] ${taskId}: install cmd for ${s.name} failed: ${c.stderr.trim()}`);
251
+ continue;
252
+ }
253
+ await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
254
+ await exec(container, ["touch", marker]);
255
+ console.log(`[skills] ${taskId}: ran install command for ${s.name}`);
256
+ done.push(installedSkillPath(s));
257
+ }
258
+ } catch (e) {
259
+ console.warn(`[skills] ${taskId}: package skill ${s.name} errored: ${String(e)}`);
260
+ }
261
+ }
262
+ return done;
263
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Task-token minting (ADR-048) — the host mints the token the in-container `uai`
3
+ * CLI uses; the cloud verifies it (lib/task-token.ts). The signing key is the
4
+ * TASK's ephemeral `cliSecret`, issued by the cloud at task-up (not a static
5
+ * shared secret). Algorithm MUST stay identical to the cloud side.
6
+ */
7
+ import { createHmac } from "node:crypto";
8
+
9
+ export interface TaskTokenPayload {
10
+ taskId: string;
11
+ userId: string;
12
+ permissions: string[];
13
+ agentId?: string;
14
+ }
15
+
16
+ const PREFIX = "uai_";
17
+
18
+ function b64url(buf: Buffer): string {
19
+ return buf
20
+ .toString("base64")
21
+ .replace(/\+/g, "-")
22
+ .replace(/\//g, "_")
23
+ .replace(/=+$/, "");
24
+ }
25
+
26
+ export function signTaskToken(payload: TaskTokenPayload, secret: string): string {
27
+ const body = b64url(
28
+ Buffer.from(
29
+ JSON.stringify({
30
+ t: payload.taskId,
31
+ u: payload.userId,
32
+ p: payload.permissions,
33
+ ...(payload.agentId ? { a: payload.agentId } : {}),
34
+ iat: Math.floor(Date.now() / 1000),
35
+ }),
36
+ ),
37
+ );
38
+ const sig = b64url(createHmac("sha256", secret).update(body).digest());
39
+ return `${PREFIX}${body}.${sig}`;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
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
@@ -1080,12 +1080,35 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
1080
1080
  kind: expectStringValue(row.kind, "agent.kind"),
1081
1081
  };
1082
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;
1083
1086
  if (typeof row.defaultPrompt === "string") {
1084
1087
  out.defaultPrompt = row.defaultPrompt;
1085
1088
  }
1086
1089
  if (typeof row.initialPrompt === "string") {
1087
1090
  out.initialPrompt = row.initialPrompt;
1088
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
+ }
1089
1112
  return out;
1090
1113
  });
1091
1114
  }
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,6 +123,9 @@ 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[];