@realqiyan/agent-task 0.0.0-stage → 0.0.1-rc.2
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/LICENSE +21 -0
- package/README.md +47 -2
- package/bin/agent-task.mjs +61 -0
- package/config.example.json +7 -0
- package/deploy/agent-task.service.example +17 -0
- package/dist/acp.js +169 -0
- package/dist/admin.js +31 -0
- package/dist/config.js +72 -0
- package/dist/coordinator-provider.js +28 -0
- package/dist/coordinator-storage.js +43 -0
- package/dist/coordinator.js +117 -0
- package/dist/environment.js +36 -0
- package/dist/executor-runtime.js +37 -0
- package/dist/http-auth.js +9 -0
- package/dist/human-input.js +62 -0
- package/dist/launcher.js +238 -0
- package/dist/lock.js +43 -0
- package/dist/main.js +175 -0
- package/dist/mcp.js +40 -0
- package/dist/model.js +25 -0
- package/dist/node-execution.js +102 -0
- package/dist/permissions.js +9 -0
- package/dist/private-files.js +95 -0
- package/dist/results.js +79 -0
- package/dist/settings-contract.js +16 -0
- package/dist/settings.js +180 -0
- package/dist/startup.js +56 -0
- package/dist/task-contract.js +37 -0
- package/dist/task-model.js +2 -0
- package/dist/task-storage.js +123 -0
- package/dist/tasks.js +508 -0
- package/dist/version.js +3 -0
- package/dist/web/api.js +26 -0
- package/dist/web/app.js +72 -0
- package/dist/web/dom.js +65 -0
- package/dist/web/index.html +33 -0
- package/dist/web/settings-view.js +126 -0
- package/dist/web/style.css +24 -0
- package/dist/web/tasks-view.js +83 -0
- package/dist/web.js +117 -0
- package/dist/worker.js +89 -0
- package/dist/workspace.js +16 -0
- package/docs/architecture-guidelines.md +52 -0
- package/docs/deployment.md +115 -0
- package/docs/design-proposal.md +55 -0
- package/docs/release.md +57 -0
- package/docs/technical-design.md +37 -0
- package/docs/validation.md +108 -0
- package/docs/web-console.md +40 -0
- package/package.json +68 -4
- package/scripts/install-lib.mjs +184 -0
- package/scripts/mcp-call.mjs +17 -0
- package/scripts/probe-agent.mjs +39 -0
- package/scripts/service-runner.mjs +9 -0
- package/scripts/setup-wizard.mjs +29 -0
- package/scripts/setup.mjs +130 -0
- package/scripts/uninstall.mjs +57 -0
package/dist/model.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const timestamp = z.string().datetime({ offset: true }).transform(value => new Date(value).toISOString());
|
|
3
|
+
export const identifier = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/);
|
|
4
|
+
export const scheduleSchema = z.discriminatedUnion("type", [
|
|
5
|
+
z.object({ type: z.literal("now") }).strict(),
|
|
6
|
+
z.object({ type: z.literal("once"), at: timestamp }).strict(),
|
|
7
|
+
z.object({ type: z.literal("interval"), startAt: timestamp,
|
|
8
|
+
seconds: z.number().int().min(60).max(31_536_000) }).strict(),
|
|
9
|
+
]);
|
|
10
|
+
export const executorSchema = z.object({
|
|
11
|
+
id: identifier,
|
|
12
|
+
type: z.enum(["pi", "codex", "claude", "opencode"]),
|
|
13
|
+
note: z.string().max(2000).default(""),
|
|
14
|
+
cwd: z.string().min(1),
|
|
15
|
+
unattendedApproved: z.boolean().default(false),
|
|
16
|
+
}).strict();
|
|
17
|
+
export const OUTPUT_LIMIT = 262_144;
|
|
18
|
+
export const EVENT_LIMIT = 2000;
|
|
19
|
+
export const GRACE_MS = 30_000;
|
|
20
|
+
export function iso(now = Date.now()) { return new Date(now).toISOString(); }
|
|
21
|
+
export function errorText(error) { return (error instanceof Error ? error.message : String(error)).slice(0, 2000); }
|
|
22
|
+
export function initialTime(schedule, now) {
|
|
23
|
+
return schedule.type === "now" ? now : Date.parse(schedule.type === "once" ? schedule.at : schedule.startAt);
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
/** One supervised worker and ACP session per node, retained across turns. */
|
|
4
|
+
export const createExecution = (environment) => (input, event, beforeStart) => {
|
|
5
|
+
const worker = fork(fileURLToPath(new URL("./worker.js", import.meta.url)), [], {
|
|
6
|
+
env: { ...environment }, stdio: ["ignore", "ignore", "pipe", "ipc"], execArgv: [],
|
|
7
|
+
});
|
|
8
|
+
worker.stderr?.resume();
|
|
9
|
+
let stopped = false, exited = false, busy = false;
|
|
10
|
+
let failure;
|
|
11
|
+
let kill, hardKill;
|
|
12
|
+
let turn;
|
|
13
|
+
let readyResolve, readyReject;
|
|
14
|
+
const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
|
15
|
+
void ready.catch(() => { });
|
|
16
|
+
let done;
|
|
17
|
+
const closed = new Promise(resolve => { done = resolve; });
|
|
18
|
+
const close = () => {
|
|
19
|
+
if (!stopped && !exited) {
|
|
20
|
+
stopped = true;
|
|
21
|
+
readyReject(new Error("Execution stopped"));
|
|
22
|
+
turn?.reject(new Error("Execution stopped"));
|
|
23
|
+
turn = undefined;
|
|
24
|
+
if (worker.connected)
|
|
25
|
+
worker.send({ type: "close" }, () => { });
|
|
26
|
+
kill = setTimeout(() => worker.kill("SIGTERM"), 3000);
|
|
27
|
+
hardKill = setTimeout(() => worker.kill("SIGKILL"), 6000);
|
|
28
|
+
}
|
|
29
|
+
return closed;
|
|
30
|
+
};
|
|
31
|
+
const fail = (error) => { failure = error; readyReject(error); turn?.reject(error); turn = undefined; void close(); };
|
|
32
|
+
worker.on("error", fail);
|
|
33
|
+
worker.on("message", (message) => {
|
|
34
|
+
try {
|
|
35
|
+
if (message.type === "ready") {
|
|
36
|
+
if (stopped) {
|
|
37
|
+
if (worker.connected)
|
|
38
|
+
worker.disconnect();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
beforeStart();
|
|
42
|
+
worker.send({ type: "open", input }, error => { if (error)
|
|
43
|
+
fail(error); });
|
|
44
|
+
}
|
|
45
|
+
else if (message.type === "opened")
|
|
46
|
+
readyResolve();
|
|
47
|
+
else if (message.type === "event" && message.kind && message.seq !== undefined) {
|
|
48
|
+
event(message.kind, message.data ?? null, message.text ?? "");
|
|
49
|
+
if (worker.connected)
|
|
50
|
+
worker.send({ type: "ack", seq: message.seq }, () => { });
|
|
51
|
+
}
|
|
52
|
+
else if (message.type === "turn") {
|
|
53
|
+
turn?.resolve({ stopReason: message.stopReason ?? "unknown" });
|
|
54
|
+
turn = undefined;
|
|
55
|
+
}
|
|
56
|
+
else if (message.type === "failed")
|
|
57
|
+
fail(new Error(message.error ?? "Worker failed"));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
fail(error);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const settle = () => {
|
|
64
|
+
if (exited)
|
|
65
|
+
return;
|
|
66
|
+
exited = true;
|
|
67
|
+
worker.stderr?.destroy();
|
|
68
|
+
if (kill)
|
|
69
|
+
clearTimeout(kill);
|
|
70
|
+
if (hardKill)
|
|
71
|
+
clearTimeout(hardKill);
|
|
72
|
+
const error = failure ?? new Error("Execution stopped or disconnected; external effects may be unknown");
|
|
73
|
+
readyReject(error);
|
|
74
|
+
turn?.reject(error);
|
|
75
|
+
turn = undefined;
|
|
76
|
+
done();
|
|
77
|
+
};
|
|
78
|
+
worker.once("exit", settle);
|
|
79
|
+
worker.once("close", settle);
|
|
80
|
+
return {
|
|
81
|
+
closed, close,
|
|
82
|
+
async prompt(text) {
|
|
83
|
+
if (stopped || exited || busy)
|
|
84
|
+
throw new Error("Execution closed or busy");
|
|
85
|
+
busy = true;
|
|
86
|
+
try {
|
|
87
|
+
await ready;
|
|
88
|
+
if (stopped || exited)
|
|
89
|
+
throw new Error("Execution stopped");
|
|
90
|
+
return await new Promise((resolve, reject) => {
|
|
91
|
+
turn = { resolve, reject };
|
|
92
|
+
worker.send({ type: "prompt", text }, error => { if (error)
|
|
93
|
+
fail(error); });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
busy = false;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
//# sourceMappingURL=node-execution.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Applies only to ACP requests that actually ask for approval; this is not a sandbox. */
|
|
2
|
+
export function permissionDecision(request, cancelled = false) {
|
|
3
|
+
if (cancelled)
|
|
4
|
+
return { outcome: { outcome: "cancelled" } };
|
|
5
|
+
// Native authorization must suffice. A new ACP approval would widen it.
|
|
6
|
+
const option = request.options.find(value => value.kind === "reject_once");
|
|
7
|
+
return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=permissions.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, constants, openSync, closeSync, fstatSync, lstatSync, realpathSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, basename, join, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export function checkDataDirectory(path) {
|
|
5
|
+
if (!isAbsolute(path) || resolve(path) !== path || /[\x00-\x1f\x7f]/.test(path))
|
|
6
|
+
throw new Error("Expected a canonical absolute dataDir");
|
|
7
|
+
let parent = path;
|
|
8
|
+
while (!existsSync(parent))
|
|
9
|
+
parent = dirname(parent);
|
|
10
|
+
if (realpathSync(parent) !== parent || !lstatSync(parent).isDirectory())
|
|
11
|
+
throw new Error("dataDir must not traverse symlinks");
|
|
12
|
+
if (parent === path) {
|
|
13
|
+
const stat = lstatSync(path);
|
|
14
|
+
if (stat.uid !== process.getuid() || (stat.mode & 0o077))
|
|
15
|
+
throw new Error("dataDir must be owned and private");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function privateDirectory(path) {
|
|
19
|
+
const directory = dirname(path), stat = lstatSync(directory);
|
|
20
|
+
if (realpathSync(directory) !== directory || !stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077)) {
|
|
21
|
+
throw new Error("Expected a canonical, private, owned configuration directory");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function ownedFile(path) {
|
|
25
|
+
const stat = lstatSync(path);
|
|
26
|
+
if (!stat.isFile() || stat.uid !== process.getuid())
|
|
27
|
+
throw new Error("Expected an owned regular configuration file");
|
|
28
|
+
}
|
|
29
|
+
export function readPrivateText(path) {
|
|
30
|
+
privateDirectory(path);
|
|
31
|
+
ownedFile(path);
|
|
32
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
33
|
+
try {
|
|
34
|
+
const stat = fstatSync(fd);
|
|
35
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.size > 1_048_576) {
|
|
36
|
+
throw new Error("Expected a private, owned configuration file of at most 1 MiB");
|
|
37
|
+
}
|
|
38
|
+
const text = readFileSync(fd, "utf8");
|
|
39
|
+
if (Buffer.byteLength(text) > 1_048_576)
|
|
40
|
+
throw new Error("Configuration file grew beyond limit");
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
closeSync(fd);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class FileCommitError extends Error {
|
|
48
|
+
uncertain;
|
|
49
|
+
constructor(uncertain) {
|
|
50
|
+
super(uncertain ? "Configuration publication could not be confirmed; stop and inspect the files" : "Configuration was not published");
|
|
51
|
+
this.uncertain = uncertain;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Caller owns the configuration. No await between comparison, validation and publication. */
|
|
55
|
+
export function replacePrivateText(path, text) {
|
|
56
|
+
const temporary = join(dirname(path), "." + basename(path) + "." + randomUUID() + ".tmp");
|
|
57
|
+
let fd, created = false, published = false;
|
|
58
|
+
try {
|
|
59
|
+
privateDirectory(path);
|
|
60
|
+
ownedFile(path);
|
|
61
|
+
if (Buffer.byteLength(text) > 1_048_576)
|
|
62
|
+
throw new Error("Configuration exceeds file limit");
|
|
63
|
+
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
64
|
+
created = true;
|
|
65
|
+
writeFileSync(fd, text, "utf8");
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
fd = undefined;
|
|
69
|
+
privateDirectory(path);
|
|
70
|
+
ownedFile(path);
|
|
71
|
+
renameSync(temporary, path);
|
|
72
|
+
published = true;
|
|
73
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
74
|
+
try {
|
|
75
|
+
fsyncSync(directory);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
closeSync(directory);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new FileCommitError(published);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (fd !== undefined)
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
if (created && !published) {
|
|
88
|
+
try {
|
|
89
|
+
unlinkSync(temporary);
|
|
90
|
+
}
|
|
91
|
+
catch { /* Never remove the committed file. */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=private-files.js.map
|
package/dist/results.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, fstatSync, fsyncSync, realpathSync, statSync, accessSync, constants } from "node:fs";
|
|
2
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { OUTPUT_LIMIT } from "./model.js";
|
|
4
|
+
/** Exact upstream files within dataDir/results, never a general dataDir read capability. */
|
|
5
|
+
export function readInputFile(root, allowed, path, line = 1, limit) {
|
|
6
|
+
if (!allowed.includes(path) || realpathSync(path) !== path)
|
|
7
|
+
throw new Error("Input file is not authorized for this run");
|
|
8
|
+
const rel = relative(root, path);
|
|
9
|
+
if (realpathSync(root) !== root || !rel || rel.startsWith("..") || isAbsolute(rel))
|
|
10
|
+
throw new Error("Input file outside result scope");
|
|
11
|
+
if (!Number.isInteger(line) || line < 1 || (limit !== undefined && (!Number.isInteger(limit) || limit < 1)))
|
|
12
|
+
throw new Error("Invalid line range");
|
|
13
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
try {
|
|
15
|
+
const stat = fstatSync(fd);
|
|
16
|
+
if (!stat.isFile() || stat.size > OUTPUT_LIMIT)
|
|
17
|
+
throw new Error("Invalid or oversized input file");
|
|
18
|
+
const content = readFileSync(fd, "utf8");
|
|
19
|
+
if (Buffer.byteLength(content) > OUTPUT_LIMIT)
|
|
20
|
+
throw new Error("Input file grew beyond limit");
|
|
21
|
+
if (line === 1 && limit === undefined)
|
|
22
|
+
return content;
|
|
23
|
+
return content.split("\n").slice(line - 1, limit === undefined ? undefined : line - 1 + limit).join("\n");
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class Results {
|
|
30
|
+
root;
|
|
31
|
+
constructor(directory) {
|
|
32
|
+
mkdirSync(join(directory, "results"), { recursive: true, mode: 0o700 });
|
|
33
|
+
this.root = join(directory, "results");
|
|
34
|
+
if (realpathSync(this.root) !== this.root)
|
|
35
|
+
throw new Error("Result root must not be a symlink");
|
|
36
|
+
}
|
|
37
|
+
inside(root, path) {
|
|
38
|
+
const rel = relative(root, path);
|
|
39
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
40
|
+
}
|
|
41
|
+
readable(path) {
|
|
42
|
+
if (realpathSync(path) !== path || !this.inside(this.root, path) || !statSync(path).isFile())
|
|
43
|
+
throw new Error("Invalid result file");
|
|
44
|
+
accessSync(path, constants.R_OK);
|
|
45
|
+
// Actually open it. Native agent sandbox access remains an administrator-reviewed prerequisite.
|
|
46
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
save(taskRunId, nodeId, runId, text) {
|
|
50
|
+
for (const id of [taskRunId, nodeId, runId]) {
|
|
51
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/.test(id))
|
|
52
|
+
throw new Error("Invalid result identity");
|
|
53
|
+
}
|
|
54
|
+
const directory = join(this.root, taskRunId);
|
|
55
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
56
|
+
if (realpathSync(directory) !== directory)
|
|
57
|
+
throw new Error("Result directory must not be a symlink");
|
|
58
|
+
const path = join(directory, nodeId + "." + runId + ".txt");
|
|
59
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(fd, text, "utf8");
|
|
62
|
+
fsyncSync(fd);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
for (const dir of [directory, this.root]) {
|
|
68
|
+
const handle = openSync(dir, constants.O_RDONLY);
|
|
69
|
+
try {
|
|
70
|
+
fsyncSync(handle);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
closeSync(handle);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=results.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { configSchema } from "./config.js";
|
|
3
|
+
export const settingsConfigSchema = z.object({
|
|
4
|
+
editToken: z.string().uuid(),
|
|
5
|
+
config: configSchema,
|
|
6
|
+
}).strict();
|
|
7
|
+
const credential = {
|
|
8
|
+
editToken: z.string().uuid(),
|
|
9
|
+
key: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(200),
|
|
10
|
+
};
|
|
11
|
+
export const credentialUpdateSchema = z.discriminatedUnion("action", [
|
|
12
|
+
z.object({ ...credential, action: z.literal("replace"),
|
|
13
|
+
value: z.string().min(1).max(16_384).refine(value => Boolean(value.trim()) && !value.includes("\0")) }).strict(),
|
|
14
|
+
z.object({ ...credential, action: z.literal("delete") }).strict(),
|
|
15
|
+
]);
|
|
16
|
+
//# sourceMappingURL=settings-contract.js.map
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ADMIN_TOKEN_ENV, configSchema, coordinatorCredentialEnv, validateConfig } from "./config.js";
|
|
4
|
+
import { parseEnvironment, validateServiceEnvironment } from "./environment.js";
|
|
5
|
+
import { checkDataDirectory, FileCommitError, readPrivateText, replacePrivateText } from "./private-files.js";
|
|
6
|
+
import { credentialUpdateSchema, settingsConfigSchema } from "./settings-contract.js";
|
|
7
|
+
export class SettingsError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const protectedKeys = new Set(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "NODE_OPTIONS", "NODE_PATH",
|
|
15
|
+
"PI_ACP_PI_COMMAND", "AGENT_TASK_TOKEN", ADMIN_TOKEN_ENV, "__proto__", "constructor", "prototype"]);
|
|
16
|
+
const modelKeys = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY"];
|
|
17
|
+
function parse(schema, value) {
|
|
18
|
+
const result = schema.safeParse(value);
|
|
19
|
+
if (!result.success) {
|
|
20
|
+
// Field paths only: parser diagnostics and rejected values may contain credentials.
|
|
21
|
+
const fields = result.error.issues.slice(0, 8).map(issue => issue.path.join(".") || "request");
|
|
22
|
+
throw new SettingsError("invalid", "Invalid fields: " + fields.join(", "));
|
|
23
|
+
}
|
|
24
|
+
return result.data;
|
|
25
|
+
}
|
|
26
|
+
function referenced(config) {
|
|
27
|
+
return new Set(config.coordinator ? [coordinatorCredentialEnv(config.coordinator)] : []);
|
|
28
|
+
}
|
|
29
|
+
function editableKey(key, config) {
|
|
30
|
+
return !protectedKeys.has(key) && key !== config.tokenEnv && !/^(NODE_|LD_|DYLD_)/.test(key) &&
|
|
31
|
+
(/(?:_KEY|_TOKEN|_SECRET|_PASSWORD)$/.test(key) || referenced(config).has(key));
|
|
32
|
+
}
|
|
33
|
+
/** Disk settings are next-start inputs only. This service never mutates live objects or process.env. */
|
|
34
|
+
export class Settings {
|
|
35
|
+
configPath;
|
|
36
|
+
environmentPath;
|
|
37
|
+
edits = new Map();
|
|
38
|
+
initial;
|
|
39
|
+
running;
|
|
40
|
+
runningEnvironment;
|
|
41
|
+
base;
|
|
42
|
+
closed = false;
|
|
43
|
+
uncertain = false;
|
|
44
|
+
startedAt = new Date().toISOString();
|
|
45
|
+
constructor(configPath, environmentPath, running, base, environment) {
|
|
46
|
+
this.configPath = configPath;
|
|
47
|
+
this.environmentPath = environmentPath;
|
|
48
|
+
if (configPath === environmentPath)
|
|
49
|
+
throw new SettingsError("invalid", "Configuration and environment must be separate files");
|
|
50
|
+
this.running = structuredClone(running);
|
|
51
|
+
this.base = { ...base };
|
|
52
|
+
this.runningEnvironment = { ...environment };
|
|
53
|
+
this.initial = this.snapshot();
|
|
54
|
+
}
|
|
55
|
+
snapshot() {
|
|
56
|
+
if (this.closed)
|
|
57
|
+
throw new SettingsError("unavailable", "Configuration management is closed");
|
|
58
|
+
try {
|
|
59
|
+
const configText = readPrivateText(this.configPath), environmentText = readPrivateText(this.environmentPath);
|
|
60
|
+
return { configText, environmentText, config: configSchema.parse(JSON.parse(configText)), environment: parseEnvironment(environmentText) };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new SettingsError("unavailable", "Cannot read private configuration files; inspect their format, ownership and permissions offline");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
pending(snapshot) {
|
|
67
|
+
return snapshot.configText !== this.initial.configText || snapshot.environmentText !== this.initial.environmentText;
|
|
68
|
+
}
|
|
69
|
+
status() {
|
|
70
|
+
return { pendingRestart: this.pending(this.snapshot()), writeBlocked: this.uncertain, startedAt: this.startedAt };
|
|
71
|
+
}
|
|
72
|
+
get() {
|
|
73
|
+
const snapshot = this.snapshot(), now = Date.now();
|
|
74
|
+
for (const [key, edit] of this.edits)
|
|
75
|
+
if (edit.expires <= now)
|
|
76
|
+
this.edits.delete(key);
|
|
77
|
+
while (this.edits.size >= 32)
|
|
78
|
+
this.edits.delete(this.edits.keys().next().value);
|
|
79
|
+
const editToken = randomUUID();
|
|
80
|
+
this.edits.set(editToken, { configText: snapshot.configText, environmentText: snapshot.environmentText, expires: now + 15 * 60_000 });
|
|
81
|
+
const keys = new Set([...modelKeys, ...Object.keys(snapshot.environment), ...referenced(snapshot.config), ...referenced(this.running)]);
|
|
82
|
+
const credentials = [...keys].filter(key => editableKey(key, snapshot.config) && editableKey(key, this.running) && ![snapshot.environment[snapshot.config.tokenEnv], snapshot.environment[ADMIN_TOKEN_ENV]].includes(snapshot.environment[key])).sort().map(key => ({
|
|
83
|
+
key, saved: Boolean(snapshot.environment[key]), running: Boolean(this.runningEnvironment[key]),
|
|
84
|
+
pendingRestart: snapshot.environment[key] !== this.runningEnvironment[key],
|
|
85
|
+
referenced: referenced(snapshot.config).has(key),
|
|
86
|
+
}));
|
|
87
|
+
return { editToken, running: structuredClone(this.running), saved: snapshot.config, credentials,
|
|
88
|
+
pendingRestart: this.pending(snapshot), writeBlocked: this.uncertain };
|
|
89
|
+
}
|
|
90
|
+
editing(token) {
|
|
91
|
+
if (this.uncertain)
|
|
92
|
+
throw new SettingsError("unavailable", "Publication was uncertain; stop the service and inspect configuration files before further saves");
|
|
93
|
+
const snapshot = this.snapshot(), edit = this.edits.get(token);
|
|
94
|
+
if (!edit || edit.expires <= Date.now() || edit.configText !== snapshot.configText || edit.environmentText !== snapshot.environmentText) {
|
|
95
|
+
throw new SettingsError("conflict", "Configuration changed or the edit expired; reload before saving");
|
|
96
|
+
}
|
|
97
|
+
return snapshot;
|
|
98
|
+
}
|
|
99
|
+
candidate(value) {
|
|
100
|
+
const input = parse(settingsConfigSchema, value), snapshot = this.editing(input.editToken);
|
|
101
|
+
const environment = { ...this.base, ...snapshot.environment };
|
|
102
|
+
let config;
|
|
103
|
+
try {
|
|
104
|
+
config = validateConfig(input.config);
|
|
105
|
+
checkDataDirectory(config.dataDir);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof z.ZodError)
|
|
109
|
+
throw new SettingsError("invalid", "Invalid configuration fields");
|
|
110
|
+
throw new SettingsError("invalid", "Local validation failed: check data directory, workspaces and credential isolation");
|
|
111
|
+
}
|
|
112
|
+
const previous = snapshot.config.coordinator, next = config.coordinator;
|
|
113
|
+
const endpoint = (model) => model.provider === "openai-compatible" ? model.baseUrl : model.provider;
|
|
114
|
+
if (previous && next && endpoint(previous) !== endpoint(next) && coordinatorCredentialEnv(previous) === coordinatorCredentialEnv(next)) {
|
|
115
|
+
throw new SettingsError("invalid", "Changing the model endpoint requires a new credential reference; save the new key first");
|
|
116
|
+
}
|
|
117
|
+
for (const key of referenced(config)) {
|
|
118
|
+
if (!environment[key])
|
|
119
|
+
throw new SettingsError("invalid", "A referenced environment value is missing; prepare credentials or native configuration first");
|
|
120
|
+
}
|
|
121
|
+
const token = environment[config.tokenEnv] ?? environment[snapshot.config.tokenEnv];
|
|
122
|
+
try {
|
|
123
|
+
validateServiceEnvironment(config, { ...environment, [config.tokenEnv]: token });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new SettingsError("invalid", "Service, administrator and model credentials must be valid and distinct");
|
|
127
|
+
}
|
|
128
|
+
if (config.tokenEnv !== snapshot.config.tokenEnv && environment[config.tokenEnv] && environment[config.tokenEnv] !== environment[snapshot.config.tokenEnv]) {
|
|
129
|
+
throw new SettingsError("invalid", "tokenEnv already contains another value; use a new environment name");
|
|
130
|
+
}
|
|
131
|
+
return config;
|
|
132
|
+
}
|
|
133
|
+
validate(value) { return this.candidate(value); }
|
|
134
|
+
saveConfig(value) {
|
|
135
|
+
const config = this.candidate(value);
|
|
136
|
+
const snapshot = this.editing(parse(settingsConfigSchema, value).editToken);
|
|
137
|
+
if (config.tokenEnv !== snapshot.config.tokenEnv) {
|
|
138
|
+
snapshot.environment[config.tokenEnv] = snapshot.environment[snapshot.config.tokenEnv];
|
|
139
|
+
// Publish an alias first. Either configuration still uses the same service token.
|
|
140
|
+
this.publish(this.environmentPath, snapshot.environment);
|
|
141
|
+
}
|
|
142
|
+
this.publish(this.configPath, config);
|
|
143
|
+
return this.get();
|
|
144
|
+
}
|
|
145
|
+
saveCredential(value) {
|
|
146
|
+
const input = parse(credentialUpdateSchema, value), snapshot = this.editing(input.editToken);
|
|
147
|
+
if (!editableKey(input.key, snapshot.config) || !editableKey(input.key, this.running) ||
|
|
148
|
+
[snapshot.environment[snapshot.config.tokenEnv], snapshot.environment[ADMIN_TOKEN_ENV]].includes(snapshot.environment[input.key]))
|
|
149
|
+
throw new SettingsError("invalid", "This environment key is not console-managed; new credential names must end in _KEY, _TOKEN, _SECRET or _PASSWORD");
|
|
150
|
+
if (input.action === "delete") {
|
|
151
|
+
if (referenced(snapshot.config).has(input.key))
|
|
152
|
+
throw new SettingsError("invalid", "The saved configuration still references this credential");
|
|
153
|
+
delete snapshot.environment[input.key];
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
if ([snapshot.environment[snapshot.config.tokenEnv], snapshot.environment[ADMIN_TOKEN_ENV],
|
|
157
|
+
this.runningEnvironment[this.running.tokenEnv], this.runningEnvironment[ADMIN_TOKEN_ENV]].includes(input.value)) {
|
|
158
|
+
throw new SettingsError("invalid", "Service and administrator tokens cannot be reused as credentials");
|
|
159
|
+
}
|
|
160
|
+
snapshot.environment[input.key] = input.value;
|
|
161
|
+
}
|
|
162
|
+
// Recheck the complete environment format, not only the edited value.
|
|
163
|
+
parseEnvironment(JSON.stringify(snapshot.environment));
|
|
164
|
+
this.publish(this.environmentPath, snapshot.environment);
|
|
165
|
+
return this.get();
|
|
166
|
+
}
|
|
167
|
+
publish(path, value) {
|
|
168
|
+
try {
|
|
169
|
+
replacePrivateText(path, JSON.stringify(value, null, 2) + "\n");
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error instanceof FileCommitError && error.uncertain)
|
|
173
|
+
this.uncertain = true;
|
|
174
|
+
throw new SettingsError("unavailable", this.uncertain ? "Publication could not be confirmed; stop and inspect the files" : "Save failed; no replacement was published");
|
|
175
|
+
}
|
|
176
|
+
this.edits.clear();
|
|
177
|
+
}
|
|
178
|
+
close() { this.closed = true; this.edits.clear(); }
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=settings.js.map
|
package/dist/startup.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, openSync, fsyncSync, closeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { readEnvironment, validateServiceEnvironment } from "./environment.js";
|
|
6
|
+
import { checkDataDirectory, readPrivateText, replacePrivateText } from "./private-files.js";
|
|
7
|
+
import { acquireLock } from "./lock.js";
|
|
8
|
+
/** A stable launch locator is the only runtime input outside the selected dataDir. */
|
|
9
|
+
export function startupPaths(home) {
|
|
10
|
+
const { dataDir } = z.object({ dataDir: z.string() }).strict().parse(JSON.parse(readPrivateText(join(home, "launch.json"))));
|
|
11
|
+
checkDataDirectory(dataDir);
|
|
12
|
+
return { dataDir, configPath: join(dataDir, "config.json"), environmentPath: join(dataDir, "environment.json") };
|
|
13
|
+
}
|
|
14
|
+
function prepareFile(path, text) {
|
|
15
|
+
if (existsSync(path)) {
|
|
16
|
+
if (readPrivateText(path) !== text)
|
|
17
|
+
throw new Error("Target contains different startup files; refusing to overwrite them");
|
|
18
|
+
return; // Explicitly resume an interrupted preparation, not a data repair.
|
|
19
|
+
}
|
|
20
|
+
const fd = openSync(path, "wx", 0o600);
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(fd, text);
|
|
23
|
+
fsyncSync(fd);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function prepareStartup(home) {
|
|
30
|
+
const current = startupPaths(home), config = loadConfig(current.configPath), environment = readEnvironment(current.environmentPath);
|
|
31
|
+
validateServiceEnvironment(config, environment);
|
|
32
|
+
checkDataDirectory(config.dataDir);
|
|
33
|
+
if (config.dataDir !== current.dataDir) {
|
|
34
|
+
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
35
|
+
checkDataDirectory(config.dataDir);
|
|
36
|
+
const release = await acquireLock(config.dataDir);
|
|
37
|
+
try {
|
|
38
|
+
// Only next-start inputs. Never copy tasks, sessions, results, native configuration or workspaces.
|
|
39
|
+
prepareFile(join(config.dataDir, "config.json"), JSON.stringify(config, null, 2) + "\n");
|
|
40
|
+
prepareFile(join(config.dataDir, "environment.json"), JSON.stringify(environment, null, 2) + "\n");
|
|
41
|
+
const fd = openSync(config.dataDir, "r");
|
|
42
|
+
try {
|
|
43
|
+
fsyncSync(fd);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
replacePrivateText(join(home, "launch.json"), JSON.stringify({ dataDir: config.dataDir }) + "\n");
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await release();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { ...startupPaths(home), config, environment };
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=startup.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { identifier, scheduleSchema, timestamp } from "./model.js";
|
|
4
|
+
export const nodeSchema = z.object({
|
|
5
|
+
id: identifier, prompt: z.string().trim().min(1).max(64_000),
|
|
6
|
+
executorId: identifier.describe("Administrator-registered ACP executor assigned to this node; not a command or URL."),
|
|
7
|
+
dependsOn: z.array(identifier).max(20).default([]),
|
|
8
|
+
maxRunSeconds: z.number().int().min(1).max(3600).default(600),
|
|
9
|
+
}).strict();
|
|
10
|
+
export const questionSchema = z.object({
|
|
11
|
+
nodeId: identifier.optional(), question: z.string().trim().min(1).max(4000),
|
|
12
|
+
completedWork: z.string().trim().min(1).max(4000),
|
|
13
|
+
}).strict();
|
|
14
|
+
export const replySchema = z.object({
|
|
15
|
+
runId: z.string().uuid(), questionId: z.string().uuid(), requestKey: identifier,
|
|
16
|
+
answer: z.string().trim().min(1).max(16000),
|
|
17
|
+
}).strict();
|
|
18
|
+
export const taskIdSchema = z.object({ id: z.string().uuid() }).strict();
|
|
19
|
+
export const taskListSchema = z.object({
|
|
20
|
+
after: z.number().int().nonnegative().default(0), limit: z.number().int().min(1).max(100).default(20),
|
|
21
|
+
}).strict();
|
|
22
|
+
export const taskRunListSchema = taskListSchema.extend({ taskId: z.string().uuid().optional() });
|
|
23
|
+
export const taskUpdateSchema = z.object({
|
|
24
|
+
id: z.string().uuid(), action: z.enum(["pause", "resume", "cancel"]),
|
|
25
|
+
}).strict();
|
|
26
|
+
export const taskSchema = z.object({
|
|
27
|
+
requestKey: identifier, name: z.string().trim().min(1).max(200),
|
|
28
|
+
prompt: z.string().trim().min(1).max(64_000).describe("Overall goal and coordination constraints; does not generate or replace the DAG."),
|
|
29
|
+
nodes: z.array(nodeSchema).min(1).max(20).describe("Caller-defined fixed DAG. Each node specifies its prompt, executorId and direct dependencies."),
|
|
30
|
+
workspace: z.string().min(1).max(4096).refine(path => isAbsolute(path) && !path.includes("\0"), "Workspace must be an absolute path without NUL").optional(),
|
|
31
|
+
schedule: scheduleSchema, expiresAt: timestamp.optional(),
|
|
32
|
+
maxRunSeconds: z.number().int().min(1).max(86_400).default(1800),
|
|
33
|
+
maxSteps: z.number().int().min(1).max(200).default(40),
|
|
34
|
+
maxNodeTurns: z.number().int().min(1).max(10).default(3),
|
|
35
|
+
inputWaitSeconds: z.number().int().min(1).max(604800).default(86400),
|
|
36
|
+
}).strict();
|
|
37
|
+
//# sourceMappingURL=task-contract.js.map
|