@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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ADMIN_TOKEN_ENV, coordinatorCredentialEnv } from "./config.js";
|
|
2
|
+
import { readPrivateText } from "./private-files.js";
|
|
3
|
+
export function parseEnvironment(text) {
|
|
4
|
+
let value;
|
|
5
|
+
try {
|
|
6
|
+
value = JSON.parse(text);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error("Invalid environment file JSON");
|
|
10
|
+
}
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.entries(value).some(([key, item]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || ["__proto__", "constructor", "prototype"].includes(key) ||
|
|
12
|
+
typeof item !== "string" || item.includes("\0")))
|
|
13
|
+
throw new Error("Invalid environment file");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export function readEnvironment(path) {
|
|
17
|
+
return parseEnvironment(readPrivateText(path));
|
|
18
|
+
}
|
|
19
|
+
/** Credential names and values must stay separate, including historical token aliases. */
|
|
20
|
+
export function validateServiceEnvironment(config, environment) {
|
|
21
|
+
const token = environment[config.tokenEnv], adminToken = environment[ADMIN_TOKEN_ENV];
|
|
22
|
+
if (!token || !/^[!-~]{32,256}$/.test(token))
|
|
23
|
+
throw new Error("A private service token of 32–256 visible ASCII characters is required");
|
|
24
|
+
if (adminToken !== undefined && (!/^[!-~]{32,256}$/.test(adminToken) || adminToken === token))
|
|
25
|
+
throw new Error("Invalid or reused administrator token");
|
|
26
|
+
const credential = config.coordinator ? environment[coordinatorCredentialEnv(config.coordinator)] : undefined;
|
|
27
|
+
if (credential && (credential === token || credential === adminToken))
|
|
28
|
+
throw new Error("Model credentials must be distinct from service and administrator tokens");
|
|
29
|
+
return { token, adminToken };
|
|
30
|
+
}
|
|
31
|
+
/** Explicit file mode never falls back to inherited shell credentials. */
|
|
32
|
+
export function baseEnvironment(source) {
|
|
33
|
+
return Object.fromEntries(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"]
|
|
34
|
+
.filter(key => source[key] !== undefined).map(key => [key, source[key]]));
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=environment.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
3
|
+
import { ADMIN_TOKEN_ENV, coordinatorCredentialEnv } from "./config.js";
|
|
4
|
+
const commands = {
|
|
5
|
+
pi: { command: "pi-acp", args: [] },
|
|
6
|
+
codex: { command: "codex-acp", args: [] },
|
|
7
|
+
claude: { command: "claude-agent-acp", args: [] },
|
|
8
|
+
opencode: { command: "opencode", args: ["acp"] },
|
|
9
|
+
};
|
|
10
|
+
/** Fixed ACP adapters, resolved in the OS user's native PATH. Never a shell command. */
|
|
11
|
+
export function executorCommand(type, environment) {
|
|
12
|
+
const find = (name) => {
|
|
13
|
+
for (const directory of (environment.PATH ?? "").split(delimiter).filter(isAbsolute)) {
|
|
14
|
+
try {
|
|
15
|
+
const path = realpathSync(join(directory, name));
|
|
16
|
+
accessSync(path, constants.X_OK);
|
|
17
|
+
if (statSync(path).isFile())
|
|
18
|
+
return path;
|
|
19
|
+
}
|
|
20
|
+
catch { /* Try the next native PATH entry. */ }
|
|
21
|
+
}
|
|
22
|
+
throw new Error("Missing ACP dependency on native PATH: " + name);
|
|
23
|
+
};
|
|
24
|
+
const entry = commands[type];
|
|
25
|
+
if (type === "pi")
|
|
26
|
+
find("pi");
|
|
27
|
+
return { command: find(entry.command), args: [...entry.args] };
|
|
28
|
+
}
|
|
29
|
+
/** Private service inputs never enter workers, even through an alias with the same value. */
|
|
30
|
+
export function executorEnvironment(native, privateValues, config) {
|
|
31
|
+
const blocked = new Set([...Object.keys(privateValues), config.tokenEnv, ADMIN_TOKEN_ENV,
|
|
32
|
+
...(config.coordinator ? [coordinatorCredentialEnv(config.coordinator)] : [])]);
|
|
33
|
+
const secrets = new Set([...Object.values(privateValues), ...[...blocked].map(key => native[key])].filter(Boolean));
|
|
34
|
+
return Object.fromEntries(Object.entries(native).filter(([key, value]) => value !== undefined && !blocked.has(key) && !secrets.has(value) &&
|
|
35
|
+
!/^(AGENT_TASK_|NODE_|LD_|DYLD_)/.test(key) && key !== "PI_ACP_PI_COMMAND"));
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=executor-runtime.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function bearerVerifier(token) {
|
|
3
|
+
const expected = Buffer.from("Bearer " + token);
|
|
4
|
+
return header => {
|
|
5
|
+
const actual = Buffer.from(header ?? "");
|
|
6
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=http-auth.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { iso } from "./model.js";
|
|
3
|
+
/** Human facts are persisted business state, not executor permission or retry authorization. */
|
|
4
|
+
export function askInput(run, request, seconds, now) {
|
|
5
|
+
if (run.status !== "running")
|
|
6
|
+
throw new Error("Task run is not running");
|
|
7
|
+
if (run.nodes.some(node => node.status === "running"))
|
|
8
|
+
throw new Error("Wait for all ACP turns before asking for input");
|
|
9
|
+
if (request.nodeId && !run.nodes.some(node => node.definition.id === request.nodeId))
|
|
10
|
+
throw new Error("Unknown question node");
|
|
11
|
+
if (run.questions.length >= 20)
|
|
12
|
+
throw new Error("Question limit reached");
|
|
13
|
+
const sessions = run.nodes.filter(node => node.status === "awaiting_review");
|
|
14
|
+
const question = { ...request, id: randomUUID(), createdAt: iso(now), expiresAt: iso(now + seconds * 1000),
|
|
15
|
+
sessionExpiresAt: sessions.length ? iso(Math.min(now + 300_000, ...sessions.map(node => Date.parse(node.startedAt) + node.definition.maxRunSeconds * 1000))) : null, reply: null };
|
|
16
|
+
run.questions.push(question);
|
|
17
|
+
run.status = "waiting_input";
|
|
18
|
+
return question;
|
|
19
|
+
}
|
|
20
|
+
export function waitingFailure(run, now) {
|
|
21
|
+
if (run.status !== "waiting_input")
|
|
22
|
+
return;
|
|
23
|
+
const question = run.questions.at(-1);
|
|
24
|
+
if (question.sessionExpiresAt && now >= Date.parse(question.sessionExpiresAt)) {
|
|
25
|
+
return { status: "interrupted", reason: "ACP session retention expired while waiting; reconcile external effects before any new work" };
|
|
26
|
+
}
|
|
27
|
+
if (now >= Date.parse(question.expiresAt))
|
|
28
|
+
return { status: "timed_out", reason: "Human input deadline reached" };
|
|
29
|
+
}
|
|
30
|
+
/** Exact retries return their receipt, even after completion/cancellation; they never reactivate a run. */
|
|
31
|
+
export function applyReply(run, input, now) {
|
|
32
|
+
const question = run.questions.find(value => value.id === input.questionId);
|
|
33
|
+
if (!question)
|
|
34
|
+
throw new Error("Unknown question");
|
|
35
|
+
const used = run.questions.find(value => value.reply?.requestKey === input.requestKey);
|
|
36
|
+
if (used) {
|
|
37
|
+
if (used.id !== question.id || used.reply.answer !== input.answer)
|
|
38
|
+
throw new Error("Reply requestKey already used with different parameters");
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (question.reply || run.questions.at(-1)?.id !== question.id)
|
|
42
|
+
throw new Error("Question already answered or stale");
|
|
43
|
+
if (run.status !== "waiting_input")
|
|
44
|
+
throw new Error("Task run is not waiting for input: " + run.status);
|
|
45
|
+
const failure = waitingFailure(run, now);
|
|
46
|
+
if (failure)
|
|
47
|
+
throw new Error(failure.reason);
|
|
48
|
+
question.reply = { requestKey: input.requestKey, answer: input.answer, receivedAt: iso(now) };
|
|
49
|
+
run.waitedMs += Math.max(0, now - Date.parse(question.createdAt));
|
|
50
|
+
run.status = "running";
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
export function coordinatorInput(run) {
|
|
54
|
+
const question = run.questions.findLast(value => value.reply !== null);
|
|
55
|
+
if (!question)
|
|
56
|
+
return { requestId: "run:" + run.id, content: "Advance this task run using its caller-defined DAG. Start by reading inspect_run." };
|
|
57
|
+
return { requestId: "reply:" + question.id,
|
|
58
|
+
content: "Human facts for the existing task run. Read inspect_run; preserve the committed plan and accepted deliveries. This reply grants no permissions or authorization to retry uncertain effects.\n" +
|
|
59
|
+
JSON.stringify({ questionId: question.id, nodeId: question.nodeId, question: question.question,
|
|
60
|
+
completedWork: question.completedWork, answer: question.reply.answer }) };
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=human-input.js.map
|
package/dist/launcher.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { createServer, request } from "node:http";
|
|
3
|
+
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, lstatSync, openSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { acquireLauncherLock } from "./lock.js";
|
|
8
|
+
import { prepareStartup, startupPaths } from "./startup.js";
|
|
9
|
+
import { readEnvironment } from "./environment.js";
|
|
10
|
+
import { loadConfig } from "./config.js";
|
|
11
|
+
function message(child, type, timeout = 30_000) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const cleanup = () => { clearTimeout(timer); child.off("message", receive); child.off("exit", exit); child.off("error", fail); };
|
|
14
|
+
const fail = () => { cleanup(); reject(new Error("Service process failed before " + type)); };
|
|
15
|
+
const exit = () => fail();
|
|
16
|
+
const receive = (value) => { if (value.type === type) {
|
|
17
|
+
cleanup();
|
|
18
|
+
resolve(value);
|
|
19
|
+
} };
|
|
20
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error("Service timed out before " + type)); }, timeout);
|
|
21
|
+
child.on("message", receive);
|
|
22
|
+
child.once("exit", exit);
|
|
23
|
+
child.once("error", fail);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function stop(child) {
|
|
27
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
28
|
+
return;
|
|
29
|
+
const closed = new Promise(resolve => child.once("close", () => resolve()));
|
|
30
|
+
child.kill("SIGTERM");
|
|
31
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 12_000);
|
|
32
|
+
try {
|
|
33
|
+
await closed;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function health(ready, token) {
|
|
40
|
+
const response = await fetch(ready.url, {
|
|
41
|
+
method: "POST", headers: { Authorization: "Bearer " + token, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
42
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "service_status", arguments: {} } }),
|
|
43
|
+
signal: AbortSignal.timeout(5000),
|
|
44
|
+
});
|
|
45
|
+
const body = await response.json();
|
|
46
|
+
if (!response.ok || !body.result?.structuredContent?.data?.running)
|
|
47
|
+
throw new Error("Authenticated health check failed");
|
|
48
|
+
}
|
|
49
|
+
function removeSocket(path) {
|
|
50
|
+
if (!existsSync(path))
|
|
51
|
+
return;
|
|
52
|
+
const stat = lstatSync(path);
|
|
53
|
+
if (!stat.isSocket() || stat.uid !== process.getuid())
|
|
54
|
+
throw new Error("Refusing to remove an unowned control socket path");
|
|
55
|
+
unlinkSync(path);
|
|
56
|
+
}
|
|
57
|
+
function logFile(path) {
|
|
58
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
59
|
+
const stat = fstatSync(fd);
|
|
60
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077)) {
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
throw new Error("Invalid private log file");
|
|
63
|
+
}
|
|
64
|
+
return fd;
|
|
65
|
+
}
|
|
66
|
+
/** Stable owner for foreground and service managers. Only explicit requests restart a child. */
|
|
67
|
+
export async function launch(home, nativeEnvironment) {
|
|
68
|
+
process.umask(0o077);
|
|
69
|
+
startupPaths(home); // Also checks private ownership of the locator.
|
|
70
|
+
const unlock = acquireLauncherLock(home);
|
|
71
|
+
const socket = join(home, "control.sock"), children = new Set();
|
|
72
|
+
let current, ready, restarting = true, closing = false;
|
|
73
|
+
let release;
|
|
74
|
+
const finished = new Promise(resolve => { release = resolve; });
|
|
75
|
+
const control = createServer(async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
if (req.method !== "POST" || !["/status", "/restart"].includes(req.url ?? "")) {
|
|
78
|
+
res.writeHead(404).end();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const result = req.url === "/restart" ? await restart() : await status();
|
|
82
|
+
res.setHeader("Content-Type", "application/json");
|
|
83
|
+
res.end(JSON.stringify(result));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
res.writeHead(503).end(JSON.stringify({ error: "Service unavailable; inspect private logs and startup files" }));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
const shutdown = async () => {
|
|
90
|
+
if (closing)
|
|
91
|
+
return;
|
|
92
|
+
closing = true;
|
|
93
|
+
control.close();
|
|
94
|
+
control.closeAllConnections();
|
|
95
|
+
await Promise.all([...children].map(stop));
|
|
96
|
+
try {
|
|
97
|
+
removeSocket(socket);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
unlock();
|
|
101
|
+
release();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const signal = () => { void shutdown(); };
|
|
105
|
+
process.on("SIGINT", signal);
|
|
106
|
+
process.on("SIGTERM", signal);
|
|
107
|
+
const start = async () => {
|
|
108
|
+
const inputs = await prepareStartup(home);
|
|
109
|
+
if (closing)
|
|
110
|
+
throw new Error("Launcher is closing");
|
|
111
|
+
const log = logFile(join(inputs.dataDir, "service.log"));
|
|
112
|
+
const child = fork(fileURLToPath(new URL("./main.js", import.meta.url)), ["--config", inputs.configPath, "--environment", inputs.environmentPath], {
|
|
113
|
+
env: { ...nativeEnvironment, AGENT_TASK_INSTANCE_ID: randomUUID() }, execArgv: [], stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
114
|
+
});
|
|
115
|
+
children.add(child);
|
|
116
|
+
const output = (chunk) => { appendFileSync(log, chunk); process.stdout.write(chunk); };
|
|
117
|
+
child.stdout.on("data", output);
|
|
118
|
+
child.stderr.on("data", output);
|
|
119
|
+
child.once("close", () => {
|
|
120
|
+
closeSync(log);
|
|
121
|
+
children.delete(child);
|
|
122
|
+
if (current === child && !restarting && !closing) {
|
|
123
|
+
ready = undefined;
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
void shutdown();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
child.on("message", (value) => {
|
|
129
|
+
if (value.type !== "restart")
|
|
130
|
+
return;
|
|
131
|
+
if (child !== current || restarting || closing) {
|
|
132
|
+
child.send({ type: "restarted", error: "Restart already in progress or unavailable" }, () => { });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
void restart(child).then(value => {
|
|
136
|
+
if (child.connected)
|
|
137
|
+
child.send({ type: "restarted", ready: value }, () => { });
|
|
138
|
+
}, () => {
|
|
139
|
+
if (child.connected)
|
|
140
|
+
child.send({ type: "restarted", error: "Restart failed; inspect private logs and use CLI restart after correcting startup files" }, () => { });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
const value = (await message(child, "ready")).ready;
|
|
145
|
+
await health(value, inputs.environment[inputs.config.tokenEnv]);
|
|
146
|
+
current = child;
|
|
147
|
+
ready = value;
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
await stop(child);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const status = async () => {
|
|
156
|
+
if (!ready || restarting || closing)
|
|
157
|
+
throw new Error("Service unavailable");
|
|
158
|
+
const paths = startupPaths(home), config = loadConfig(paths.configPath), env = readEnvironment(paths.environmentPath);
|
|
159
|
+
await health(ready, env[config.tokenEnv]);
|
|
160
|
+
return ready;
|
|
161
|
+
};
|
|
162
|
+
const restart = async (requester) => {
|
|
163
|
+
if (restarting || closing)
|
|
164
|
+
throw new Error("Restart already in progress or closed");
|
|
165
|
+
restarting = true;
|
|
166
|
+
const old = current;
|
|
167
|
+
current = undefined;
|
|
168
|
+
ready = undefined;
|
|
169
|
+
try {
|
|
170
|
+
if (old) {
|
|
171
|
+
const drained = message(old, "drained");
|
|
172
|
+
old.send({ type: "drain" });
|
|
173
|
+
try {
|
|
174
|
+
await drained;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
await stop(old);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
// Keep only the authenticated Web response alive until the new process is verified.
|
|
181
|
+
if (old !== requester)
|
|
182
|
+
await stop(old);
|
|
183
|
+
}
|
|
184
|
+
const value = await start();
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
restarting = false;
|
|
189
|
+
if (requester) {
|
|
190
|
+
const timer = setTimeout(() => { void stop(requester); }, 5000);
|
|
191
|
+
requester.once("close", () => clearTimeout(timer));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
removeSocket(socket); // Stale socket only, under exclusive launcher ownership.
|
|
197
|
+
await new Promise((resolve, reject) => { control.once("error", reject); control.listen(socket, resolve); });
|
|
198
|
+
chmodSync(socket, 0o600);
|
|
199
|
+
await start();
|
|
200
|
+
restarting = false;
|
|
201
|
+
await finished;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await shutdown();
|
|
205
|
+
process.off("SIGINT", signal);
|
|
206
|
+
process.off("SIGTERM", signal);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Local administrator capability: the socket lives in the private launcher directory. */
|
|
210
|
+
export function controlService(home, action) {
|
|
211
|
+
startupPaths(home);
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
let timedOut = false;
|
|
214
|
+
const req = request({ socketPath: join(home, "control.sock"), path: "/" + action, method: "POST" }, res => {
|
|
215
|
+
let body = "";
|
|
216
|
+
res.setEncoding("utf8");
|
|
217
|
+
res.on("data", chunk => { body += chunk; });
|
|
218
|
+
res.on("end", () => {
|
|
219
|
+
if (res.statusCode !== 200) {
|
|
220
|
+
reject(new Error("Service unavailable; inspect private logs and startup files"));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
resolve(JSON.parse(body));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
reject(new Error("Invalid launcher response"));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
req.setTimeout(60_000, () => { timedOut = true; req.destroy(); });
|
|
232
|
+
req.once("error", () => reject(new Error(timedOut
|
|
233
|
+
? "Service control timed out; outcome is unknown, check status before another restart"
|
|
234
|
+
: "Cannot contact launcher; start the foreground or managed service first")));
|
|
235
|
+
req.end();
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=launcher.js.map
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** The stable installation lock prevents two launchers or uninstall during a directory switch. */
|
|
5
|
+
export function acquireLauncherLock(home) {
|
|
6
|
+
const db = new DatabaseSync(join(home, "launcher-lock.sqlite"));
|
|
7
|
+
try {
|
|
8
|
+
db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
db.close();
|
|
12
|
+
throw new Error("Another launcher owns this installation");
|
|
13
|
+
}
|
|
14
|
+
let released = false;
|
|
15
|
+
return () => { if (!released) {
|
|
16
|
+
db.close();
|
|
17
|
+
released = true;
|
|
18
|
+
} };
|
|
19
|
+
}
|
|
20
|
+
/** A dedicated SQLite transaction owns an OS lock until close or process death.
|
|
21
|
+
* Local filesystems only. Never unlink this file: that would create a second lock identity.
|
|
22
|
+
*/
|
|
23
|
+
export async function acquireLock(directory) {
|
|
24
|
+
if (!["linux", "darwin"].includes(process.platform))
|
|
25
|
+
throw new Error("Supported platforms: Linux and macOS");
|
|
26
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
const db = new DatabaseSync(join(directory, "service-lock.sqlite"));
|
|
28
|
+
try {
|
|
29
|
+
db.exec("PRAGMA busy_timeout=0; PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE");
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
db.close();
|
|
33
|
+
throw new Error("State directory is locked or its filesystem does not support SQLite locking", { cause: error });
|
|
34
|
+
}
|
|
35
|
+
let released = false;
|
|
36
|
+
return async () => {
|
|
37
|
+
if (released)
|
|
38
|
+
return;
|
|
39
|
+
db.close();
|
|
40
|
+
released = true;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=lock.js.map
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { baseEnvironment, readEnvironment, validateServiceEnvironment } from "./environment.js";
|
|
7
|
+
import { bearerVerifier } from "./http-auth.js";
|
|
8
|
+
import { Settings } from "./settings.js";
|
|
9
|
+
import { createWebRouter } from "./web.js";
|
|
10
|
+
import { acquireLock } from "./lock.js";
|
|
11
|
+
import { createMcpServer } from "./mcp.js";
|
|
12
|
+
import { errorText } from "./model.js";
|
|
13
|
+
import { TaskStore } from "./task-storage.js";
|
|
14
|
+
import { Tasks } from "./tasks.js";
|
|
15
|
+
import { Results } from "./results.js";
|
|
16
|
+
import { createExecution } from "./node-execution.js";
|
|
17
|
+
import { executorEnvironment } from "./executor-runtime.js";
|
|
18
|
+
import { createCoordinator } from "./coordinator.js";
|
|
19
|
+
process.umask(0o077);
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
if (args[0] !== "--config" || args.length !== 4 || args[2] !== "--environment") {
|
|
22
|
+
console.error("Internal runtime requires --config PATH --environment PATH; use agent-task start");
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
const configPath = resolve(args[1]), environmentPath = resolve(args[3]);
|
|
26
|
+
const native = { ...process.env }, base = baseEnvironment(native);
|
|
27
|
+
const environment = readEnvironment(environmentPath);
|
|
28
|
+
const instanceId = process.env.AGENT_TASK_INSTANCE_ID ?? randomUUID();
|
|
29
|
+
for (const key of Object.keys(process.env))
|
|
30
|
+
delete process.env[key];
|
|
31
|
+
Object.assign(process.env, base, environment);
|
|
32
|
+
const config = loadConfig(configPath);
|
|
33
|
+
const { token, adminToken } = validateServiceEnvironment(config, environment);
|
|
34
|
+
const authorized = bearerVerifier(token);
|
|
35
|
+
let http;
|
|
36
|
+
let tasks, taskStore, settings;
|
|
37
|
+
let release;
|
|
38
|
+
let shutting;
|
|
39
|
+
const connections = new Set();
|
|
40
|
+
function shutdown(code, drain = false) {
|
|
41
|
+
process.exitCode = Math.max(process.exitCode ? Number(process.exitCode) : 0, code);
|
|
42
|
+
return shutting ??= (async () => {
|
|
43
|
+
settings?.close();
|
|
44
|
+
try {
|
|
45
|
+
await tasks?.close();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error(errorText(error));
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
for (const close of connections)
|
|
52
|
+
await close().catch(() => { });
|
|
53
|
+
if (http) {
|
|
54
|
+
if (drain)
|
|
55
|
+
http.close(); // Release the listener, but retain the in-flight Web restart response.
|
|
56
|
+
else {
|
|
57
|
+
http.closeAllConnections();
|
|
58
|
+
await new Promise(done => http.close(() => done()));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
taskStore?.close();
|
|
62
|
+
await release?.();
|
|
63
|
+
})().catch(error => { console.error(errorText(error)); process.exitCode = 1; });
|
|
64
|
+
}
|
|
65
|
+
const fatal = (error) => { console.error(errorText(error)); void shutdown(1).finally(() => { if (process.connected)
|
|
66
|
+
process.disconnect(); }); };
|
|
67
|
+
let restartPending = false;
|
|
68
|
+
const restart = async () => {
|
|
69
|
+
if (!process.connected || restartPending || shutting || settings?.status().writeBlocked)
|
|
70
|
+
throw new Error("Restart unavailable");
|
|
71
|
+
restartPending = true;
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
process.once("message", function receive(value) {
|
|
74
|
+
if (value.type !== "restarted") {
|
|
75
|
+
process.once("message", receive);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// The HTTP adapter finishes the authenticated response before this child exits.
|
|
79
|
+
setTimeout(() => { if (process.connected)
|
|
80
|
+
process.disconnect(); http?.closeAllConnections(); }, 200);
|
|
81
|
+
if (value.error)
|
|
82
|
+
reject(new Error(value.error));
|
|
83
|
+
else
|
|
84
|
+
resolve(value.ready);
|
|
85
|
+
});
|
|
86
|
+
process.send({ type: "restart" });
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
process.on("message", (value) => {
|
|
90
|
+
if (value.type === "drain")
|
|
91
|
+
void shutdown(0, true).then(() => {
|
|
92
|
+
if (process.exitCode) {
|
|
93
|
+
if (process.connected)
|
|
94
|
+
process.disconnect();
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
process.send?.({ type: "drained" });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
const stop = () => { void shutdown(0).finally(() => { http?.closeAllConnections(); if (process.connected)
|
|
101
|
+
process.disconnect(); }); };
|
|
102
|
+
process.once("SIGINT", stop);
|
|
103
|
+
process.once("SIGTERM", stop);
|
|
104
|
+
process.once("disconnect", stop);
|
|
105
|
+
try {
|
|
106
|
+
release = await acquireLock(config.dataDir);
|
|
107
|
+
taskStore = new TaskStore(config.dataDir);
|
|
108
|
+
tasks = new Tasks(taskStore, config.executors, new Results(config.dataDir), createExecution(executorEnvironment(native, environment, config)), config.coordinator ? createCoordinator(config.dataDir, config.coordinator) : undefined, fatal);
|
|
109
|
+
const app = express();
|
|
110
|
+
app.disable("x-powered-by");
|
|
111
|
+
app.disable("etag");
|
|
112
|
+
app.use((_req, res, next) => {
|
|
113
|
+
if (shutting) {
|
|
114
|
+
res.status(503).end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
next();
|
|
118
|
+
});
|
|
119
|
+
if (adminToken) {
|
|
120
|
+
settings = new Settings(configPath, environmentPath, config, base, process.env);
|
|
121
|
+
app.use("/admin", createWebRouter(tasks, settings, adminToken, fatal, restart, instanceId));
|
|
122
|
+
}
|
|
123
|
+
app.use("/mcp", (req, res, next) => {
|
|
124
|
+
const suffix = req.socket.localPort === 80 ? "" : ":" + req.socket.localPort;
|
|
125
|
+
if (req.headers.origin !== undefined || !["127.0.0.1" + suffix, "localhost" + suffix].includes(req.headers.host ?? "")) {
|
|
126
|
+
res.status(403).end();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!authorized(req.headers.authorization)) {
|
|
130
|
+
res.status(401).set("WWW-Authenticate", "Bearer").end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
next();
|
|
134
|
+
}, express.json({ limit: "512kb" }));
|
|
135
|
+
app.post("/mcp", async (req, res) => {
|
|
136
|
+
const server = createMcpServer(tasks, fatal);
|
|
137
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
138
|
+
const close = async () => { connections.delete(close); await transport.close(); await server.close(); };
|
|
139
|
+
connections.add(close);
|
|
140
|
+
res.once("close", () => { void close().catch(() => { }); });
|
|
141
|
+
try {
|
|
142
|
+
await server.connect(transport);
|
|
143
|
+
await transport.handleRequest(req, res, req.body);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
if (!res.headersSent)
|
|
147
|
+
res.status(500).json({ error: "MCP request failed" });
|
|
148
|
+
await close();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
app.all("/mcp", (_req, res) => { res.status(405).set("Allow", "POST").end(); });
|
|
152
|
+
app.use((_req, res) => { res.status(404).end(); });
|
|
153
|
+
app.use(((error, _req, res, _next) => {
|
|
154
|
+
res.status(error.status === 413 ? 413 : 400).json({ error: "Invalid request body" });
|
|
155
|
+
}));
|
|
156
|
+
http = await new Promise((done, reject) => {
|
|
157
|
+
const listening = app.listen(config.port, "127.0.0.1", () => done(listening));
|
|
158
|
+
listening.once("error", reject);
|
|
159
|
+
});
|
|
160
|
+
if (shutting) {
|
|
161
|
+
http.closeAllConnections();
|
|
162
|
+
http.close();
|
|
163
|
+
throw new Error("Startup stopped");
|
|
164
|
+
}
|
|
165
|
+
tasks.start();
|
|
166
|
+
const address = http.address();
|
|
167
|
+
const origin = "http://127.0.0.1:" + (typeof address === "object" && address ? address.port : config.port);
|
|
168
|
+
const ready = { instanceId, pid: process.pid, url: origin + "/mcp", consoleUrl: settings ? origin + "/admin/" : null };
|
|
169
|
+
console.log(JSON.stringify({ event: "ready", ...ready }));
|
|
170
|
+
process.send?.({ type: "ready", ready });
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
fatal(error);
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=main.js.map
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { errorText } from "./model.js";
|
|
4
|
+
import { StorageError } from "./task-storage.js";
|
|
5
|
+
import { replySchema, taskSchema, taskUpdateSchema, taskIdSchema, taskListSchema, taskRunListSchema } from "./task-contract.js";
|
|
6
|
+
import { version } from "./version.js";
|
|
7
|
+
export function createMcpServer(service, fatal) {
|
|
8
|
+
const server = new McpServer({ name: "agent-task", version });
|
|
9
|
+
function register(name, description, inputSchema, execute, readOnly = true) {
|
|
10
|
+
const outputSchema = z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() });
|
|
11
|
+
server.registerTool(name, {
|
|
12
|
+
description, inputSchema, outputSchema,
|
|
13
|
+
annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, idempotentHint: readOnly, openWorldHint: !readOnly },
|
|
14
|
+
}, async (input) => {
|
|
15
|
+
let value;
|
|
16
|
+
try {
|
|
17
|
+
value = { ok: true, data: execute(input) };
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof StorageError)
|
|
21
|
+
fatal(error);
|
|
22
|
+
value = { ok: false, error: errorText(error) };
|
|
23
|
+
}
|
|
24
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value,
|
|
25
|
+
isError: !value.ok };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
register("service_status", "Service health and active node run IDs. MCP disconnection does not cancel tasks.", z.object({}).strict(), () => service.status());
|
|
29
|
+
register("executor_list", "Registered local ACP execution profiles and authorization settings. Approval filtering is not a sandbox.", z.object({}).strict(), () => service.listExecutors());
|
|
30
|
+
register("task_create", "Create a task with a caller-defined DAG in nodes. Each node selects an administrator-registered ACP executorId. The coordinator advances this fixed DAG, reviews deliveries and requests missing user facts; it does not plan new nodes. Optional workspace selects an existing absolute directory; otherwise each executor uses its registered cwd. Path checks do not grant permissions; no uncertain-effect retries.", taskSchema, input => service.create(input), false);
|
|
31
|
+
register("task_list", "List persisted task definitions.", taskListSchema, input => service.list(input.after, input.limit));
|
|
32
|
+
register("task_get", "Get task goal, caller-defined DAG and assigned executor profiles, canonical workspace (null means executor cwd defaults), budgets and schedule.", taskIdSchema, input => service.get(input.id));
|
|
33
|
+
register("task_update", "Pause, resume or cancel future triggers only; resume never catches up. To change a task, cancel it and create a new task. Use task_run_cancel to stop an active run; cancellation does not roll back external effects.", taskUpdateSchema, input => service.update(input), false);
|
|
34
|
+
register("task_run_list", "List independent task runs with node progress and blocking reasons.", taskRunListSchema, input => service.runs(input.taskId, input.after, input.limit));
|
|
35
|
+
register("task_run_get", "Get the committed plan, selected node workspaces, ACP turns, accepted deliveries, blocking questions and errors for one task run.", taskIdSchema, input => service.run(input.id));
|
|
36
|
+
register("task_run_reply", "Answer a persisted question on the same task run. Deduplicated by requestKey; no permission escalation or uncertain-effect replay.", replySchema, input => service.reply(input), false);
|
|
37
|
+
register("task_run_cancel", "Stop this task run; external effects are not rolled back.", taskIdSchema, input => service.cancel(input.id), false);
|
|
38
|
+
return server;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=mcp.js.map
|