@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,184 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, realpathSync, readdirSync, unlinkSync, rmdirSync, openSync, closeSync, fsyncSync, renameSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve, isAbsolute, basename } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { controlService } from "../dist/launcher.js";
|
|
9
|
+
|
|
10
|
+
export const packageRoot = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
|
11
|
+
|
|
12
|
+
export const label = "com.agent-task.service";
|
|
13
|
+
export const unit = "agent-task.service";
|
|
14
|
+
export const packages = {
|
|
15
|
+
pi: ["@earendil-works/pi-coding-agent@1.3.0", "pi-acp@0.0.34"],
|
|
16
|
+
codex: ["@agentclientprotocol/codex-acp@2.2.2"],
|
|
17
|
+
claude: ["@zed-industries/claude-agent-acp@0.23.1"],
|
|
18
|
+
opencode: ["opencode-ai@1.19.3"],
|
|
19
|
+
};
|
|
20
|
+
export function argumentsFor(argv) {
|
|
21
|
+
const flags = new Set(["prepare-only", "service", "check", "probe", "resume", "help"]), options = {};
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const key = argv[i].replace(/^--/, "");
|
|
24
|
+
if (!argv[i].startsWith("--") || key in options) throw new Error("Invalid or repeated option");
|
|
25
|
+
if (flags.has(key)) options[key] = true;
|
|
26
|
+
else if (["home", "executors", "cwd", "provider", "model", "port"].includes(key) && argv[i + 1] && !argv[i + 1].startsWith("--")) options[key] = argv[++i];
|
|
27
|
+
else throw new Error("Unknown option or missing value: " + key);
|
|
28
|
+
}
|
|
29
|
+
return options;
|
|
30
|
+
}
|
|
31
|
+
export function installRoot(options) {
|
|
32
|
+
if (!["linux", "darwin"].includes(process.platform)) throw new Error("Only Linux and macOS are supported");
|
|
33
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
34
|
+
if (major < 22 || (major === 22 && minor < 19)) throw new Error("Node.js 22.19+ is required");
|
|
35
|
+
const path = options.home ?? join(homedir(), process.platform === "darwin" ? "Library/Application Support/agent-task" : ".local/share/agent-task");
|
|
36
|
+
if (!isAbsolute(path) || /[\x00-\x1f\x7f]/.test(path)) throw new Error("Installation home must be an absolute path without control characters");
|
|
37
|
+
let parent = resolve(path);
|
|
38
|
+
const tail = [];
|
|
39
|
+
while (!existsSync(parent)) { tail.unshift(basename(parent)); parent = dirname(parent); }
|
|
40
|
+
return join(realpathSync(parent), ...tail);
|
|
41
|
+
}
|
|
42
|
+
export function privateFile(path) {
|
|
43
|
+
const stat = lstatSync(path);
|
|
44
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077)) throw new Error("Expected a private, owned regular file: " + path);
|
|
45
|
+
}
|
|
46
|
+
export function json(path) {
|
|
47
|
+
privateFile(path);
|
|
48
|
+
try { return JSON.parse(readFileSync(path, "utf8")); }
|
|
49
|
+
catch { throw new Error("Cannot read valid JSON from " + path); } // JSON parser diagnostics can quote secrets.
|
|
50
|
+
}
|
|
51
|
+
export function createFile(path, text) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
53
|
+
writeFileSync(path, text, { flag: "wx", mode: 0o600 });
|
|
54
|
+
}
|
|
55
|
+
export function run(command, args, options = {}) {
|
|
56
|
+
const env = Object.fromEntries(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
|
|
57
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: 300000, stdio: "pipe", env, ...options });
|
|
58
|
+
if (result.error || result.status !== 0) throw new Error("Command failed (diagnostics withheld; may contain credentials): " + command + " " + args[0]);
|
|
59
|
+
return result.stdout?.trim();
|
|
60
|
+
}
|
|
61
|
+
export function operationLock(root) {
|
|
62
|
+
const db = new DatabaseSync(join(root, "setup-lock.sqlite"));
|
|
63
|
+
try { db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE"); }
|
|
64
|
+
catch { db.close(); throw new Error("Another setup/uninstall operation owns this installation"); }
|
|
65
|
+
return () => db.close();
|
|
66
|
+
}
|
|
67
|
+
export function readManifest(root) {
|
|
68
|
+
if (realpathSync(root) !== root || lstatSync(root).uid !== process.getuid() || (lstatSync(root).mode & 0o077)) throw new Error("Installation home must be canonical, owned and private");
|
|
69
|
+
const value = json(join(root, "installation.json"));
|
|
70
|
+
if (value.project !== "agent-task" || value.root !== root || value.platform !== process.platform || value.home !== homedir() || typeof value.node !== "string" || !isAbsolute(value.node) || value.app !== packageRoot) throw new Error("Installation ownership does not match this user/platform");
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
export function saveManifest(root, manifest) {
|
|
74
|
+
// Preserve the last committed inventory even if a disk-full write fails.
|
|
75
|
+
const temporary = join(root, `installation-${process.pid}.tmp`);
|
|
76
|
+
let fd;
|
|
77
|
+
try {
|
|
78
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
79
|
+
writeFileSync(fd, JSON.stringify(manifest, null, 2) + "\n"); fsyncSync(fd);
|
|
80
|
+
closeSync(fd); fd = undefined;
|
|
81
|
+
renameSync(temporary, join(root, "installation.json"));
|
|
82
|
+
const directory = openSync(root, "r");
|
|
83
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
84
|
+
} finally {
|
|
85
|
+
if (fd !== undefined) closeSync(fd);
|
|
86
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function runtimeBaseEnv(manifest) {
|
|
90
|
+
// Capture the OS user environment; the runtime isolates private service values before worker creation.
|
|
91
|
+
return { ...process.env, HOME: manifest.home, PATH: [join(manifest.root, "components/node_modules/.bin"), dirname(manifest.node), process.env.PATH ?? "", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":"), LANG: process.env.LANG ?? "en_US.UTF-8" };
|
|
92
|
+
}
|
|
93
|
+
const xml = value => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
94
|
+
const systemdQuote = value => '"' + value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("$", "$$") + '"';
|
|
95
|
+
export function serviceDefinition(manifest) {
|
|
96
|
+
const runner = join(manifest.app, "scripts/service-runner.mjs");
|
|
97
|
+
if (manifest.platform === "linux") return {
|
|
98
|
+
path: join(manifest.home, ".config/systemd/user", unit),
|
|
99
|
+
text: `[Unit]\nDescription=Agent Task (managed by setup)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=${[manifest.node, runner, manifest.root].map(systemdQuote).join(" ")}\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=45\nKillMode=control-group\nUMask=0077\n\n[Install]\nWantedBy=default.target\n`,
|
|
100
|
+
};
|
|
101
|
+
return { path: join(manifest.home, "Library/LaunchAgents", label + ".plist"), text:
|
|
102
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${label}</string>\n<key>ProgramArguments</key><array>${[manifest.node, runner, manifest.root].map(value => `<string>${xml(value)}</string>`).join("")}</array>\n<key>RunAtLoad</key><true/>\n<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n<key>ThrottleInterval</key><integer>5</integer>\n<key>ExitTimeOut</key><integer>45</integer>\n<key>AbandonProcessGroup</key><false/>\n<key>Umask</key><integer>63</integer>\n</dict></plist>\n` };
|
|
103
|
+
}
|
|
104
|
+
export function serviceEnv() {
|
|
105
|
+
const keys = ["HOME", "PATH", "LANG", "LC_ALL", "DBUS_SESSION_BUS_ADDRESS"];
|
|
106
|
+
return { ...Object.fromEntries(keys.filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]])),
|
|
107
|
+
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.getuid()}` };
|
|
108
|
+
}
|
|
109
|
+
export async function checkHealth(home, waitMs = 5000) {
|
|
110
|
+
const deadline = Date.now() + waitMs;
|
|
111
|
+
do {
|
|
112
|
+
try { return await controlService(home, "status"); }
|
|
113
|
+
catch { /* The launcher may still be starting; never log credentials. */ }
|
|
114
|
+
await delay(200);
|
|
115
|
+
} while (Date.now() < deadline);
|
|
116
|
+
throw new Error("Authenticated MCP health check failed; inspect private logs and startup files");
|
|
117
|
+
}
|
|
118
|
+
export function verifyServiceFile(manifest) {
|
|
119
|
+
const definition = serviceDefinition(manifest);
|
|
120
|
+
if (existsSync(definition.path)) {
|
|
121
|
+
privateFile(definition.path);
|
|
122
|
+
if (readFileSync(definition.path, "utf8") !== definition.text) throw new Error("Service file differs; refusing to overwrite or remove it: " + definition.path);
|
|
123
|
+
}
|
|
124
|
+
return definition;
|
|
125
|
+
}
|
|
126
|
+
export function activate(manifest) {
|
|
127
|
+
const definition = verifyServiceFile(manifest);
|
|
128
|
+
if (!existsSync(definition.path)) createFile(definition.path, definition.text);
|
|
129
|
+
if (manifest.platform === "linux") {
|
|
130
|
+
run("systemctl", ["--user", "daemon-reload"], { env: serviceEnv() });
|
|
131
|
+
run("systemctl", ["--user", "enable", "--now", unit], { env: serviceEnv() });
|
|
132
|
+
run("systemctl", ["--user", "is-active", unit], { env: serviceEnv() });
|
|
133
|
+
} else {
|
|
134
|
+
const domain = `gui/${process.getuid()}`;
|
|
135
|
+
const active = spawnSync("launchctl", ["print", domain + "/" + label], { stdio: "ignore" });
|
|
136
|
+
if (active.status !== 0) run("launchctl", ["bootstrap", domain, definition.path]);
|
|
137
|
+
run("launchctl", ["kickstart", domain + "/" + label]);
|
|
138
|
+
run("launchctl", ["print", domain + "/" + label]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export async function deactivate(manifest) {
|
|
142
|
+
const definition = verifyServiceFile(manifest);
|
|
143
|
+
if (!existsSync(definition.path)) {
|
|
144
|
+
if (manifest.serviceInstalled) throw new Error("Managed service file is missing; verify it is unloaded before cleanup");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (manifest.platform === "linux") {
|
|
148
|
+
run("systemctl", ["--user", "disable", "--now", unit], { env: serviceEnv() });
|
|
149
|
+
// Do not remove code while the service is still active or deactivating.
|
|
150
|
+
const state = run("systemctl", ["--user", "show", "--property=ActiveState", "--value", unit], { env: serviceEnv() });
|
|
151
|
+
if (!["inactive", "failed"].includes(state)) throw new Error("Service has not stopped");
|
|
152
|
+
} else {
|
|
153
|
+
const target = `gui/${process.getuid()}/${label}`;
|
|
154
|
+
const active = spawnSync("launchctl", ["print", target], { stdio: "ignore" });
|
|
155
|
+
if (active.status === 0) run("launchctl", ["bootout", target]);
|
|
156
|
+
// bootout can return while launchd still reports the job during graceful shutdown.
|
|
157
|
+
const deadline = Date.now() + 50_000;
|
|
158
|
+
while (spawnSync("launchctl", ["print", target], { stdio: "ignore" }).status === 0) {
|
|
159
|
+
if (Date.now() >= deadline) throw new Error("LaunchAgent is still loaded");
|
|
160
|
+
await delay(100);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
unlinkSync(definition.path);
|
|
164
|
+
if (manifest.platform === "linux") run("systemctl", ["--user", "daemon-reload"], { env: serviceEnv() });
|
|
165
|
+
}
|
|
166
|
+
export function inventory(root, relative) {
|
|
167
|
+
const path = join(root, relative), stat = lstatSync(path);
|
|
168
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return [relative];
|
|
169
|
+
return readdirSync(path).flatMap(name => inventory(root, join(relative, name))).concat(relative);
|
|
170
|
+
}
|
|
171
|
+
export function removeInventory(root, files) {
|
|
172
|
+
for (const relative of files) {
|
|
173
|
+
if (!/^components(\/|$)/.test(relative) || relative.split("/").includes("..")) throw new Error("Invalid component inventory");
|
|
174
|
+
const path = join(root, relative);
|
|
175
|
+
// Never follow a directory replaced by a symlink outside the installation.
|
|
176
|
+
try {
|
|
177
|
+
if (realpathSync(dirname(path)) !== dirname(path)) throw new Error("Component parent path changed; cleanup stopped");
|
|
178
|
+
const stat = lstatSync(path);
|
|
179
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
180
|
+
try { rmdirSync(path); } catch (error) { if (error.code !== "ENOTEMPTY") throw error; }
|
|
181
|
+
} else unlinkSync(path);
|
|
182
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import { version } from "../dist/version.js";
|
|
4
|
+
const [tool, input = "{}"] = process.argv.slice(2);
|
|
5
|
+
if (!tool || !process.env.AGENT_TASK_TOKEN) {
|
|
6
|
+
console.error("Usage: AGENT_TASK_TOKEN=... node scripts/mcp-call.mjs <tool|--list> '[JSON arguments]'\nAGENT_TASK_URL defaults to http://127.0.0.1:8765/mcp");
|
|
7
|
+
process.exit(2);
|
|
8
|
+
}
|
|
9
|
+
const client = new Client({ name: "agent-task-command", version });
|
|
10
|
+
try {
|
|
11
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(process.env.AGENT_TASK_URL ?? "http://127.0.0.1:8765/mcp"),
|
|
12
|
+
{ requestInit: { headers: { Authorization: "Bearer " + process.env.AGENT_TASK_TOKEN } } }));
|
|
13
|
+
const result = tool === "--list" ? await client.listTools() : await client.callTool({ name: tool, arguments: JSON.parse(input) });
|
|
14
|
+
console.log(JSON.stringify(result, null, 2));
|
|
15
|
+
if ("isError" in result && result.isError) process.exitCode = 1;
|
|
16
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
17
|
+
finally { await client.close(); }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Real ACP initialization only: no session/new, prompt, login, or model request.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { Readable, Writable } from "node:stream";
|
|
4
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
5
|
+
import { loadConfig } from "../dist/config.js";
|
|
6
|
+
import { executorCommand, executorEnvironment } from "../dist/executor-runtime.js";
|
|
7
|
+
import { readEnvironment } from "../dist/environment.js";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { terminateGroup } from "../dist/acp.js";
|
|
10
|
+
import { version } from "../dist/version.js";
|
|
11
|
+
const [file, id] = process.argv.slice(2);
|
|
12
|
+
if (!file || !id) { console.error("Usage: node scripts/probe-agent.mjs <config.json> <executorId>"); process.exit(2); }
|
|
13
|
+
const config = loadConfig(file);
|
|
14
|
+
const executor = config.executors.find(value => value.id === id);
|
|
15
|
+
if (!executor) throw new Error("Executor not found");
|
|
16
|
+
const env = executorEnvironment(process.env, readEnvironment(join(config.dataDir, "environment.json")), config);
|
|
17
|
+
const launch = executorCommand(executor.type, env);
|
|
18
|
+
const child = spawn(launch.command, launch.args, {
|
|
19
|
+
cwd: executor.cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
child.stderr.resume();
|
|
22
|
+
let connection;
|
|
23
|
+
const closed = new Promise(resolve => child.once("close", resolve));
|
|
24
|
+
child.once("error", error => { console.error("ACP executable could not start"); connection?.close(error); });
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
connection?.close(new Error("ACP initialization timed out"));
|
|
27
|
+
void terminateGroup(child);
|
|
28
|
+
}, 10000);
|
|
29
|
+
try {
|
|
30
|
+
connection = await acp.client({ name: "agent-task-probe" }).connect(acp.ndJsonStream(
|
|
31
|
+
Writable.toWeb(child.stdin), Readable.toWeb(child.stdout), { maxMessageBytes: 1048576 }));
|
|
32
|
+
const result = await connection.agent.request(acp.methods.agent.initialize, {
|
|
33
|
+
protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {},
|
|
34
|
+
clientInfo: { name: "agent-task-probe", version },
|
|
35
|
+
});
|
|
36
|
+
if (result.protocolVersion !== acp.PROTOCOL_VERSION) throw new Error("Incompatible ACP protocol version");
|
|
37
|
+
console.log(JSON.stringify(result, null, 2));
|
|
38
|
+
} catch { console.error("ACP initialize failed; inspect native configuration in a private terminal. No task was sent."); process.exitCode = 1; }
|
|
39
|
+
finally { clearTimeout(timer); connection?.close(); await terminateGroup(child); await closed; }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readManifest, runtimeBaseEnv } from "./install-lib.mjs";
|
|
2
|
+
import { launch } from "../dist/launcher.js";
|
|
3
|
+
|
|
4
|
+
process.umask(0o077);
|
|
5
|
+
const root = process.argv[2];
|
|
6
|
+
if (!root || process.argv.length !== 3) throw new Error("Expected installation home");
|
|
7
|
+
const manifest = readManifest(root);
|
|
8
|
+
if (manifest.phase !== "ready") throw new Error("Installation is not ready");
|
|
9
|
+
await launch(root, runtimeBaseEnv(manifest));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
export async function setupWizard(options, root) {
|
|
4
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive setup requires a terminal; use --executors none|pi,codex,claude,opencode and --cwd ABSOLUTE_DIR for noninteractive setup");
|
|
5
|
+
const input = createInterface({ input: process.stdin, output: process.stdout });
|
|
6
|
+
const abort = new AbortController();
|
|
7
|
+
input.on("SIGINT", () => abort.abort());
|
|
8
|
+
const ask = async (question, fallback) => (await input.question(`${question}${fallback ? " [" + fallback + "]" : ""}: `, { signal: abort.signal })).trim() || fallback;
|
|
9
|
+
try {
|
|
10
|
+
console.log(`Private installation: ${root}\nNo credentials are requested here. Use the local Web console or a private editor after setup.\nExecutors are optional; installing one does not authorize it.`);
|
|
11
|
+
options.executors ??= await ask("Executors to install (none or comma-separated pi,codex,claude,opencode)", "none");
|
|
12
|
+
options.cwd ??= await ask("Existing absolute workspace (outside installation and npm package)", process.cwd());
|
|
13
|
+
if (!options.provider) {
|
|
14
|
+
const provider = await ask("Coordinator provider (none/openai/anthropic/deepseek; configurable later in Web)", "none");
|
|
15
|
+
if (provider !== "none") options.provider = provider;
|
|
16
|
+
}
|
|
17
|
+
if (options.provider) options.model ??= await ask("Coordinator model ID", "");
|
|
18
|
+
options.port ??= await ask("Loopback HTTP port", "8765");
|
|
19
|
+
if (!options["prepare-only"] && !options.service) {
|
|
20
|
+
const answer = await ask("Install and start the background service now? (yes/no)", "no");
|
|
21
|
+
if (!["yes", "no"].includes(answer)) throw new Error("Expected yes or no; setup cancelled");
|
|
22
|
+
if (answer === "yes") options.service = true;
|
|
23
|
+
}
|
|
24
|
+
if (await ask("Write private configuration and install selected components? (yes/no)", "no") !== "yes") throw new Error("Setup cancelled; no configuration written");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (abort.signal.aborted) throw new Error("Setup cancelled; no configuration written");
|
|
27
|
+
throw error;
|
|
28
|
+
} finally { input.close(); }
|
|
29
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { argumentsFor, installRoot, packages, createFile, json, run, operationLock, readManifest, saveManifest,
|
|
8
|
+
runtimeBaseEnv, activate, inventory, verifyServiceFile, packageRoot, checkHealth } from "./install-lib.mjs";
|
|
9
|
+
import { setupWizard } from "./setup-wizard.mjs";
|
|
10
|
+
import { startupPaths } from "../dist/startup.js";
|
|
11
|
+
import { readEnvironment } from "../dist/environment.js";
|
|
12
|
+
import { executorCommand, executorEnvironment } from "../dist/executor-runtime.js";
|
|
13
|
+
import { acquireLauncherLock } from "../dist/lock.js";
|
|
14
|
+
|
|
15
|
+
process.umask(0o077);
|
|
16
|
+
let unlock;
|
|
17
|
+
try {
|
|
18
|
+
const options = argumentsFor(process.argv.slice(2));
|
|
19
|
+
if (options.help) {
|
|
20
|
+
console.log("Usage: agent-task setup [--home ABSOLUTE_PRIVATE_DIR]\nInteractive on a terminal for a new installation. Noninteractive: --executors pi,codex,claude,opencode|none --cwd ABSOLUTE_DIR [--provider openai|anthropic|deepseek --model ID] [--port 8765]\nDefault: --prepare-only (no background service). --service explicitly installs/starts the background service.\nExisting installation: --check [--probe] | --resume. No credentials accepted on the command line. Existing configuration is never replaced.");
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
if (options.service && (options["prepare-only"] || options.check)) throw new Error("--service cannot be combined with --prepare-only or --check");
|
|
24
|
+
if (options.probe && !options.check) throw new Error("--probe requires --check");
|
|
25
|
+
const root = installRoot(options), manifestPath = join(root, "installation.json");
|
|
26
|
+
if (!existsSync(manifestPath) && !options.check && !options.resume && (!options.executors || !options.cwd)) await setupWizard(options, root);
|
|
27
|
+
if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
28
|
+
if (realpathSync(root) !== root || lstatSync(root).uid !== process.getuid() || (lstatSync(root).mode & 0o077)) throw new Error("Installation home must be canonical, owned and private");
|
|
29
|
+
unlock = operationLock(root);
|
|
30
|
+
let manifest;
|
|
31
|
+
if (existsSync(manifestPath)) {
|
|
32
|
+
manifest = readManifest(root);
|
|
33
|
+
if (["executors", "cwd", "provider", "model", "port"].some(key => options[key] !== undefined)) throw new Error("Installation already exists; initialization options cannot replace existing configuration");
|
|
34
|
+
if (manifest.phase !== "ready" && !options.resume) throw new Error("Incomplete or uninstalled components; use --resume explicitly (configuration will be preserved)");
|
|
35
|
+
} else {
|
|
36
|
+
if (options.check || options.probe || options.resume) throw new Error("No installation to check/resume");
|
|
37
|
+
if (!options.executors || !options.cwd) throw new Error("Select --executors pi,codex,claude,opencode|none and --cwd ABSOLUTE_DIR");
|
|
38
|
+
const selected = options.executors === "none" ? [] : options.executors.split(",");
|
|
39
|
+
if (selected.some(id => !Object.hasOwn(packages, id)) || new Set(selected).size !== selected.length) throw new Error("Invalid executor selection");
|
|
40
|
+
if (!options.cwd.startsWith("/") || !lstatSync(realpathSync(options.cwd)).isDirectory()) throw new Error("cwd must be an existing absolute directory");
|
|
41
|
+
const cwd = realpathSync(options.cwd);
|
|
42
|
+
if ([root, packageRoot].some(path => cwd === path || cwd.startsWith(path + "/"))) throw new Error("Workspace must be outside the installation home and npm package");
|
|
43
|
+
if (Boolean(options.provider) !== Boolean(options.model) || (options.provider && !["openai", "anthropic", "deepseek"].includes(options.provider))) throw new Error("Specify a supported --provider together with --model");
|
|
44
|
+
const port = Number(options.port ?? 8765);
|
|
45
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid port");
|
|
46
|
+
// Reject unowned artifacts. This script never adopts or clears an existing installation.
|
|
47
|
+
for (const name of ["components", "launch.json", "data"]) {
|
|
48
|
+
if (existsSync(join(root, name))) throw new Error("Existing unowned installation artifact: " + name);
|
|
49
|
+
}
|
|
50
|
+
manifest = { project: "agent-task", root, home: homedir(), platform: process.platform, node: process.execPath, app: packageRoot,
|
|
51
|
+
selected, cwd, port, coordinator: options.provider ? { provider: options.provider, model: options.model } : undefined,
|
|
52
|
+
phase: "installing", serviceInstalled: false, files: [] };
|
|
53
|
+
createFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
54
|
+
}
|
|
55
|
+
verifyServiceFile(manifest);
|
|
56
|
+
const app = manifest.app, components = join(root, "components");
|
|
57
|
+
if (!existsSync(join(app, "dist/main.js"))) throw new Error("Prebuilt runtime is missing; reinstall the npm package (source contributors: npm run build)");
|
|
58
|
+
if (manifest.phase !== "ready") {
|
|
59
|
+
if (manifest.serviceInstalled) throw new Error("Stop/uninstall the existing service before rebuilding components");
|
|
60
|
+
for (const name of ["components"]) {
|
|
61
|
+
if (!existsSync(join(root, name))) continue;
|
|
62
|
+
const known = new Set(manifest.files);
|
|
63
|
+
if (inventory(root, name).some(file => !known.has(file))) throw new Error("Uninventoried component files exist; refusing to overwrite them during resume");
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
console.log("Using the prebuilt npm runtime. No source checkout or compilation required.");
|
|
67
|
+
mkdirSync(components, { recursive: true, mode: 0o700 });
|
|
68
|
+
if (!existsSync(join(components, "package.json"))) createFile(join(components, "package.json"), JSON.stringify({ private: true }));
|
|
69
|
+
const dependencies = manifest.selected.flatMap(id => packages[id]);
|
|
70
|
+
if (dependencies.length) {
|
|
71
|
+
console.log("Installing selected ACP executors into private components (not global executables).");
|
|
72
|
+
const installArgs = existsSync(join(components, "package-lock.json"))
|
|
73
|
+
? ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]
|
|
74
|
+
: ["install", "--save-exact", "--ignore-scripts", "--no-audit", "--no-fund", ...dependencies];
|
|
75
|
+
run("npm", installArgs, { cwd: components });
|
|
76
|
+
if (manifest.selected.includes("opencode")) {
|
|
77
|
+
// This pinned package requires copying its platform binary. No other lifecycle scripts run.
|
|
78
|
+
run(manifest.node, [join(components, "node_modules/opencode-ai/postinstall.mjs")], { cwd: components });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
mkdirSync(join(root, "data/results"), { recursive: true, mode: 0o700 });
|
|
82
|
+
const dataDir = join(root, "data");
|
|
83
|
+
if (!existsSync(join(root, "launch.json"))) createFile(join(root, "launch.json"), JSON.stringify({ dataDir }) + "\n");
|
|
84
|
+
if (!existsSync(join(dataDir, "environment.json"))) createFile(join(dataDir, "environment.json"), JSON.stringify({ AGENT_TASK_TOKEN: randomBytes(32).toString("hex"), AGENT_TASK_ADMIN_TOKEN: randomBytes(32).toString("hex") }, null, 2) + "\n");
|
|
85
|
+
if (!existsSync(join(dataDir, "config.json"))) createFile(join(dataDir, "config.json"), JSON.stringify({ dataDir, port: manifest.port,
|
|
86
|
+
tokenEnv: "AGENT_TASK_TOKEN", coordinator: manifest.coordinator,
|
|
87
|
+
executors: manifest.selected.map(id => ({ id, type: id, note: "", cwd: manifest.cwd, unattendedApproved: false })) }, null, 2) + "\n");
|
|
88
|
+
manifest.phase = "ready";
|
|
89
|
+
delete manifest.port; delete manifest.cwd; delete manifest.coordinator;
|
|
90
|
+
} finally {
|
|
91
|
+
manifest.files = ["components"].filter(name => existsSync(join(root, name))).flatMap(name => inventory(root, name));
|
|
92
|
+
saveManifest(root, manifest);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const paths = startupPaths(root), env = runtimeBaseEnv(manifest);
|
|
96
|
+
// Use the same native launch environment; private values are read separately, never passed to probes.
|
|
97
|
+
run(manifest.node, ["--input-type=module", "-e", `import {loadConfig} from ${JSON.stringify(pathToFileURL(join(app, "dist/config.js")).href)}; loadConfig(process.argv[1]);`, paths.configPath], { env });
|
|
98
|
+
const config = json(paths.configPath);
|
|
99
|
+
for (const id of manifest.selected) {
|
|
100
|
+
const cliEnv = executorEnvironment(env, readEnvironment(paths.environmentPath), config);
|
|
101
|
+
executorCommand(id, cliEnv); // No native process is launched without an explicit --probe.
|
|
102
|
+
}
|
|
103
|
+
const report = { platform: process.platform, dependencies: "ready", configuration: "valid", background: "not-checked",
|
|
104
|
+
coordinator: config.coordinator ? "configured; authentication not verified" : "not configured; task admission disabled",
|
|
105
|
+
executors: config.executors.map(executor => ({ id: executor.id, authorized: executor.unattendedApproved,
|
|
106
|
+
handshake: "not checked", execution: "not verified by setup" })) };
|
|
107
|
+
if (options.probe) {
|
|
108
|
+
for (const entry of report.executors) {
|
|
109
|
+
try { run(manifest.node, [join(app, "scripts/probe-agent.mjs"), paths.configPath, entry.id], { env, timeout: 20000 }); entry.handshake = "passed (initialize only)"; }
|
|
110
|
+
catch { entry.handshake = "failed; inspect native dependencies/configuration separately"; process.exitCode = 1; }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (options.service) {
|
|
114
|
+
if (!manifest.serviceInstalled) acquireLauncherLock(root)(); // Refuse a live foreground owner.
|
|
115
|
+
// Record intent before activating so a failed service start is still owned by uninstall.
|
|
116
|
+
manifest.serviceInstalled = true; saveManifest(root, manifest);
|
|
117
|
+
activate(manifest);
|
|
118
|
+
report.background = "service manager registered";
|
|
119
|
+
}
|
|
120
|
+
if (manifest.serviceInstalled) {
|
|
121
|
+
try {
|
|
122
|
+
await checkHealth(root, 15000);
|
|
123
|
+
report.background = "authenticated MCP health check passed";
|
|
124
|
+
} catch { report.background = "not ready; run --check after startup and inspect the service manager"; process.exitCode = 1; }
|
|
125
|
+
}
|
|
126
|
+
console.log(JSON.stringify(report, null, 2));
|
|
127
|
+
console.log(`Config: ${paths.configPath}\nSecrets (never printed): ${paths.environmentPath}\nInstalled ACP adapters: ${join(components, "node_modules/.bin")}. Prepare authentication with each executor\'s official native tools under the service OS user. No login is performed by setup.\nConsole: http://127.0.0.1:${config.port}/admin/ (use the separate AGENT_TASK_ADMIN_TOKEN from the private environment file; never place it in a URL). Configure coordinator credentials there or in environment.json, and audit native executor permissions before setting unattendedApproved=true. No permissions are granted by setup. Use Web restart or agent-task restart after changes.\nLinux: user service requires lingering for logout survival (administrator decision). macOS: LaunchAgent survives terminal close, not user logout; log in again to restart. Neither platform executes during sleep/shutdown.`);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(error.message); process.exitCode = 1;
|
|
130
|
+
} finally { unlock?.(); }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { argumentsFor, installRoot, readManifest, saveManifest, operationLock, deactivate, removeInventory, json } from "./install-lib.mjs";
|
|
6
|
+
|
|
7
|
+
import { startupPaths } from "../dist/startup.js";
|
|
8
|
+
import { acquireLauncherLock } from "../dist/lock.js";
|
|
9
|
+
|
|
10
|
+
process.umask(0o077);
|
|
11
|
+
const options = argumentsFor(process.argv.slice(2));
|
|
12
|
+
if (options.help) {
|
|
13
|
+
console.log("Usage: agent-task uninstall [--home ABSOLUTE_PRIVATE_DIR]\nStops/removes the owned service and installed components only. Keeps configuration, credentials, logs, task history and workspaces.");
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
let unlock, release, unlockLauncher;
|
|
17
|
+
try {
|
|
18
|
+
if (Object.keys(options).some(key => key !== "home")) throw new Error("Only --home is accepted");
|
|
19
|
+
const root = installRoot(options);
|
|
20
|
+
if (!existsSync(root)) { console.log("Nothing installed; nothing removed."); process.exit(0); }
|
|
21
|
+
readManifest(root);
|
|
22
|
+
unlock = operationLock(root);
|
|
23
|
+
const manifest = readManifest(root);
|
|
24
|
+
await deactivate(manifest);
|
|
25
|
+
manifest.serviceInstalled = false; saveManifest(root, manifest);
|
|
26
|
+
unlockLauncher = acquireLauncherLock(root);
|
|
27
|
+
if (existsSync(join(root, "launch.json"))) {
|
|
28
|
+
const paths = startupPaths(root), config = json(paths.configPath);
|
|
29
|
+
const protectedPaths = [paths.dataDir, config.dataDir, ...config.executors.map(executor => executor.cwd)];
|
|
30
|
+
// Verify the service is stopped, including any manually started owner.
|
|
31
|
+
const lockModule = join(manifest.app, "dist/lock.js");
|
|
32
|
+
if (existsSync(lockModule)) {
|
|
33
|
+
const { acquireLock } = await import(pathToFileURL(lockModule).href);
|
|
34
|
+
release = await acquireLock(paths.dataDir);
|
|
35
|
+
const storeModule = join(manifest.app, "dist/task-storage.js");
|
|
36
|
+
if (existsSync(join(paths.dataDir, "tasks.sqlite")) && existsSync(storeModule)) {
|
|
37
|
+
const { TaskStore } = await import(pathToFileURL(storeModule).href);
|
|
38
|
+
const store = new TaskStore(paths.dataDir);
|
|
39
|
+
try {
|
|
40
|
+
for (const task of store.definitions()) protectedPaths.push(task.workspace, task.input.workspace, ...Object.values(task.executors).map(executor => executor.cwd));
|
|
41
|
+
for (const run of store.rounds()) for (const node of run.nodes) protectedPaths.push(node.workspace);
|
|
42
|
+
} finally { store.close(); }
|
|
43
|
+
}
|
|
44
|
+
} else if (manifest.phase !== "uninstalled") throw new Error("Cannot verify data ownership; resume setup before uninstall");
|
|
45
|
+
for (const path of protectedPaths.filter(Boolean)) {
|
|
46
|
+
const canonical = existsSync(path) ? realpathSync(path) : path;
|
|
47
|
+
const components = join(root, "components");
|
|
48
|
+
if (canonical === components || canonical.startsWith(components + "/")) {
|
|
49
|
+
throw new Error("Installed components are referenced as data/workspace; refusing to delete them");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
removeInventory(root, manifest.files);
|
|
54
|
+
manifest.phase = "uninstalled"; manifest.files = []; saveManifest(root, manifest);
|
|
55
|
+
console.log("Owned service and inventoried components removed. Configuration, environment.json, logs, data, workspaces and all native/shared executor authentication are preserved. Repeated uninstall is safe.");
|
|
56
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
57
|
+
finally { await release?.(); unlockLauncher?.(); unlock?.(); }
|