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