@realqiyan/agent-task 0.0.1-rc.1 → 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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  | 目的 | 入口 |
10
10
  | --- | --- |
11
11
  | 安装、认证、后台运行、卸载 | [部署说明](docs/deployment.md) |
12
- | 本机控制台、配置保存与手动重启 | [控制台说明](docs/web-console.md) |
12
+ | 本机控制台、配置保存与 Web/CLI 重启 | [控制台说明](docs/web-console.md) |
13
13
  | 理解产品目标和架构选择 | [总体方案](docs/design-proposal.md) |
14
14
  | 查找实现与对应测试 | [代码导航](docs/technical-design.md) |
15
15
  | 查询测试命令、真实验收及缺口 | [验证说明](docs/validation.md) |
@@ -20,7 +20,7 @@
20
20
 
21
21
  ## 使用入口
22
22
 
23
- Linux/macOS,Node.js **22.19+**。安装包自带运行产物,无需克隆或编译。发布目标为 `0.0.1-rc.1`,使用 `latest`;实际发布与平台验收状态见[发布证据](docs/validation.md#npm-发布候选验证)。
23
+ Linux/macOS,Node.js **22.19+**。安装包自带运行产物,无需克隆或编译。`latest` 指向的已发布版本及平台验收范围见[发布证据](docs/validation.md#npm-发布候选验证)。
24
24
 
25
25
  ```bash
26
26
  npm install -g @realqiyan/agent-task
@@ -28,6 +28,8 @@ agent-task setup
28
28
  agent-task start
29
29
  # 另一个终端进行带认证的健康检查:
30
30
  agent-task status
31
+ # 应用已保存配置并确认新实例可用:
32
+ agent-task restart
31
33
  ```
32
34
 
33
35
  npm 包名为 `@realqiyan/agent-task`,安装后的命令仍为 `agent-task`。setup 提供交互配置;启动后可使用本机 Web 控制台。先停止前台实例,再用 `agent-task service install` 安装并启动用户后台服务。安装器不会替用户登录或批准执行端;详细操作见[部署说明](docs/deployment.md)。
@@ -4,8 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  const help = `Usage: agent-task <command>
5
5
  setup [options] Interactive configuration (setup --help for flags)
6
6
  start [--home PATH] Foreground service using the private installation
7
- start --config PATH --environment PATH
8
- Foreground service with explicit private files
7
+ restart [--home PATH] Restart foreground or managed runtime; verify readiness
9
8
  status [--home PATH] Authenticated MCP health check; no token printed
10
9
  service install [--home PATH]
11
10
  Install/start systemd user service or LaunchAgent
@@ -26,14 +25,10 @@ try {
26
25
  } else if (command === "setup" || command === "uninstall") {
27
26
  process.argv = [process.execPath, command, ...args];
28
27
  await import(`../scripts/${command}.mjs`);
29
- } else if (command === "start" && args[0] === "--config") {
30
- if (args.length !== 4 || args[2] !== "--environment") throw new Error("Use start --config PATH --environment PATH");
31
- process.argv = [process.execPath, "agent-task", ...args];
32
- await import("../dist/main.js");
33
28
  } else {
34
29
  const lib = await import("../scripts/install-lib.mjs");
35
30
  const action = command === "service" ? args.shift() : undefined;
36
- if (!["start", "status", "service"].includes(command) || (command === "service" && !["install", "remove"].includes(action))) throw new Error(help);
31
+ if (!["start", "status", "restart", "service"].includes(command) || (command === "service" && !["install", "remove"].includes(action))) throw new Error(help);
37
32
  const options = lib.argumentsFor(args);
38
33
  if (Object.keys(options).some(key => key !== "home")) throw new Error("Only --home is accepted here");
39
34
  const root = lib.installRoot(options);
@@ -45,19 +40,19 @@ try {
45
40
  const unlock = lib.operationLock(root);
46
41
  try {
47
42
  const manifest = lib.readManifest(root);
48
- lib.deactivate(manifest);
43
+ await lib.deactivate(manifest);
49
44
  manifest.serviceInstalled = false;
50
45
  lib.saveManifest(root, manifest);
51
46
  console.log("Owned service removed; configuration, credentials, components and data preserved.");
52
47
  } finally { unlock(); }
53
48
  } else if (command === "start") {
49
+ if (lib.readManifest(root).serviceInstalled) throw new Error("Remove the managed service before starting a foreground instance");
54
50
  process.argv = [process.execPath, "service-runner", root];
55
51
  await import("../scripts/service-runner.mjs");
56
52
  } else {
57
- const manifest = lib.readManifest(root);
58
- if (manifest.phase !== "ready") throw new Error("Installation is not ready");
59
- await lib.checkHealth(lib.json(root + "/config.json"), lib.runtimeEnv(manifest));
60
- console.log("Authenticated MCP health check passed.");
53
+ const { controlService } = await import("../dist/launcher.js");
54
+ const ready = await controlService(root, command);
55
+ console.log(JSON.stringify({ event: command === "restart" ? "restarted" : "healthy", ...ready }));
61
56
  }
62
57
  }
63
58
  } catch (error) {
@@ -6,10 +6,10 @@ StartLimitBurst=3
6
6
  [Service]
7
7
  Type=simple
8
8
  WorkingDirectory=/absolute/path/to/agent-task
9
- ExecStart=/absolute/path/to/node /absolute/path/to/agent-task/dist/main.js --config %h/.config/agent-task/config.json --environment %h/.config/agent-task/environment.json
9
+ ExecStart=/absolute/path/to/node /absolute/path/to/agent-task/scripts/service-runner.mjs %h/.local/share/agent-task
10
10
  Restart=on-failure
11
11
  RestartSec=5
12
- TimeoutStopSec=15
12
+ TimeoutStopSec=45
13
13
  KillMode=control-group
14
14
  UMask=0077
15
15
 
package/dist/acp.js CHANGED
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { Readable, Writable } from "node:stream";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
4
  import * as acp from "@agentclientprotocol/sdk";
5
- import { executorEnv } from "./config.js";
5
+ import { executorCommand } from "./executor-runtime.js";
6
6
  import { readInputFile } from "./results.js";
7
7
  import { permissionDecision } from "./permissions.js";
8
8
  import { version } from "./version.js";
@@ -47,10 +47,11 @@ export async function terminateGroup(child) {
47
47
  child.stdout?.destroy();
48
48
  child.stderr?.destroy();
49
49
  }
50
- export async function openAcp(executor, signal, emit, inputFiles = []) {
50
+ export async function openAcp(executor, signal, emit, resultsRoot, inputFiles = []) {
51
51
  signal.throwIfAborted();
52
- const child = spawn(executor.command, executor.args, {
53
- cwd: executor.cwd, env: executorEnv(executor.envKeys), detached: true, stdio: ["pipe", "pipe", "pipe"],
52
+ const launch = executorCommand(executor.type, process.env);
53
+ const child = spawn(launch.command, launch.args, {
54
+ cwd: executor.cwd, env: process.env, detached: true, stdio: ["pipe", "pipe", "pipe"],
54
55
  });
55
56
  const controller = new AbortController();
56
57
  const cancel = AbortSignal.any([signal, controller.signal]);
@@ -102,7 +103,7 @@ export async function openAcp(executor, signal, emit, inputFiles = []) {
102
103
  cancel.throwIfAborted();
103
104
  if (ctx.params.sessionId !== sessionId)
104
105
  throw new Error("Wrong input-file session");
105
- return { content: readInputFile(executor, inputFiles, ctx.params.path, ctx.params.line ?? 1, ctx.params.limit ?? undefined) };
106
+ return { content: readInputFile(resultsRoot, inputFiles, ctx.params.path, ctx.params.line ?? 1, ctx.params.limit ?? undefined) };
106
107
  }
107
108
  catch (error) {
108
109
  controller.abort(error);
@@ -111,7 +112,7 @@ export async function openAcp(executor, signal, emit, inputFiles = []) {
111
112
  })
112
113
  .onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
113
114
  const request = ctx.params;
114
- const response = permissionDecision(request, executor.permissionAllowlist, cancel.aborted || request.sessionId !== sessionId);
115
+ const response = permissionDecision(request, cancel.aborted || request.sessionId !== sessionId);
115
116
  await report("permission", { tool: request.toolCall.name ?? null, outcome: response.outcome });
116
117
  return response;
117
118
  })
package/dist/admin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { dirname, resolve } from "node:path";
3
3
  import { loadConfig, validateConfig } from "./config.js";
4
4
  import { replacePrivateText } from "./private-files.js";
5
5
  import { executorSchema } from "./model.js";
@@ -10,10 +10,10 @@ if (!configPath || !["register", "list"].includes(action ?? "") || (action === "
10
10
  throw new Error("Usage: node dist/admin.js list CONFIG | register CONFIG PROFILE.json (service stopped)");
11
11
  }
12
12
  const path = resolve(configPath), config = loadConfig(path);
13
- const release = await acquireLock(config.dataDir);
13
+ const release = await acquireLock(dirname(path));
14
14
  try {
15
15
  if (action === "list") {
16
- console.log(JSON.stringify(config.executors.map(({ id, command, args, cwd, envKeys, permissionAllowlist, unattendedApproved, resultReadRoots }) => ({ id, transport: "stdio", command, args, cwd, envKeys, permissionAllowlist, unattendedApproved, resultReadRoots })), null, 2));
16
+ console.log(JSON.stringify(config.executors.map(executor => ({ ...executor, transport: "stdio" })), null, 2));
17
17
  }
18
18
  else {
19
19
  const profile = executorSchema.parse(JSON.parse(readFileSync(resolve(profilePath), "utf8")));
package/dist/config.js CHANGED
@@ -1,10 +1,11 @@
1
- import { readFileSync, realpathSync, statSync, accessSync, constants } from "node:fs";
2
- import { basename, delimiter, isAbsolute, join } from "node:path";
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { executorSchema } from "./model.js";
5
+ import { readPrivateText } from "./private-files.js";
5
6
  export const ADMIN_TOKEN_ENV = "AGENT_TASK_ADMIN_TOKEN";
6
7
  const envKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
7
- const baseEnvKeys = ["PATH", "LANG", "LC_ALL", "TZ"];
8
+ const baseEnvKeys = ["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "NODE_OPTIONS", "NODE_PATH", "__proto__", "constructor", "prototype"];
8
9
  const modelId = z.string().min(1).max(200);
9
10
  const coordinatorSchema = z.discriminatedUnion("provider", [
10
11
  z.object({ provider: z.enum(["openai", "anthropic", "deepseek"]), model: modelId }).strict(),
@@ -36,22 +37,22 @@ export function coordinatorCredentialEnv(config) {
36
37
  case "openai-compatible": return config.apiKeyEnv;
37
38
  }
38
39
  }
39
- export function loadConfig(path, environment = process.env) {
40
+ export function loadConfig(path) {
40
41
  let value;
41
42
  try {
42
- value = JSON.parse(readFileSync(path, "utf8"));
43
+ value = JSON.parse(readPrivateText(path));
43
44
  }
44
45
  catch {
45
46
  throw new Error("Cannot read configuration JSON");
46
47
  }
47
- return validateConfig(value, environment);
48
+ return validateConfig(value);
48
49
  }
49
- export function validateConfig(value, environment = process.env) {
50
+ export function validateConfig(value) {
50
51
  const config = configSchema.parse(value);
51
- if (!isAbsolute(config.dataDir))
52
- throw new Error("dataDir must be absolute");
52
+ if (!isAbsolute(config.dataDir) || /[\x00-\x1f\x7f]/.test(config.dataDir))
53
+ throw new Error("dataDir must be absolute without control characters");
53
54
  const coordinatorKey = config.coordinator ? coordinatorCredentialEnv(config.coordinator) : undefined;
54
- if ([...baseEnvKeys, ADMIN_TOKEN_ENV, "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY", coordinatorKey].includes(config.tokenEnv))
55
+ if (/^(NODE_|LD_|DYLD_)/.test(config.tokenEnv) || [...baseEnvKeys, ADMIN_TOKEN_ENV, "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY", coordinatorKey].includes(config.tokenEnv))
55
56
  throw new Error("tokenEnv must be separate from base environment, administrator and model credentials");
56
57
  if (coordinatorKey === ADMIN_TOKEN_ENV)
57
58
  throw new Error("Administrator token must not be used as a model credential");
@@ -60,54 +61,12 @@ export function validateConfig(value, environment = process.env) {
60
61
  if (ids.has(executor.id))
61
62
  throw new Error("Duplicate executor ID");
62
63
  ids.add(executor.id);
63
- if (!isAbsolute(executor.command) || !isAbsolute(executor.cwd))
64
- throw new Error("Executor command and cwd must be absolute");
65
- if (basename(executor.command) === "pi") {
66
- throw new Error(`Executor ${executor.id}: pi CLI is not an ACP server (including --mode rpc). Install pi-acp with npm install -g pi-acp@0.0.34 and configure its absolute executable path.`);
67
- }
68
- const piCommandOverride = executor.envKeys.includes("PI_ACP_PI_COMMAND") && environment.PI_ACP_PI_COMMAND;
69
- if (basename(executor.command) === "pi-acp" && !piCommandOverride && !(environment.PATH ?? "").split(delimiter).some(directory => {
70
- if (!isAbsolute(directory))
71
- return false;
72
- try {
73
- const file = join(directory, "pi");
74
- accessSync(file, constants.X_OK);
75
- return statSync(file).isFile();
76
- }
77
- catch {
78
- return false;
79
- }
80
- })) {
81
- throw new Error(`Executor ${executor.id}: pi-acp requires pi on the executor PATH. Install/configure pi before starting the service.`);
82
- }
83
- try {
84
- executor.command = realpathSync(executor.command);
85
- accessSync(executor.command, constants.X_OK);
86
- }
87
- catch {
88
- throw new Error(`Executor ${executor.id}: ACP executable is missing or not executable. Check the administrator-configured absolute command path. For Pi, install npm install -g pi-acp@0.0.34 and use command -v pi-acp to locate it; pi must also be on the executor PATH.`);
89
- }
64
+ if (!isAbsolute(executor.cwd))
65
+ throw new Error("Executor cwd must be absolute");
90
66
  executor.cwd = realpathSync(executor.cwd);
91
- if (!statSync(executor.command).isFile() || !statSync(executor.cwd).isDirectory())
92
- throw new Error("Invalid executor command or cwd");
93
- for (const root of executor.resultReadRoots) {
94
- if (!isAbsolute(root) || realpathSync(root) !== root || !statSync(root).isDirectory())
95
- throw new Error("Result read roots must be canonical absolute directories");
96
- }
97
- if (executor.envKeys.includes(config.tokenEnv) || executor.envKeys.includes(ADMIN_TOKEN_ENV))
98
- throw new Error("Service or administrator token must not be passed to executors");
99
- if (coordinatorKey && executor.envKeys.includes(coordinatorKey))
100
- throw new Error("Coordinator credential must not be passed to executors; use separate executor credentials");
67
+ if (!statSync(executor.cwd).isDirectory())
68
+ throw new Error("Invalid executor cwd");
101
69
  }
102
70
  return config;
103
71
  }
104
- /** No shell or wholesale inheritance; secrets are references to administrator-selected environment keys. */
105
- export function executorEnv(keys, source = process.env) {
106
- const env = {};
107
- for (const key of new Set(["PATH", "LANG", "LC_ALL", "TZ", ...keys])) {
108
- if (key !== ADMIN_TOKEN_ENV && source[key] !== undefined)
109
- env[key] = source[key];
110
- }
111
- return env;
112
- }
113
72
  //# sourceMappingURL=config.js.map
@@ -1,3 +1,4 @@
1
+ import { ADMIN_TOKEN_ENV, coordinatorCredentialEnv } from "./config.js";
1
2
  import { readPrivateText } from "./private-files.js";
2
3
  export function parseEnvironment(text) {
3
4
  let value;
@@ -15,6 +16,18 @@ export function parseEnvironment(text) {
15
16
  export function readEnvironment(path) {
16
17
  return parseEnvironment(readPrivateText(path));
17
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
+ }
18
31
  /** Explicit file mode never falls back to inherited shell credentials. */
19
32
  export function baseEnvironment(source) {
20
33
  return Object.fromEntries(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"]
@@ -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,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 CHANGED
@@ -1,6 +1,22 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { mkdirSync } from "node:fs";
3
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
+ }
4
20
  /** A dedicated SQLite transaction owns an OS lock until close or process death.
5
21
  * Local filesystems only. Never unlink this file: that would create a second lock identity.
6
22
  */