@runuai/host 0.9.167 → 0.9.168
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 +789 -21
- package/lib/agents/cursor.ts +16 -10
- package/lib/agents/durable-proc.ts +12 -5
- package/lib/agents/machine-durable.ts +12 -5
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/transport.ts +5 -0
- package/lib/assignment-evidence.ts +93 -0
- package/lib/cloud-credential-leases.ts +23 -6
- package/lib/cloud-credential-runtime.ts +10 -1
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/engine-accounts.ts +4 -6
- package/lib/host-env.ts +2 -27
- package/lib/orchestrator.ts +97 -16
- package/lib/project-env-loader.ts +5 -0
- package/lib/secrets-proxy/policy.ts +12 -3
- package/lib/task-env-source.ts +2 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1052 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-token.ts +3 -0
- package/package.json +14 -2
- package/src/cloud-credentials.ts +41 -4
- package/src/engine-account-paths.ts +11 -0
- package/src/index.ts +66 -3
- package/src/main.ts +125 -0
- package/src/protocol.ts +185 -2
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/workspace-path-policy.ts +41 -0
package/lib/agent-cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateShareablePath, ENGINE_ACCOUNT_PATH_RULES } from "../src/workspace-path-policy";
|
|
1
2
|
/**
|
|
2
3
|
* In-container `uai` CLI materialisation (ADR-048).
|
|
3
4
|
*
|
|
@@ -126,7 +127,7 @@ export async function writeAgentCli(
|
|
|
126
127
|
): Promise<boolean> {
|
|
127
128
|
if (
|
|
128
129
|
!apiUrl ||
|
|
129
|
-
(
|
|
130
|
+
(roster.length === 0 && !allowPermissionless)
|
|
130
131
|
) {
|
|
131
132
|
return false;
|
|
132
133
|
}
|
|
@@ -161,7 +162,7 @@ export async function writeAgentCliViaEnvironment(
|
|
|
161
162
|
): Promise<boolean> {
|
|
162
163
|
if (
|
|
163
164
|
!apiUrl ||
|
|
164
|
-
(
|
|
165
|
+
(roster.length === 0 && !allowPermissionless)
|
|
165
166
|
) {
|
|
166
167
|
return false;
|
|
167
168
|
}
|
|
@@ -199,6 +200,7 @@ export function agentCliEnv(
|
|
|
199
200
|
ownerUserId: string | null,
|
|
200
201
|
apiUrl: string | null,
|
|
201
202
|
cliSecret: string | null,
|
|
203
|
+
leadRunId?: string | null,
|
|
202
204
|
): Record<string, string> {
|
|
203
205
|
const runtimeAuthority = { ...AGENT_RUNTIME_AUTHORITY_ENV };
|
|
204
206
|
const permissions = agent.permissions ?? [];
|
|
@@ -213,6 +215,7 @@ export function agentCliEnv(
|
|
|
213
215
|
agentId: agent.id,
|
|
214
216
|
// ADR-157 §3: binds the token to this agent, not merely its handle.
|
|
215
217
|
...(agent.tokenGeneration ? { generation: agent.tokenGeneration } : {}),
|
|
218
|
+
...(leadRunId ? { leadRunId } : {}),
|
|
216
219
|
},
|
|
217
220
|
cliSecret,
|
|
218
221
|
),
|
|
@@ -227,9 +230,11 @@ export function agentCliEnv(
|
|
|
227
230
|
|
|
228
231
|
export const CLI_SOURCE = `#!/usr/bin/env node
|
|
229
232
|
// uai — in-task agent CLI (ADR-048). Talks to the cloud agent API as the task.
|
|
230
|
-
import { readFileSync } from "node:fs";
|
|
231
|
-
import {
|
|
233
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
234
|
+
import { open, realpath } from "node:fs/promises";
|
|
235
|
+
import { dirname, isAbsolute, join, relative, resolve, sep, basename } from "node:path";
|
|
232
236
|
import { fileURLToPath } from "node:url";
|
|
237
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
233
238
|
|
|
234
239
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
235
240
|
let cfg = {};
|
|
@@ -285,28 +290,529 @@ async function api(method, path, body) {
|
|
|
285
290
|
return json;
|
|
286
291
|
}
|
|
287
292
|
function out(v) { console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2)); }
|
|
293
|
+
const NL = String.fromCharCode(10);
|
|
294
|
+
function flagValue(v) { return typeof v === "string" && v !== "true" && v.length > 0; }
|
|
295
|
+
// ADR-161 §3.2: setup fields read from files, so a brief or a .tool-versions
|
|
296
|
+
// keeps its newlines and quotes without shell escaping.
|
|
297
|
+
function readFlagFile(path) {
|
|
298
|
+
try { return path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8"); }
|
|
299
|
+
catch (error) { console.error("uai: cannot read " + path + ": " + error.message); process.exit(1); }
|
|
300
|
+
}
|
|
301
|
+
function projectSetupFields(flags) {
|
|
302
|
+
const fields = {};
|
|
303
|
+
if (flagValue(flags.slug)) fields.slug = flags.slug;
|
|
304
|
+
if (flagValue(flags["prompt-file"])) fields.defaultPrompt = readFlagFile(flags["prompt-file"]);
|
|
305
|
+
else if (flagValue(flags.prompt)) fields.defaultPrompt = flags.prompt;
|
|
306
|
+
if (flagValue(flags["previews-file"])) {
|
|
307
|
+
try { fields.previewPorts = JSON.parse(readFlagFile(flags["previews-file"])); }
|
|
308
|
+
catch { console.error("uai: --previews-file must hold a JSON array of preview declarations"); process.exit(1); }
|
|
309
|
+
}
|
|
310
|
+
if (flagValue(flags["tool-versions-file"])) fields.toolVersions = readFlagFile(flags["tool-versions-file"]);
|
|
311
|
+
return fields;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ADR-161 §2: add a repository to the running task this agent is on. The add
|
|
315
|
+
// is a durable operation; by default the CLI waits for its outcome.
|
|
316
|
+
function opKey() { return "cli-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 12); }
|
|
317
|
+
function opLine(op) {
|
|
318
|
+
if (op.state === "failed") return "could not add " + op.slug + ": " + (op.error ? op.error.code + " — " + op.error.message : "failed");
|
|
319
|
+
if (op.state === "ready" || op.state === "ready_with_warning") {
|
|
320
|
+
return "added " + op.slug + " at " + op.path + (op.warning ? " (warning: " + op.warning + ")" : "");
|
|
321
|
+
}
|
|
322
|
+
return "adding " + op.slug + " (" + op.state + "); check again with: uai task project status " + op.operationId;
|
|
323
|
+
}
|
|
324
|
+
async function waitForProjectOp(op) {
|
|
325
|
+
const deadline = Date.now() + 25 * 60 * 1000;
|
|
326
|
+
while (!op.done && Date.now() < deadline) {
|
|
327
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
328
|
+
op = (await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(op.operationId))).operation;
|
|
329
|
+
}
|
|
330
|
+
return op;
|
|
331
|
+
}
|
|
332
|
+
async function addProjectToTask(project, wait) {
|
|
333
|
+
const started = await api("POST", "/api/agent/task/projects", { projectId: project, idempotencyKey: opKey() });
|
|
334
|
+
const op = wait ? await waitForProjectOp(started.operation) : started.operation;
|
|
335
|
+
if (op.state === "failed") { console.error("uai: " + opLine(op)); process.exit(1); }
|
|
336
|
+
out(opLine(op));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ADR-161 §1.3. UAI_FILES_ROOT and UAI_FILES_STATE_FILE exist so tests can
|
|
340
|
+
// point at a temp dir; a task leaves them unset and uses the defaults.
|
|
341
|
+
const FILES_CAP_COUNT = 500;
|
|
342
|
+
const FILES_CAP_BYTES = 1024 * 1024 * 1024;
|
|
343
|
+
const FILES_ROOT = process.env.UAI_FILES_ROOT || "/workspace/files";
|
|
344
|
+
const FILES_STATE = process.env.UAI_FILES_STATE_FILE || "/workspace/.uai/files-state.json";
|
|
345
|
+
|
|
346
|
+
function filesTarget(raw) {
|
|
347
|
+
if (raw === "org" || raw === "me") return { scope: raw, path: "" };
|
|
348
|
+
if (typeof raw === "string" && raw.startsWith("org/")) return { scope: "org", path: raw.slice(4) };
|
|
349
|
+
if (typeof raw === "string" && raw.startsWith("me/")) return { scope: "me", path: raw.slice(3) };
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
function filesQuery(scope, path, cursor) {
|
|
353
|
+
let q = "/api/agent/files?scope=" + encodeURIComponent(scope) + "&path=" + encodeURIComponent(path || "");
|
|
354
|
+
if (cursor) q += "&cursor=" + encodeURIComponent(cursor);
|
|
355
|
+
return q;
|
|
356
|
+
}
|
|
357
|
+
async function filesListFolder(scope, path, bounded) {
|
|
358
|
+
const entries = [];
|
|
359
|
+
let cursor = "";
|
|
360
|
+
let pages = 0;
|
|
361
|
+
do {
|
|
362
|
+
if (bounded && pages >= FILES_CAP_COUNT) {
|
|
363
|
+
console.error("uai: files get -r stops at 500 files");
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
pages += 1;
|
|
367
|
+
const page = await api("GET", filesQuery(scope, path, cursor));
|
|
368
|
+
for (const entry of page.entries || []) entries.push(entry);
|
|
369
|
+
cursor = page.cursor || "";
|
|
370
|
+
} while (cursor);
|
|
371
|
+
return entries;
|
|
372
|
+
}
|
|
373
|
+
function defaultFilesDest(scope, path) {
|
|
374
|
+
return FILES_ROOT + "/" + scope + (path ? "/" + path : "");
|
|
375
|
+
}
|
|
376
|
+
function filesRefuse(message) {
|
|
377
|
+
console.error("uai: " + message);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
function legacyFilesMount(root) {
|
|
381
|
+
try {
|
|
382
|
+
lstatSync(join(dirname(root), ".uai", "files-mounted"));
|
|
383
|
+
return true;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err && err.code === "ENOENT") return false;
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Default destinations stay under FILES_ROOT. The directories are opened
|
|
390
|
+
// with O_DIRECTORY|O_NOFOLLOW and held across the download; the temp file
|
|
391
|
+
// is created and renamed relative to those descriptors. An explicit <dest>
|
|
392
|
+
// uses prepareExplicitDest and is not contained.
|
|
393
|
+
function closeFds(fds) {
|
|
394
|
+
for (const fd of fds) {
|
|
395
|
+
try { closeSync(fd); } catch (err) {}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function refuseHeld(fds, message) {
|
|
399
|
+
closeFds(fds);
|
|
400
|
+
filesRefuse(message);
|
|
401
|
+
}
|
|
402
|
+
function lstatChild(dirFd, name) {
|
|
403
|
+
try {
|
|
404
|
+
return lstatSync("/proc/self/fd/" + dirFd + "/" + name);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (err && err.code === "ENOENT") return null;
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function openDirAt(parentFd, name) {
|
|
411
|
+
const target = parentFd < 0 ? name : "/proc/self/fd/" + parentFd + "/" + name;
|
|
412
|
+
let fd = -1;
|
|
413
|
+
try {
|
|
414
|
+
fd = openSync(target, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
if (err && (err.code === "ELOOP" || err.code === "ENOTDIR" || err.code === "EPERM")) {
|
|
417
|
+
filesRefuse("files destination follows a symlink");
|
|
418
|
+
}
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
const opened = fstatSync(fd);
|
|
422
|
+
const listed = lstatSync(target);
|
|
423
|
+
if (
|
|
424
|
+
listed.isSymbolicLink() ||
|
|
425
|
+
!listed.isDirectory() ||
|
|
426
|
+
opened.dev !== listed.dev ||
|
|
427
|
+
opened.ino !== listed.ino
|
|
428
|
+
) {
|
|
429
|
+
closeSync(fd);
|
|
430
|
+
filesRefuse("files destination follows a symlink");
|
|
431
|
+
}
|
|
432
|
+
return fd;
|
|
433
|
+
}
|
|
434
|
+
function staysUnder(rootReal, fileReal) {
|
|
435
|
+
return fileReal === rootReal || fileReal.indexOf(rootReal + sep) === 0;
|
|
436
|
+
}
|
|
437
|
+
function openContainedDest(dest) {
|
|
438
|
+
const fds = [];
|
|
439
|
+
const root = resolve(FILES_ROOT);
|
|
440
|
+
let rootStat = null;
|
|
441
|
+
try {
|
|
442
|
+
rootStat = lstatSync(root);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (!err || err.code !== "ENOENT") throw err;
|
|
445
|
+
}
|
|
446
|
+
if (rootStat && rootStat.isSymbolicLink()) filesRefuse("refusing a legacy shared-files mount");
|
|
447
|
+
if (legacyFilesMount(root)) filesRefuse("refusing a legacy shared-files mount");
|
|
448
|
+
const target = resolve(dest);
|
|
449
|
+
const rel = relative(root, target);
|
|
450
|
+
if (rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
|
|
451
|
+
filesRefuse("files destination must stay under /workspace/files");
|
|
452
|
+
}
|
|
453
|
+
if (!rootStat) {
|
|
454
|
+
const parent = dirname(root);
|
|
455
|
+
let parentStat = null;
|
|
456
|
+
try { parentStat = lstatSync(parent); } catch (err) {
|
|
457
|
+
if (!err || err.code !== "ENOENT") throw err;
|
|
458
|
+
}
|
|
459
|
+
if (!parentStat || parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
|
|
460
|
+
filesRefuse("files destination follows a symlink");
|
|
461
|
+
}
|
|
462
|
+
mkdirSync(root);
|
|
463
|
+
} else if (!rootStat.isDirectory()) {
|
|
464
|
+
filesRefuse("files destination is not a directory");
|
|
465
|
+
}
|
|
466
|
+
const rootFd = openDirAt(-1, root);
|
|
467
|
+
fds.push(rootFd);
|
|
468
|
+
// The allowed root is the pathname at open time, not wherever the held
|
|
469
|
+
// directory descriptor points later. realOf(rootFd) follows a rename of
|
|
470
|
+
// /workspace/files itself, so both sides of the comparison would move and
|
|
471
|
+
// a download would land outside. Pin realpath once, before any await.
|
|
472
|
+
let rootReal = "";
|
|
473
|
+
try {
|
|
474
|
+
rootReal = realpathSync(root);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
refuseHeld(fds, "files destination left /workspace/files");
|
|
477
|
+
}
|
|
478
|
+
const pinned = lstatSync(rootReal);
|
|
479
|
+
const openedRoot = fstatSync(rootFd);
|
|
480
|
+
if (
|
|
481
|
+
pinned.isSymbolicLink() ||
|
|
482
|
+
!pinned.isDirectory() ||
|
|
483
|
+
pinned.dev !== openedRoot.dev ||
|
|
484
|
+
pinned.ino !== openedRoot.ino
|
|
485
|
+
) {
|
|
486
|
+
refuseHeld(fds, "files destination left /workspace/files");
|
|
487
|
+
}
|
|
488
|
+
const parts = rel.split(sep);
|
|
489
|
+
let dirFd = rootFd;
|
|
490
|
+
for (let i = 0; i < parts.length; i++) {
|
|
491
|
+
const name = parts[i];
|
|
492
|
+
if (!name || name === "." || name === "..") {
|
|
493
|
+
refuseHeld(fds, "files destination must stay under /workspace/files");
|
|
494
|
+
}
|
|
495
|
+
const last = i === parts.length - 1;
|
|
496
|
+
const st = lstatChild(dirFd, name);
|
|
497
|
+
if (st && st.isSymbolicLink()) refuseHeld(fds, "files destination follows a symlink");
|
|
498
|
+
if (last) {
|
|
499
|
+
if (st && !st.isFile()) refuseHeld(fds, "files destination is not a file");
|
|
500
|
+
return { rootFd: rootFd, dirFd: dirFd, name: name, fds: fds, rootReal: rootReal };
|
|
501
|
+
}
|
|
502
|
+
if (!st) {
|
|
503
|
+
try {
|
|
504
|
+
mkdirSync("/proc/self/fd/" + dirFd + "/" + name);
|
|
505
|
+
} catch (err) {
|
|
506
|
+
if (!err || err.code !== "EEXIST") {
|
|
507
|
+
closeFds(fds);
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const created = lstatChild(dirFd, name);
|
|
512
|
+
if (!created || created.isSymbolicLink()) refuseHeld(fds, "files destination follows a symlink");
|
|
513
|
+
if (!created.isDirectory()) refuseHeld(fds, "files destination is not a directory");
|
|
514
|
+
} else if (!st.isDirectory()) {
|
|
515
|
+
refuseHeld(fds, "files destination is not a directory");
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
dirFd = openDirAt(dirFd, name);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
closeFds(fds);
|
|
521
|
+
throw err;
|
|
522
|
+
}
|
|
523
|
+
fds.push(dirFd);
|
|
524
|
+
}
|
|
525
|
+
refuseHeld(fds, "files destination must stay under /workspace/files");
|
|
526
|
+
}
|
|
527
|
+
function prepareExplicitDest(dest) {
|
|
528
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
529
|
+
return dest;
|
|
530
|
+
}
|
|
531
|
+
let filesPartSeq = 0;
|
|
532
|
+
function recordFileState(scope, path, etag) {
|
|
533
|
+
mkdirSync(dirname(FILES_STATE), { recursive: true });
|
|
534
|
+
const lock = FILES_STATE + ".lock";
|
|
535
|
+
let fd = -1;
|
|
536
|
+
const started = Date.now();
|
|
537
|
+
while (fd < 0) {
|
|
538
|
+
try {
|
|
539
|
+
fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
if (!err || err.code !== "EEXIST") throw err;
|
|
542
|
+
if (Date.now() - started > 5000) throw new Error("files state is locked");
|
|
543
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
let state = {};
|
|
548
|
+
try { state = JSON.parse(readFileSync(FILES_STATE, "utf8")); } catch {}
|
|
549
|
+
if (!state || typeof state !== "object") state = {};
|
|
550
|
+
state[scope + "/" + path] = { etag: etag };
|
|
551
|
+
const tmp = FILES_STATE + ".tmp";
|
|
552
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
553
|
+
renameSync(tmp, FILES_STATE);
|
|
554
|
+
} finally {
|
|
555
|
+
closeSync(fd);
|
|
556
|
+
try { unlinkSync(lock); } catch {}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function procChild(dirFd, name) {
|
|
560
|
+
return "/proc/self/fd/" + dirFd + "/" + name;
|
|
561
|
+
}
|
|
562
|
+
function unlinkChild(dirFd, name) {
|
|
563
|
+
try { unlinkSync(procChild(dirFd, name)); } catch (unlinkErr) {}
|
|
564
|
+
}
|
|
565
|
+
function realOf(fd) {
|
|
566
|
+
return realpathSync("/proc/self/fd/" + fd);
|
|
567
|
+
}
|
|
568
|
+
function fileUnderRoot(rootReal, fd) {
|
|
569
|
+
try {
|
|
570
|
+
return staysUnder(rootReal, realOf(fd));
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function refuseEscaped(dirFd, name, fd) {
|
|
576
|
+
if (fd >= 0) {
|
|
577
|
+
try { closeSync(fd); } catch (closeErr) {}
|
|
578
|
+
}
|
|
579
|
+
unlinkChild(dirFd, name);
|
|
580
|
+
filesRefuse("files destination left /workspace/files");
|
|
581
|
+
}
|
|
582
|
+
async function streamInto(fd, body, budget) {
|
|
583
|
+
const reader = body.getReader();
|
|
584
|
+
let accepted = 0;
|
|
585
|
+
while (true) {
|
|
586
|
+
const step = await reader.read();
|
|
587
|
+
if (step.done) break;
|
|
588
|
+
const buf = Buffer.from(step.value);
|
|
589
|
+
if (budget && accepted + buf.length > budget.remaining) {
|
|
590
|
+
try { await reader.cancel(); } catch (cancelErr) {}
|
|
591
|
+
const capErr = new Error("cap");
|
|
592
|
+
capErr.code = "FILES_CAP";
|
|
593
|
+
throw capErr;
|
|
594
|
+
}
|
|
595
|
+
writeSync(fd, buf);
|
|
596
|
+
accepted += buf.length;
|
|
597
|
+
}
|
|
598
|
+
return accepted;
|
|
599
|
+
}
|
|
600
|
+
function throwFilesCap(err) {
|
|
601
|
+
if (err && err.code === "FILES_CAP") {
|
|
602
|
+
console.error("uai: files get -r stops at 1 GB");
|
|
603
|
+
process.exit(1);
|
|
604
|
+
}
|
|
605
|
+
throw err;
|
|
606
|
+
}
|
|
607
|
+
// Explicit destinations are the caller's path. Default destinations are
|
|
608
|
+
// created and renamed through the directory descriptors opened before the
|
|
609
|
+
// link request, then checked to still sit under the files root.
|
|
610
|
+
async function writePath(dest, res, budget) {
|
|
611
|
+
filesPartSeq += 1;
|
|
612
|
+
const tmp = dest + ".part-" + process.pid + "-" + filesPartSeq;
|
|
613
|
+
let fd = -1;
|
|
614
|
+
try {
|
|
615
|
+
fd = openSync(tmp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW);
|
|
616
|
+
const accepted = await streamInto(fd, res.body, budget);
|
|
617
|
+
closeSync(fd);
|
|
618
|
+
fd = -1;
|
|
619
|
+
renameSync(tmp, dest);
|
|
620
|
+
if (budget) budget.remaining -= accepted;
|
|
621
|
+
} catch (err) {
|
|
622
|
+
if (fd >= 0) { try { closeSync(fd); } catch (closeErr) {} }
|
|
623
|
+
try { unlinkSync(tmp); } catch (unlinkErr) {}
|
|
624
|
+
throwFilesCap(err);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async function writeHeld(held, res, budget) {
|
|
628
|
+
const rootReal = held.rootReal;
|
|
629
|
+
if (typeof rootReal !== "string" || !rootReal || !staysUnder(rootReal, realOf(held.dirFd))) {
|
|
630
|
+
filesRefuse("files destination left /workspace/files");
|
|
631
|
+
}
|
|
632
|
+
const existing = lstatChild(held.dirFd, held.name);
|
|
633
|
+
if (existing && existing.isSymbolicLink()) filesRefuse("files destination follows a symlink");
|
|
634
|
+
if (existing && !existing.isFile()) filesRefuse("files destination is not a file");
|
|
635
|
+
filesPartSeq += 1;
|
|
636
|
+
const tmpName = ".part-" + process.pid + "-" + filesPartSeq;
|
|
637
|
+
const tmpPath = procChild(held.dirFd, tmpName);
|
|
638
|
+
const finalPath = procChild(held.dirFd, held.name);
|
|
639
|
+
let fd = -1;
|
|
640
|
+
try {
|
|
641
|
+
try {
|
|
642
|
+
fd = openSync(tmpPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
if (err && err.code === "ELOOP") filesRefuse("files destination follows a symlink");
|
|
645
|
+
throw err;
|
|
646
|
+
}
|
|
647
|
+
if (!fileUnderRoot(rootReal, fd)) refuseEscaped(held.dirFd, tmpName, fd);
|
|
648
|
+
const accepted = await streamInto(fd, res.body, budget);
|
|
649
|
+
if (!fileUnderRoot(rootReal, fd)) refuseEscaped(held.dirFd, tmpName, fd);
|
|
650
|
+
closeSync(fd);
|
|
651
|
+
fd = -1;
|
|
652
|
+
if (!staysUnder(rootReal, realOf(held.dirFd))) {
|
|
653
|
+
unlinkChild(held.dirFd, tmpName);
|
|
654
|
+
filesRefuse("files destination left /workspace/files");
|
|
655
|
+
}
|
|
656
|
+
renameSync(tmpPath, finalPath);
|
|
657
|
+
const placed = lstatChild(held.dirFd, held.name);
|
|
658
|
+
let placedReal = "";
|
|
659
|
+
try { placedReal = realpathSync(finalPath); } catch (err) { placedReal = ""; }
|
|
660
|
+
if (!placed || placed.isSymbolicLink() || !placed.isFile() || !staysUnder(rootReal, placedReal)) {
|
|
661
|
+
unlinkChild(held.dirFd, held.name);
|
|
662
|
+
filesRefuse("files destination left /workspace/files");
|
|
663
|
+
}
|
|
664
|
+
if (budget) budget.remaining -= accepted;
|
|
665
|
+
} catch (err) {
|
|
666
|
+
if (fd >= 0) { try { closeSync(fd); } catch (closeErr) {} }
|
|
667
|
+
unlinkChild(held.dirFd, tmpName);
|
|
668
|
+
throwFilesCap(err);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
async function downloadFile(scope, path, dest, contained, budget) {
|
|
672
|
+
const held = contained ? openContainedDest(dest) : null;
|
|
673
|
+
try {
|
|
674
|
+
if (!contained) prepareExplicitDest(dest);
|
|
675
|
+
const link = await api(
|
|
676
|
+
"GET",
|
|
677
|
+
"/api/agent/files/link?scope=" + encodeURIComponent(scope) + "&path=" + encodeURIComponent(path),
|
|
678
|
+
);
|
|
679
|
+
let res;
|
|
680
|
+
try {
|
|
681
|
+
res = await fetch(link.url);
|
|
682
|
+
} catch {
|
|
683
|
+
console.error("uai: download failed");
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
if (!res.ok) {
|
|
687
|
+
console.error("uai: download failed (" + res.status + ")");
|
|
688
|
+
process.exit(1);
|
|
689
|
+
}
|
|
690
|
+
const etag = res.headers.get("etag");
|
|
691
|
+
if (!etag || (link.etag && etag !== link.etag)) {
|
|
692
|
+
if (res.body && res.body.cancel) {
|
|
693
|
+
try { await res.body.cancel(); } catch (cancelErr) {}
|
|
694
|
+
}
|
|
695
|
+
console.error(etag ? "uai: download etag does not match the inspected file" : "uai: download did not name an etag");
|
|
696
|
+
process.exit(1);
|
|
697
|
+
}
|
|
698
|
+
if (!res.body || !res.body.getReader) {
|
|
699
|
+
console.error("uai: download failed");
|
|
700
|
+
process.exit(1);
|
|
701
|
+
}
|
|
702
|
+
if (held) await writeHeld(held, res, budget);
|
|
703
|
+
else await writePath(dest, res, budget);
|
|
704
|
+
recordFileState(scope, path, etag);
|
|
705
|
+
} finally {
|
|
706
|
+
if (held) closeFds(held.fds);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Explicit '-' only: omitted argv text never blocks waiting for a terminal.
|
|
711
|
+
// Existing cloud schemas enforce the same instruction/prompt length limits.
|
|
712
|
+
function stdinText(label) {
|
|
713
|
+
if (process.stdin.isTTY) {
|
|
714
|
+
console.error("uai: " + label + " stdin is a terminal; use a pipe or a quoted heredoc with - <<'EOF' (see uai --help)");
|
|
715
|
+
process.exit(1);
|
|
716
|
+
}
|
|
717
|
+
const text = readFileSync(0, "utf8").trim();
|
|
718
|
+
if (!text) { console.error("uai: " + label + " stdin is empty"); process.exit(1); }
|
|
719
|
+
return text;
|
|
720
|
+
}
|
|
288
721
|
|
|
289
722
|
const argv = process.argv.slice(2);
|
|
290
723
|
const [group, action, ...rest] = argv;
|
|
291
724
|
const { flags, rest: pos } = parseFlags(rest);
|
|
292
725
|
|
|
726
|
+
const validateShareablePath = ${validateShareablePath.toString()};
|
|
727
|
+
const assertShareablePath = (path) => validateShareablePath(path, ${JSON.stringify(ENGINE_ACCOUNT_PATH_RULES)});
|
|
293
728
|
async function main() {
|
|
729
|
+
if (group === "secret") {
|
|
730
|
+
if (action !== "request" || pos.length !== 1 || !flags.why || Object.keys(flags).some(key => !["why", "proxy-domain", "no-wait"].includes(key))) { console.error('uai: secret request NAME --why "reason" [--proxy-domain host]… [--no-wait]'); process.exit(1); }
|
|
731
|
+
// Repeated domains retain order from argv; the generic flag parser intentionally keeps one value.
|
|
732
|
+
const domains = [];
|
|
733
|
+
for (let i = 0; i < rest.length; i++) if (rest[i] === "--proxy-domain" && rest[i + 1]) domains.push(rest[++i]);
|
|
734
|
+
const created = await api("POST", "/api/agent/secrets/requests", { name: pos[0], why: flags.why, domains });
|
|
735
|
+
let request = created.request;
|
|
736
|
+
out({ request });
|
|
737
|
+
if (flags["no-wait"] === "true") return;
|
|
738
|
+
while (request.status === "pending") {
|
|
739
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
740
|
+
request = (await api("GET", "/api/agent/secrets/requests/" + encodeURIComponent(request.id))).request;
|
|
741
|
+
}
|
|
742
|
+
out({ request });
|
|
743
|
+
if (request.status !== "provided") process.exit(1);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (group === "artifacts") {
|
|
747
|
+
const rows = (await api("GET", "/api/agent/artifacts")).artifacts || [];
|
|
748
|
+
out(rows.length ? rows.map((a) => a.id + " " + a.name + " (" + a.size + " bytes) " + API_URL + a.open).join("\\n") : "no shared files");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (group === "share") {
|
|
752
|
+
const parsed = parseFlags([action, ...rest].filter(Boolean));
|
|
753
|
+
if (parsed.rest.length !== 1 || Object.keys(parsed.flags).some((k) => k !== "title")) throw new Error("share <path> [--title <text>]");
|
|
754
|
+
const requestedPath = isAbsolute(parsed.rest[0]) ? parsed.rest[0] : process.cwd() + "/" + parsed.rest[0];
|
|
755
|
+
const lexical = assertShareablePath(requestedPath);
|
|
756
|
+
const sourcePath = assertShareablePath(await realpath(lexical));
|
|
757
|
+
const file = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
758
|
+
try {
|
|
759
|
+
const stat = await file.stat();
|
|
760
|
+
if (!stat.isFile() || stat.size > 100 * 1024 * 1024) throw new Error("share needs a regular file of at most 100 MiB");
|
|
761
|
+
const hash = createHash("sha256");
|
|
762
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) hash.update(chunk);
|
|
763
|
+
const created = await api("POST", "/api/agent/artifacts", { name: basename(sourcePath), size: stat.size, sha256: hash.digest("hex"), sourcePath, requestedPath, title: parsed.flags.title });
|
|
764
|
+
const uploaded = await fetch(created.upload.url, { method: "PUT", headers: created.upload.headers, body: file.createReadStream({ start: 0, autoClose: false }), duplex: "half" });
|
|
765
|
+
if (!uploaded.ok) throw new Error("staging upload refused (" + uploaded.status + "); retry share to make a new receipt");
|
|
766
|
+
let done;
|
|
767
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
768
|
+
done = await api("POST", "/api/agent/artifacts/" + encodeURIComponent(created.artifact.id) + "/complete");
|
|
769
|
+
if (done.result !== "pending") break;
|
|
770
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
771
|
+
}
|
|
772
|
+
if (done?.result !== "published" || done.artifact.state !== "ready") throw new Error("artifact " + created.artifact.id + " is " + (done?.result || "recovering") + "; check uai artifacts shortly");
|
|
773
|
+
out(done.artifact.id + " " + API_URL + done.artifact.open);
|
|
774
|
+
} finally { await file.close(); }
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
294
777
|
if (group === "dispatch") {
|
|
295
778
|
const args = [action, ...rest].filter((value) => typeof value === "string");
|
|
296
779
|
const recipientArgs = [];
|
|
297
780
|
while (args.length && /^@[A-Za-z0-9_-]+$/.test(args[0])) {
|
|
298
781
|
recipientArgs.push(args.shift().slice(1));
|
|
299
782
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
783
|
+
if (!recipientArgs.length) {
|
|
784
|
+
console.error('uai: dispatch needs one or more @agent ids and a quoted instruction (or - for stdin)');
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
const tags = {};
|
|
788
|
+
while (args[0] && ["--kind", "--size", "--pr", "--request-id"].includes(args[0])) {
|
|
789
|
+
const key = args.shift().slice(2);
|
|
790
|
+
const value = args.shift();
|
|
791
|
+
if (!value || value === "-" || value.startsWith("--")) {
|
|
792
|
+
console.error("uai: dispatch --" + key + " needs a value"); process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
tags[key === "request-id" ? "requestId" : key] = value;
|
|
795
|
+
}
|
|
796
|
+
if (tags.kind && !["build", "fix", "review", "research", "design", "docs", "ops"].includes(tags.kind)) {
|
|
797
|
+
console.error("uai: dispatch --kind must be build, fix, review, research, design, docs or ops"); process.exit(1);
|
|
798
|
+
}
|
|
799
|
+
if (tags.size && !["s", "m", "l"].includes(tags.size)) {
|
|
800
|
+
console.error("uai: dispatch --size must be s, m or l"); process.exit(1);
|
|
801
|
+
}
|
|
802
|
+
const instruction = args.length === 1 && args[0] === "-" ? stdinText("dispatch") : args.join(" ").trim();
|
|
803
|
+
if (!instruction) {
|
|
804
|
+
console.error('uai: dispatch needs one or more @agent ids and a quoted instruction (or - for stdin)');
|
|
303
805
|
process.exit(1);
|
|
304
806
|
}
|
|
305
807
|
const result = await api("POST", "/api/agent/dispatch", {
|
|
306
808
|
to: recipientArgs,
|
|
307
809
|
instruction,
|
|
810
|
+
requestId: tags.requestId || randomUUID(),
|
|
811
|
+
...tags,
|
|
308
812
|
});
|
|
309
|
-
out("dispatched to " + result.recipients.map((id) => "@" + id).join(", "));
|
|
813
|
+
if (result.recipients.length) out("dispatched to " + result.recipients.map((id) => "@" + id).join(", "));
|
|
814
|
+
if (result.pendingRecipients && result.pendingRecipients.length) out("pending/in flight: " + result.pendingRecipients.map((id) => "@" + id).join(", "));
|
|
815
|
+
if (result.canceledRecipients && result.canceledRecipients.length) out("canceled: " + result.canceledRecipients.map((id) => "@" + id).join(", "));
|
|
310
816
|
if (Array.isArray(result.failedRecipients) && result.failedRecipients.length) {
|
|
311
817
|
console.error(
|
|
312
818
|
"uai: could not wake " +
|
|
@@ -315,11 +821,29 @@ async function main() {
|
|
|
315
821
|
}
|
|
316
822
|
return;
|
|
317
823
|
}
|
|
824
|
+
if (group === "review" && action === "report") {
|
|
825
|
+
const counts = ["must", "should", "nits"];
|
|
826
|
+
if (!flags.pr || !/^[0-9a-f]{40}$/.test(flags.head || "") || !["approve", "changes"].includes(flags.verdict) ||
|
|
827
|
+
counts.some(k => !/^[0-9]+$/.test(flags[k] || "") || Number(flags[k]) > 1000) ||
|
|
828
|
+
(flags.revision && !/^[0-9]+$/.test(flags.revision))) {
|
|
829
|
+
console.error("uai: review report --pr <number|URL> --head <full-sha> --must <0..1000> --should <0..1000> --nits <0..1000> --verdict approve|changes [--summary text] [--assignment id] [--revision n]"); process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
out(await api("POST", "/api/agent/review/report", { pr: flags.pr, head: flags.head,
|
|
832
|
+
must: Number(flags.must), should: Number(flags.should), nits: Number(flags.nits), verdict: flags.verdict,
|
|
833
|
+
summary: flags.summary, assignmentId: flags.assignment, expectedRevision: Number(flags.revision || 0) })); return;
|
|
834
|
+
}
|
|
318
835
|
if (group === "crew") {
|
|
319
836
|
// ADR-157: the task's built-in Astronaut (the @astronaut Uai builds, never a
|
|
320
837
|
// persona) changes its own crew. One agent per call, always with a --why.
|
|
321
838
|
const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
|
|
322
839
|
const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
|
|
840
|
+
if (action === "outcome") {
|
|
841
|
+
if (!pos[0] || !["accepted", "reworked", "failed"].includes(pos[1]) || (flags.revision && !/^[0-9]+$/.test(flags.revision))) {
|
|
842
|
+
console.error("uai: crew outcome @agent accepted|reworked|failed [--assignment id] [--note text] [--revision n]"); process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
out(await api("POST", "/api/agent/crew-outcome", { recipient: handleOf(pos[0]), outcome: pos[1],
|
|
845
|
+
assignmentId: flags.assignment, note: flags.note, expectedRevision: Number(flags.revision || 0) })); return;
|
|
846
|
+
}
|
|
323
847
|
if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
|
|
324
848
|
if (action === "status") { out(await api("GET", "/api/agent/crew-status")); return; }
|
|
325
849
|
if (action === "store") { out(await api("GET", "/api/agent/crew-store")); return; }
|
|
@@ -393,6 +917,88 @@ async function main() {
|
|
|
393
917
|
console.error("uai: crew commands are list, status, store, suggest, add, model and remove (Astronaut only)");
|
|
394
918
|
process.exit(1);
|
|
395
919
|
}
|
|
920
|
+
if (group === "files") {
|
|
921
|
+
const args = rest.filter((value) => typeof value === "string");
|
|
922
|
+
const recursive = args.indexOf("-r") !== -1;
|
|
923
|
+
const positional = args.filter((value) => value !== "-r");
|
|
924
|
+
if (action === "ls") {
|
|
925
|
+
if (positional.length > 1) {
|
|
926
|
+
console.error("uai: files ls [org|me[/prefix]]");
|
|
927
|
+
process.exit(1);
|
|
928
|
+
}
|
|
929
|
+
const target = filesTarget(positional[0] || "org");
|
|
930
|
+
if (!target) {
|
|
931
|
+
console.error("uai: files ls [org|me[/prefix]]");
|
|
932
|
+
process.exit(1);
|
|
933
|
+
}
|
|
934
|
+
out({ entries: await filesListFolder(target.scope, target.path) });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (action === "get") {
|
|
938
|
+
if (!positional.length || positional.length > 2) {
|
|
939
|
+
console.error("uai: files get <org|me>/<path> [<dest>] [-r]");
|
|
940
|
+
process.exit(1);
|
|
941
|
+
}
|
|
942
|
+
const target = filesTarget(positional[0]);
|
|
943
|
+
if (!target) {
|
|
944
|
+
console.error("uai: files get <org|me>/<path> [<dest>] [-r]");
|
|
945
|
+
process.exit(1);
|
|
946
|
+
}
|
|
947
|
+
if (!recursive) {
|
|
948
|
+
if (!target.path) {
|
|
949
|
+
console.error("uai: files get needs a file path (or -r for a folder)");
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
const dest = positional[1] || defaultFilesDest(target.scope, target.path);
|
|
953
|
+
await downloadFile(target.scope, target.path, dest, !positional[1], null);
|
|
954
|
+
out(dest);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const root = target.path;
|
|
958
|
+
const pending = [root];
|
|
959
|
+
const files = [];
|
|
960
|
+
let bytes = 0;
|
|
961
|
+
let folders = 0;
|
|
962
|
+
while (pending.length) {
|
|
963
|
+
if (folders >= FILES_CAP_COUNT) {
|
|
964
|
+
console.error("uai: files get -r stops at 500 files");
|
|
965
|
+
process.exit(1);
|
|
966
|
+
}
|
|
967
|
+
folders += 1;
|
|
968
|
+
const dir = pending.shift();
|
|
969
|
+
const entries = await filesListFolder(target.scope, dir, true);
|
|
970
|
+
for (const entry of entries) {
|
|
971
|
+
const child = dir ? dir + "/" + entry.name : entry.name;
|
|
972
|
+
if (entry.dir) {
|
|
973
|
+
pending.push(child);
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const size = entry.size || 0;
|
|
977
|
+
if (files.length >= FILES_CAP_COUNT) {
|
|
978
|
+
console.error("uai: files get -r stops at 500 files");
|
|
979
|
+
process.exit(1);
|
|
980
|
+
}
|
|
981
|
+
if (bytes + size > FILES_CAP_BYTES) {
|
|
982
|
+
console.error("uai: files get -r stops at 1 GB");
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
bytes += size;
|
|
986
|
+
files.push(child);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const budget = { remaining: FILES_CAP_BYTES };
|
|
990
|
+
const explicitRoot = positional[1] ? String(positional[1]).replace(/\\/+$/, "") : "";
|
|
991
|
+
for (const file of files) {
|
|
992
|
+
const rel = root && file.indexOf(root + "/") === 0 ? file.slice(root.length + 1) : file;
|
|
993
|
+
const dest = explicitRoot ? explicitRoot + "/" + rel : defaultFilesDest(target.scope, file);
|
|
994
|
+
await downloadFile(target.scope, file, dest, !explicitRoot, budget);
|
|
995
|
+
}
|
|
996
|
+
out({ files: files.length, dest: positional[1] || defaultFilesDest(target.scope, root) });
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
console.error("uai: files commands are ls and get");
|
|
1000
|
+
process.exit(1);
|
|
1001
|
+
}
|
|
396
1002
|
const key = group + " " + (action || "");
|
|
397
1003
|
switch (key) {
|
|
398
1004
|
case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
|
|
@@ -438,23 +1044,79 @@ async function main() {
|
|
|
438
1044
|
break;
|
|
439
1045
|
}
|
|
440
1046
|
case "task create": {
|
|
441
|
-
|
|
1047
|
+
const prompt = flags.prompt === "-" ? stdinText("task create --prompt -") : flags.prompt;
|
|
1048
|
+
let astronaut;
|
|
1049
|
+
if (flags.astronaut) {
|
|
1050
|
+
const parts = String(flags.astronaut).split("/");
|
|
1051
|
+
if (parts.length < 2 || parts.some((part) => !part) || flags.astronaut === "true") {
|
|
1052
|
+
console.error("uai: --astronaut needs engine/model[/effort]"); process.exit(1);
|
|
1053
|
+
}
|
|
1054
|
+
const kind = parts.shift();
|
|
1055
|
+
const effort = parts.length > 1 ? parts.pop() : undefined;
|
|
1056
|
+
const model = parts.join("/");
|
|
1057
|
+
astronaut = { kind, ...(model !== "default" ? { model } : {}), ...(effort && effort !== "default" ? { effort } : {}) };
|
|
1058
|
+
}
|
|
1059
|
+
if (flags.start && flags.start !== "true") {
|
|
1060
|
+
console.error("uai: --start is a flag; it takes no value"); process.exit(1);
|
|
1061
|
+
}
|
|
1062
|
+
if (flags.start && (!flags["request-id"] || flags["request-id"] === "true")) {
|
|
1063
|
+
console.error("uai: --start requires --request-id <logical-run-id>; reuse it after a timeout"); process.exit(1);
|
|
1064
|
+
}
|
|
1065
|
+
const created = (await api("POST", "/api/agent/tasks", {
|
|
442
1066
|
name: flags.name,
|
|
443
|
-
prompt
|
|
1067
|
+
prompt,
|
|
444
1068
|
projects: flags.projects ? String(flags.projects).split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
445
1069
|
team: flags.team || null,
|
|
446
1070
|
agents: flags.agents ? String(flags.agents).split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
447
1071
|
parent: flags.parent || null,
|
|
448
|
-
|
|
1072
|
+
start: flags.start === "true",
|
|
1073
|
+
...(astronaut ? { astronaut } : {}),
|
|
1074
|
+
requestId: flags["request-id"] || randomUUID(),
|
|
1075
|
+
})).task;
|
|
1076
|
+
if (flags.start && (!created || typeof created.id !== "string" || !created.id || !["queued", "starting", "running"].includes(created.status))) {
|
|
1077
|
+
console.error("uai: the cloud did not start the child" + (created?.id ? " " + created.id : "") + "; it may need updating. Reuse --request-id when retrying."); process.exit(1);
|
|
1078
|
+
}
|
|
1079
|
+
out(created);
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
case "task watch":
|
|
1083
|
+
case "task usage": {
|
|
1084
|
+
if (pos.length !== 1) { console.error("uai: task " + action + " needs a child task id"); process.exit(1); }
|
|
1085
|
+
const query = new URLSearchParams();
|
|
1086
|
+
if (action === "watch") {
|
|
1087
|
+
if (flags.after) query.set("after", String(flags.after));
|
|
1088
|
+
if (flags.limit) query.set("limit", String(flags.limit));
|
|
1089
|
+
}
|
|
1090
|
+
out(await api("GET", "/api/agent/tasks/" + encodeURIComponent(pos[0]) + "/" + action + (query.size ? "?" + query.toString() : "")));
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
case "task send": {
|
|
1094
|
+
// The text is prose, not CLI flags. Preserve literal --options in it.
|
|
1095
|
+
const [childId, ...words] = rest;
|
|
1096
|
+
const text = words.join(" ").trim();
|
|
1097
|
+
if (!childId || !text) { console.error("uai: task send needs a child task id and text"); process.exit(1); }
|
|
1098
|
+
out(await api("POST", "/api/agent/tasks/" + encodeURIComponent(childId) + "/send", { text }));
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
case "task answer": {
|
|
1102
|
+
const [childId, cardId, ...words] = rest;
|
|
1103
|
+
const option = words.join(" ").trim();
|
|
1104
|
+
if (!childId || !cardId || !option) { console.error("uai: task answer needs a child task id, card id and option text"); process.exit(1); }
|
|
1105
|
+
out(await api("POST", "/api/agent/tasks/" + encodeURIComponent(childId) + "/answer", { cardId, option }));
|
|
449
1106
|
break;
|
|
450
1107
|
}
|
|
451
1108
|
case "task close": {
|
|
452
|
-
const allowed = new Set(["status", "reason"]);
|
|
1109
|
+
const allowed = new Set(["status", "reason", "child"]);
|
|
453
1110
|
const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
|
|
454
|
-
|
|
455
|
-
|
|
1111
|
+
const childMode = Object.prototype.hasOwnProperty.call(flags, "child");
|
|
1112
|
+
if ((childMode && (flags.child === "true" || !String(flags.child).trim())) || (pos.length === 1 && !String(pos[0]).trim())) {
|
|
1113
|
+
console.error("uai: --child requires a child task id"); process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
if (pos.length > 1 || unknown.length || (childMode && pos.length)) {
|
|
1116
|
+
console.error("uai: task close accepts --child <childId>, --status and --reason");
|
|
456
1117
|
process.exit(1);
|
|
457
1118
|
}
|
|
1119
|
+
const childId = childMode ? flags.child : pos[0];
|
|
458
1120
|
const status = flags.status;
|
|
459
1121
|
if (status !== "finished" && status !== "canceled") {
|
|
460
1122
|
console.error("uai: task close needs --status finished|canceled");
|
|
@@ -464,7 +1126,8 @@ async function main() {
|
|
|
464
1126
|
console.error("uai: --reason needs text (quote multiword reasons)");
|
|
465
1127
|
process.exit(1);
|
|
466
1128
|
}
|
|
467
|
-
const
|
|
1129
|
+
const path = childId ? "/api/agent/tasks/" + encodeURIComponent(childId) + "/close" : "/api/agent/tasks/close";
|
|
1130
|
+
const closed = await api("POST", path, {
|
|
468
1131
|
status,
|
|
469
1132
|
...(flags.reason ? { reason: String(flags.reason) } : {}),
|
|
470
1133
|
});
|
|
@@ -538,13 +1201,89 @@ async function main() {
|
|
|
538
1201
|
if (!r.enabled && r.public) console.error("uai: it's public but off — it serves nothing until: uai preview enable " + r.name);
|
|
539
1202
|
break;
|
|
540
1203
|
}
|
|
1204
|
+
case "project list": {
|
|
1205
|
+
const projects = (await api("GET", "/api/agent/projects")).projects || [];
|
|
1206
|
+
out(projects.length ? projects.map((p) => p.id + " " + p.slug + " " + p.name).join(NL) : "no repositories");
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
case "project show": {
|
|
1210
|
+
if (!pos[0]) { console.error("uai: project show <id|slug>"); process.exit(1); }
|
|
1211
|
+
out((await api("GET", "/api/agent/projects/" + encodeURIComponent(pos[0]))).project);
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
541
1214
|
case "project create": {
|
|
1215
|
+
const attach = rest.includes("--attach");
|
|
542
1216
|
const name = flags.name || pos.join(" ");
|
|
543
|
-
if (!name || !flags.repo) { console.error("uai: project create --name <n> --repo <git-url> [--prompt <p>]"); process.exit(1); }
|
|
544
|
-
const
|
|
1217
|
+
if (!name || !flags.repo || flags.repo === "true") { console.error("uai: project create --name <n> --repo <git-url> [--slug <s>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] [--attach]"); process.exit(1); }
|
|
1218
|
+
const body = { name, repoUrl: flags.repo, ...projectSetupFields(flags) };
|
|
1219
|
+
const p = (await api("POST", "/api/agent/projects", body)).project;
|
|
545
1220
|
out("created project " + p.slug + " (" + p.id + ")");
|
|
1221
|
+
if (attach) await addProjectToTask(p.id, true);
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
case "project update": {
|
|
1225
|
+
if (!pos[0]) { console.error("uai: project update <id|slug> [--name <n>] [--slug <s>] [--repo <url>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>]"); process.exit(1); }
|
|
1226
|
+
const body = { ...(flagValue(flags.name) ? { name: flags.name } : {}), ...(flagValue(flags.repo) ? { repoUrl: flags.repo } : {}), ...projectSetupFields(flags) };
|
|
1227
|
+
if (Object.keys(body).length === 0) { console.error("uai: project update needs at least one field"); process.exit(1); }
|
|
1228
|
+
const r = await api("PATCH", "/api/agent/projects/" + encodeURIComponent(pos[0]), body);
|
|
1229
|
+
const proposed = r.proposed || [];
|
|
1230
|
+
out("updated " + r.project.slug + " (applies to the next task)");
|
|
1231
|
+
for (const pr of proposed) out("proposed " + pr.field + " (proposal " + pr.id + "): it changes what later tasks clone or read, so a member must approve it in the repository's Settings.");
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
case "project propose-extra": {
|
|
1235
|
+
if (!pos[0] || !flagValue(flags["extra-file"])) { console.error("uai: project propose-extra <id|slug> --extra-file <file>"); process.exit(1); }
|
|
1236
|
+
const r = await api("POST", "/api/agent/projects/" + encodeURIComponent(pos[0]) + "/extra-proposals", { extra: readFlagFile(flags["extra-file"]) });
|
|
1237
|
+
out("proposed Dockerfile additions (proposal " + r.proposal.id + "). They run as root at image build, so a member must approve them in the repository's Settings before any task uses them.");
|
|
546
1238
|
break;
|
|
547
1239
|
}
|
|
1240
|
+
case "project env": {
|
|
1241
|
+
const sub = pos[0], project = pos[1], name = pos[2];
|
|
1242
|
+
const base = "/api/agent/projects/" + encodeURIComponent(project || "") + "/env";
|
|
1243
|
+
if (!project || (["add", "remove", "propose-proxy"].includes(sub) && !name)) {
|
|
1244
|
+
console.error("uai: project env list|observed <id> | add <id> NAME --label <l> [--secret] | remove <id> NAME | propose-proxy <id> NAME --domain <d>[,<d>…] [--placeholder-prefix <p> --placeholder-length <n>]");
|
|
1245
|
+
process.exit(1);
|
|
1246
|
+
}
|
|
1247
|
+
if (sub === "list") {
|
|
1248
|
+
const keys = (await api("GET", base + "/keys")).keys || [];
|
|
1249
|
+
out(keys.length ? keys.map((k) => k.key + (k.secret ? " (secret)" : "") + " " + k.label).join(NL) : "no env keys declared");
|
|
1250
|
+
} else if (sub === "observed") {
|
|
1251
|
+
out((await api("GET", base + "/declarations")).latest);
|
|
1252
|
+
} else if (sub === "add") {
|
|
1253
|
+
const label = flagValue(flags.label) ? flags.label : undefined;
|
|
1254
|
+
const r = await api("POST", base + "/keys", { key: name, ...(label ? { label } : {}), ...(rest.includes("--secret") ? { secret: true } : {}) });
|
|
1255
|
+
out("declared " + r.key + ". It has no value: a member saves its value in the repository's Settings.");
|
|
1256
|
+
} else if (sub === "remove") {
|
|
1257
|
+
const r = await api("DELETE", base + "/keys", { key: name });
|
|
1258
|
+
out("removed the declaration of " + r.key + ". No value was touched.");
|
|
1259
|
+
} else if (sub === "propose-proxy") {
|
|
1260
|
+
if (!flagValue(flags.domain)) { console.error("uai: project env propose-proxy <id> NAME --domain <d>[,<d>…]"); process.exit(1); }
|
|
1261
|
+
const prefix = flags["placeholder-prefix"], length = flags["placeholder-length"];
|
|
1262
|
+
if ((prefix === undefined) !== (length === undefined)) { console.error("uai: --placeholder-prefix and --placeholder-length go together"); process.exit(1); }
|
|
1263
|
+
const placeholder = prefix === undefined ? null : { prefix: prefix === "true" ? "" : prefix, totalLength: Number(length) };
|
|
1264
|
+
const domains = String(flags.domain).split(",").map((d) => d.trim()).filter(Boolean);
|
|
1265
|
+
const r = await api("POST", base + "/proxy-proposals", { key: name, domains, placeholder });
|
|
1266
|
+
out("proposed sending " + name + " only to " + r.proposal.domains.join(", ") + " (proposal " + r.proposal.id + "). A member must confirm it in the repository's Settings before the proxy uses it.");
|
|
1267
|
+
} else {
|
|
1268
|
+
console.error("uai: project env commands are list, add, remove, propose-proxy and observed");
|
|
1269
|
+
process.exit(1);
|
|
1270
|
+
}
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
case "task project": {
|
|
1274
|
+
const words = rest.filter((word) => word !== "--no-wait");
|
|
1275
|
+
const sub = words[0];
|
|
1276
|
+
if (sub === "add" && words.length === 2 && !words[1].startsWith("--")) {
|
|
1277
|
+
await addProjectToTask(words[1], !rest.includes("--no-wait"));
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
if (sub === "status" && words.length === 2) {
|
|
1281
|
+
out(opLine((await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(words[1]))).operation));
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
console.error("uai: task project add <projectId|slug> [--no-wait] | task project status <operationId>");
|
|
1285
|
+
process.exit(1);
|
|
1286
|
+
}
|
|
548
1287
|
case "task search": {
|
|
549
1288
|
const q = pos.join(" ") || flags.q || "";
|
|
550
1289
|
if (!q) { console.error("uai: task search <query>"); process.exit(1); }
|
|
@@ -574,11 +1313,21 @@ async function main() {
|
|
|
574
1313
|
default:
|
|
575
1314
|
console.error([
|
|
576
1315
|
"uai — agent CLI. Commands:",
|
|
1316
|
+
' uai share <path> [--title "text"] (share an immutable file with people; Files plan, workspace quota)',
|
|
1317
|
+
' uai secret request NAME --why "reason" [--proxy-domain host]… [--no-wait]',
|
|
1318
|
+
" Never paste secrets into chat. A person supplies them through the private dialog.",
|
|
1319
|
+
" uai artifacts",
|
|
577
1320
|
" uai projects list",
|
|
1321
|
+
" uai files ls [org|me[/prefix]] (one folder: names, sizes, mtimes, etags; defaults to org)",
|
|
1322
|
+
" uai files get <org|me>/<path> [<dest>] (download; default /workspace/files/<org|me>/<path>; -r is a folder, capped at 500 files / 1 GB)",
|
|
578
1323
|
" uai people list",
|
|
579
1324
|
" uai task list",
|
|
580
|
-
" uai task create --name <n> --prompt <
|
|
581
|
-
|
|
1325
|
+
" uai task create --name <n> --prompt <text|-> [--projects id,id] [--team id] [--agents handle,handle] [--astronaut engine/model/effort] [--start --request-id <logical-run-id>]",
|
|
1326
|
+
" uai task watch <childId> [--after <cursor>] [--limit N]",
|
|
1327
|
+
" uai task usage <childId>",
|
|
1328
|
+
' uai task send <childId> "text"',
|
|
1329
|
+
' uai task answer <childId> <cardId> "option text"',
|
|
1330
|
+
' uai task close [--child <childId>] --status finished|canceled [--reason "final report"]',
|
|
582
1331
|
" uai memory search <query> [--as handle] [--limit N|all]",
|
|
583
1332
|
" uai memory save <text> [--project id] [--tags a,b]",
|
|
584
1333
|
" uai memory delete <id>",
|
|
@@ -587,14 +1336,33 @@ async function main() {
|
|
|
587
1336
|
" uai todo edit <#id> <text>",
|
|
588
1337
|
" uai todo remove <#id>",
|
|
589
1338
|
" uai todo claim|done|reopen|unclaim <#id>",
|
|
1339
|
+
" uai review report --pr <number|URL> --head <full-sha> --must <n> --should <n> --nits <n> --verdict approve|changes [--summary text] [--assignment id] [--revision n]",
|
|
1340
|
+
" uai crew outcome @agent accepted|reworked|failed [--note text] [--assignment id] [--revision n]",
|
|
1341
|
+
" Review reports require an authorized exact-head assignment. Outcomes are Secretary assessments, never human acceptance.",
|
|
590
1342
|
" uai preview add <name> <containerPort>",
|
|
591
1343
|
" uai preview enable|disable <name> (turn it on; prints its URL)",
|
|
592
1344
|
" uai preview public|private <name> (public: a link anyone can open)",
|
|
593
|
-
" uai project
|
|
1345
|
+
" uai project list | show <id|slug>",
|
|
1346
|
+
" uai project create --name <n> --repo <git-url> [--slug <s>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] [--attach] (--attach: also add it to this task)",
|
|
1347
|
+
" uai project update <id|slug> [--name <n>] [--slug <s>] [--repo <url>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] (applies to the next task; --repo and the brief are proposals unless you created the repository in this task)",
|
|
1348
|
+
" uai project propose-extra <id|slug> --extra-file <f> (Dockerfile additions; a member approves them)",
|
|
1349
|
+
" uai project env list|observed <id|slug>",
|
|
1350
|
+
" uai project env add <id|slug> NAME [--label <l>] [--secret] (declares a key; values are a member's)",
|
|
1351
|
+
" uai project env remove <id|slug> NAME (drops a declaration with no value; never deletes a value; when the workspace allows it)",
|
|
1352
|
+
" uai project env propose-proxy <id|slug> NAME --domain <d>[,<d>…] [--placeholder-prefix <p> --placeholder-length <n>] (a member confirms)",
|
|
1353
|
+
" uai task project add <projectId|slug> [--no-wait] (clone a repository into this running task; waits until ready)",
|
|
1354
|
+
" uai task project status <operationId>",
|
|
594
1355
|
" uai task search <query> (your own past tasks)",
|
|
595
1356
|
" uai task history (list your past tasks)",
|
|
596
1357
|
" uai task read <taskId> (full chat of a past task you were on)",
|
|
597
|
-
' uai dispatch @agent [@agent…]
|
|
1358
|
+
' uai dispatch @agent [@agent…] [--kind build|fix|review|research|design|docs|ops] [--size s|m|l] [--pr URL|number] [--request-id key] <instruction|-> (Secretary only)',
|
|
1359
|
+
' Tags precede the instruction; --pr is a candidate link, not proof of contribution. Reuse --request-id to retry the same logical dispatch.',
|
|
1360
|
+
" Explicit - reads UTF-8 stdin, trimmed; terminal stdin and omitted text are refused.",
|
|
1361
|
+
" For multi-line text or shell syntax use a quoted heredoc (executes nothing):",
|
|
1362
|
+
" uai dispatch @agent - <<'EOF'",
|
|
1363
|
+
" Your literal instruction, including quotes and shell syntax.",
|
|
1364
|
+
" EOF",
|
|
1365
|
+
" uai task create --name <n> --prompt - reads stdin the same way.",
|
|
598
1366
|
" uai crew list (Astronaut only: personas with summary, skills and permissions, engines, models, limits, roster)",
|
|
599
1367
|
" uai crew status (Astronaut only: each agent's engine, model, account; account usage; credits)",
|
|
600
1368
|
" uai crew store (Astronaut only: the Store's persona and skill templates, names and types only)",
|