@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.
- package/lib/agent-cli.ts +223 -0
- package/lib/agents/claude.ts +4 -2
- package/lib/agents/codex.ts +5 -2
- package/lib/agents/proc.ts +9 -0
- package/lib/agents/types.ts +18 -0
- package/lib/orchestrator.ts +98 -0
- package/lib/skills.ts +263 -0
- package/lib/task-token.ts +40 -0
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/main.ts +23 -0
- package/src/protocol.ts +14 -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,
|
|
@@ -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
|
});
|
package/lib/agents/codex.ts
CHANGED
|
@@ -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
|
});
|
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/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/orchestrator.ts
CHANGED
|
@@ -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
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[];
|