@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/dist/main.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import express from "express";
2
2
  import { resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
- import { ADMIN_TOKEN_ENV, loadConfig } from "./config.js";
5
- import { baseEnvironment, readEnvironment } from "./environment.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { baseEnvironment, readEnvironment, validateServiceEnvironment } from "./environment.js";
6
7
  import { bearerVerifier } from "./http-auth.js";
7
8
  import { Settings } from "./settings.js";
8
9
  import { createWebRouter } from "./web.js";
@@ -12,36 +13,31 @@ import { errorText } from "./model.js";
12
13
  import { TaskStore } from "./task-storage.js";
13
14
  import { Tasks } from "./tasks.js";
14
15
  import { Results } from "./results.js";
15
- import { openExecution } from "./node-execution.js";
16
+ import { createExecution } from "./node-execution.js";
17
+ import { executorEnvironment } from "./executor-runtime.js";
16
18
  import { createCoordinator } from "./coordinator.js";
17
19
  process.umask(0o077);
18
20
  const args = process.argv.slice(2);
19
- if (args[0] !== "--config" || (args.length !== 2 && !(args.length === 4 && args[2] === "--environment"))) {
20
- console.error("Usage: node dist/main.js --config /absolute/path/config.json [--environment /absolute/private/path/environment.json]");
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");
21
23
  process.exit(2);
22
24
  }
23
- const configPath = resolve(args[1]), environmentPath = args[3] ? resolve(args[3]) : undefined;
24
- const base = baseEnvironment(process.env);
25
- if (environmentPath) {
26
- const environment = readEnvironment(environmentPath);
27
- for (const key of Object.keys(process.env))
28
- delete process.env[key];
29
- Object.assign(process.env, base, environment);
30
- }
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);
31
32
  const config = loadConfig(configPath);
32
- const token = process.env[config.tokenEnv];
33
- if (!token || token.length < 32)
34
- throw new Error(config.tokenEnv + " must contain a token of at least 32 characters");
35
- const authorized = bearerVerifier(token), adminToken = process.env[ADMIN_TOKEN_ENV];
36
- if (adminToken !== undefined && (!environmentPath || !/^[!-~]{32,256}$/.test(adminToken) || adminToken === token)) {
37
- throw new Error("Console requires --environment and a distinct administrator token of 32–256 visible ASCII characters");
38
- }
33
+ const { token, adminToken } = validateServiceEnvironment(config, environment);
34
+ const authorized = bearerVerifier(token);
39
35
  let http;
40
36
  let tasks, taskStore, settings;
41
37
  let release;
42
38
  let shutting;
43
39
  const connections = new Set();
44
- function shutdown(code) {
40
+ function shutdown(code, drain = false) {
45
41
  process.exitCode = Math.max(process.exitCode ? Number(process.exitCode) : 0, code);
46
42
  return shutting ??= (async () => {
47
43
  settings?.close();
@@ -55,18 +51,61 @@ function shutdown(code) {
55
51
  for (const close of connections)
56
52
  await close().catch(() => { });
57
53
  if (http) {
58
- http.closeAllConnections();
59
- await new Promise(done => http.close(() => done()));
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
60
  }
61
61
  taskStore?.close();
62
62
  await release?.();
63
63
  })().catch(error => { console.error(errorText(error)); process.exitCode = 1; });
64
64
  }
65
- const fatal = (error) => { console.error(errorText(error)); void shutdown(1); };
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);
66
105
  try {
67
106
  release = await acquireLock(config.dataDir);
68
107
  taskStore = new TaskStore(config.dataDir);
69
- tasks = new Tasks(taskStore, config.executors, new Results(config.dataDir), openExecution, config.coordinator ? createCoordinator(config.dataDir, config.coordinator) : undefined, fatal);
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);
70
109
  const app = express();
71
110
  app.disable("x-powered-by");
72
111
  app.disable("etag");
@@ -77,9 +116,9 @@ try {
77
116
  }
78
117
  next();
79
118
  });
80
- if (adminToken && environmentPath) {
119
+ if (adminToken) {
81
120
  settings = new Settings(configPath, environmentPath, config, base, process.env);
82
- app.use("/admin", createWebRouter(tasks, settings, adminToken, fatal));
121
+ app.use("/admin", createWebRouter(tasks, settings, adminToken, fatal, restart, instanceId));
83
122
  }
84
123
  app.use("/mcp", (req, res, next) => {
85
124
  const suffix = req.socket.localPort === 80 ? "" : ":" + req.socket.localPort;
@@ -118,12 +157,17 @@ try {
118
157
  const listening = app.listen(config.port, "127.0.0.1", () => done(listening));
119
158
  listening.once("error", reject);
120
159
  });
160
+ if (shutting) {
161
+ http.closeAllConnections();
162
+ http.close();
163
+ throw new Error("Startup stopped");
164
+ }
121
165
  tasks.start();
122
166
  const address = http.address();
123
167
  const origin = "http://127.0.0.1:" + (typeof address === "object" && address ? address.port : config.port);
124
- console.log(JSON.stringify({ event: "ready", url: origin + "/mcp", consoleUrl: settings ? origin + "/admin/" : null }));
125
- process.once("SIGINT", () => { void shutdown(0); });
126
- process.once("SIGTERM", () => { void shutdown(0); });
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 });
127
171
  }
128
172
  catch (error) {
129
173
  fatal(error);
package/dist/model.js CHANGED
@@ -9,13 +9,10 @@ export const scheduleSchema = z.discriminatedUnion("type", [
9
9
  ]);
10
10
  export const executorSchema = z.object({
11
11
  id: identifier,
12
- command: z.string().min(1),
13
- args: z.array(z.string().max(4096)).max(100).default([]),
12
+ type: z.enum(["pi", "codex", "claude", "opencode"]),
13
+ note: z.string().max(2000).default(""),
14
14
  cwd: z.string().min(1),
15
- envKeys: z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)).max(50).default([]),
16
- permissionAllowlist: z.array(z.string().min(1).max(200)).max(100).default([]),
17
15
  unattendedApproved: z.boolean().default(false),
18
- resultReadRoots: z.array(z.string().min(1)).max(50).default([]),
19
16
  }).strict();
20
17
  export const OUTPUT_LIMIT = 262_144;
21
18
  export const EVENT_LIMIT = 2000;
@@ -1,10 +1,9 @@
1
1
  import { fork } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
- import { executorEnv } from "./config.js";
4
3
  /** One supervised worker and ACP session per node, retained across turns. */
5
- export const openExecution = (input, event, beforeStart) => {
4
+ export const createExecution = (environment) => (input, event, beforeStart) => {
6
5
  const worker = fork(fileURLToPath(new URL("./worker.js", import.meta.url)), [], {
7
- env: executorEnv(input.executor.envKeys), stdio: ["ignore", "ignore", "pipe", "ipc"], execArgv: [],
6
+ env: { ...environment }, stdio: ["ignore", "ignore", "pipe", "ipc"], execArgv: [],
8
7
  });
9
8
  worker.stderr?.resume();
10
9
  let stopped = false, exited = false, busy = false;
@@ -1,10 +1,9 @@
1
1
  /** Applies only to ACP requests that actually ask for approval; this is not a sandbox. */
2
- export function permissionDecision(request, allowlist, cancelled = false) {
2
+ export function permissionDecision(request, cancelled = false) {
3
3
  if (cancelled)
4
4
  return { outcome: { outcome: "cancelled" } };
5
- const name = request.toolCall.name;
6
- const allowed = typeof name === "string" && allowlist.includes(name) && request.toolCall.rawInput != null;
7
- const option = request.options.find(value => value.kind === (allowed ? "allow_once" : "reject_once"));
5
+ // Native authorization must suffice. A new ACP approval would widen it.
6
+ const option = request.options.find(value => value.kind === "reject_once");
8
7
  return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
9
8
  }
10
9
  //# sourceMappingURL=permissions.js.map
@@ -1,6 +1,20 @@
1
- import { constants, openSync, closeSync, fstatSync, lstatSync, realpathSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync } from "node:fs";
2
- import { dirname, basename, join } from "node:path";
1
+ import { existsSync, constants, openSync, closeSync, fstatSync, lstatSync, realpathSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync } from "node:fs";
2
+ import { dirname, basename, join, isAbsolute, resolve } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ export function checkDataDirectory(path) {
5
+ if (!isAbsolute(path) || resolve(path) !== path || /[\x00-\x1f\x7f]/.test(path))
6
+ throw new Error("Expected a canonical absolute dataDir");
7
+ let parent = path;
8
+ while (!existsSync(parent))
9
+ parent = dirname(parent);
10
+ if (realpathSync(parent) !== parent || !lstatSync(parent).isDirectory())
11
+ throw new Error("dataDir must not traverse symlinks");
12
+ if (parent === path) {
13
+ const stat = lstatSync(path);
14
+ if (stat.uid !== process.getuid() || (stat.mode & 0o077))
15
+ throw new Error("dataDir must be owned and private");
16
+ }
17
+ }
4
18
  function privateDirectory(path) {
5
19
  const directory = dirname(path), stat = lstatSync(directory);
6
20
  if (realpathSync(directory) !== directory || !stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077)) {
package/dist/results.js CHANGED
@@ -1,15 +1,13 @@
1
1
  import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, fstatSync, fsyncSync, realpathSync, statSync, accessSync, constants } from "node:fs";
2
2
  import { join, relative, isAbsolute } from "node:path";
3
3
  import { OUTPUT_LIMIT } from "./model.js";
4
- /** Exact per-run file allowlist intersected with administrator-granted roots. Never offers general filesystem access. */
5
- export function readInputFile(executor, allowed, path, line = 1, limit) {
4
+ /** Exact upstream files within dataDir/results, never a general dataDir read capability. */
5
+ export function readInputFile(root, allowed, path, line = 1, limit) {
6
6
  if (!allowed.includes(path) || realpathSync(path) !== path)
7
7
  throw new Error("Input file is not authorized for this run");
8
- if (!executor.resultReadRoots.some(root => {
9
- const rel = relative(realpathSync(root), path);
10
- return !rel.startsWith("..") && !isAbsolute(rel);
11
- }))
12
- throw new Error("Input file outside authorized read scope");
8
+ const rel = relative(root, path);
9
+ if (realpathSync(root) !== root || !rel || rel.startsWith("..") || isAbsolute(rel))
10
+ throw new Error("Input file outside result scope");
13
11
  if (!Number.isInteger(line) || line < 1 || (limit !== undefined && (!Number.isInteger(limit) || limit < 1)))
14
12
  throw new Error("Invalid line range");
15
13
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -32,19 +30,15 @@ export class Results {
32
30
  root;
33
31
  constructor(directory) {
34
32
  mkdirSync(join(directory, "results"), { recursive: true, mode: 0o700 });
35
- this.root = realpathSync(join(directory, "results"));
33
+ this.root = join(directory, "results");
34
+ if (realpathSync(this.root) !== this.root)
35
+ throw new Error("Result root must not be a symlink");
36
36
  }
37
37
  inside(root, path) {
38
38
  const rel = relative(root, path);
39
39
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
40
40
  }
41
- authorize(executor) {
42
- if (!executor.resultReadRoots.some(root => this.inside(realpathSync(root), this.root))) {
43
- throw new Error("Executor " + executor.id + " lacks administrator-approved result read scope");
44
- }
45
- }
46
- readable(path, executor) {
47
- this.authorize(executor);
41
+ readable(path) {
48
42
  if (realpathSync(path) !== path || !this.inside(this.root, path) || !statSync(path).isFile())
49
43
  throw new Error("Invalid result file");
50
44
  accessSync(path, constants.R_OK);
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { configSchema } from "./config.js";
3
3
  export const settingsConfigSchema = z.object({
4
4
  editToken: z.string().uuid(),
5
- config: configSchema.pick({ executors: true, coordinator: true }),
5
+ config: configSchema,
6
6
  }).strict();
7
7
  const credential = {
8
8
  editToken: z.string().uuid(),
package/dist/settings.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { ADMIN_TOKEN_ENV, configSchema, coordinatorCredentialEnv, validateConfig } from "./config.js";
4
- import { parseEnvironment } from "./environment.js";
5
- import { FileCommitError, readPrivateText, replacePrivateText } from "./private-files.js";
4
+ import { parseEnvironment, validateServiceEnvironment } from "./environment.js";
5
+ import { checkDataDirectory, FileCommitError, readPrivateText, replacePrivateText } from "./private-files.js";
6
6
  import { credentialUpdateSchema, settingsConfigSchema } from "./settings-contract.js";
7
7
  export class SettingsError extends Error {
8
8
  code;
@@ -24,8 +24,7 @@ function parse(schema, value) {
24
24
  return result.data;
25
25
  }
26
26
  function referenced(config) {
27
- return new Set([...config.executors.flatMap(executor => executor.envKeys),
28
- ...(config.coordinator ? [coordinatorCredentialEnv(config.coordinator)] : [])]);
27
+ return new Set(config.coordinator ? [coordinatorCredentialEnv(config.coordinator)] : []);
29
28
  }
30
29
  function editableKey(key, config) {
31
30
  return !protectedKeys.has(key) && key !== config.tokenEnv && !/^(NODE_|LD_|DYLD_)/.test(key) &&
@@ -80,7 +79,7 @@ export class Settings {
80
79
  const editToken = randomUUID();
81
80
  this.edits.set(editToken, { configText: snapshot.configText, environmentText: snapshot.environmentText, expires: now + 15 * 60_000 });
82
81
  const keys = new Set([...modelKeys, ...Object.keys(snapshot.environment), ...referenced(snapshot.config), ...referenced(this.running)]);
83
- const credentials = [...keys].filter(key => editableKey(key, snapshot.config)).sort().map(key => ({
82
+ const credentials = [...keys].filter(key => editableKey(key, snapshot.config) && editableKey(key, this.running) && ![snapshot.environment[snapshot.config.tokenEnv], snapshot.environment[ADMIN_TOKEN_ENV]].includes(snapshot.environment[key])).sort().map(key => ({
84
83
  key, saved: Boolean(snapshot.environment[key]), running: Boolean(this.runningEnvironment[key]),
85
84
  pendingRestart: snapshot.environment[key] !== this.runningEnvironment[key],
86
85
  referenced: referenced(snapshot.config).has(key),
@@ -102,12 +101,13 @@ export class Settings {
102
101
  const environment = { ...this.base, ...snapshot.environment };
103
102
  let config;
104
103
  try {
105
- config = validateConfig({ ...snapshot.config, ...input.config, coordinator: input.config.coordinator }, environment);
104
+ config = validateConfig(input.config);
105
+ checkDataDirectory(config.dataDir);
106
106
  }
107
107
  catch (error) {
108
108
  if (error instanceof z.ZodError)
109
109
  throw new SettingsError("invalid", "Invalid configuration fields");
110
- throw new SettingsError("invalid", "Local validation failed: check executable paths, dependencies, workspaces, read roots and credential isolation");
110
+ throw new SettingsError("invalid", "Local validation failed: check data directory, workspaces and credential isolation");
111
111
  }
112
112
  const previous = snapshot.config.coordinator, next = config.coordinator;
113
113
  const endpoint = (model) => model.provider === "openai-compatible" ? model.baseUrl : model.provider;
@@ -118,17 +118,34 @@ export class Settings {
118
118
  if (!environment[key])
119
119
  throw new SettingsError("invalid", "A referenced environment value is missing; prepare credentials or native configuration first");
120
120
  }
121
+ const token = environment[config.tokenEnv] ?? environment[snapshot.config.tokenEnv];
122
+ try {
123
+ validateServiceEnvironment(config, { ...environment, [config.tokenEnv]: token });
124
+ }
125
+ catch {
126
+ throw new SettingsError("invalid", "Service, administrator and model credentials must be valid and distinct");
127
+ }
128
+ if (config.tokenEnv !== snapshot.config.tokenEnv && environment[config.tokenEnv] && environment[config.tokenEnv] !== environment[snapshot.config.tokenEnv]) {
129
+ throw new SettingsError("invalid", "tokenEnv already contains another value; use a new environment name");
130
+ }
121
131
  return config;
122
132
  }
123
133
  validate(value) { return this.candidate(value); }
124
134
  saveConfig(value) {
125
135
  const config = this.candidate(value);
136
+ const snapshot = this.editing(parse(settingsConfigSchema, value).editToken);
137
+ if (config.tokenEnv !== snapshot.config.tokenEnv) {
138
+ snapshot.environment[config.tokenEnv] = snapshot.environment[snapshot.config.tokenEnv];
139
+ // Publish an alias first. Either configuration still uses the same service token.
140
+ this.publish(this.environmentPath, snapshot.environment);
141
+ }
126
142
  this.publish(this.configPath, config);
127
143
  return this.get();
128
144
  }
129
145
  saveCredential(value) {
130
146
  const input = parse(credentialUpdateSchema, value), snapshot = this.editing(input.editToken);
131
- if (!editableKey(input.key, snapshot.config))
147
+ if (!editableKey(input.key, snapshot.config) || !editableKey(input.key, this.running) ||
148
+ [snapshot.environment[snapshot.config.tokenEnv], snapshot.environment[ADMIN_TOKEN_ENV]].includes(snapshot.environment[input.key]))
132
149
  throw new SettingsError("invalid", "This environment key is not console-managed; new credential names must end in _KEY, _TOKEN, _SECRET or _PASSWORD");
133
150
  if (input.action === "delete") {
134
151
  if (referenced(snapshot.config).has(input.key))
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, writeFileSync, openSync, fsyncSync, closeSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { loadConfig } from "./config.js";
5
+ import { readEnvironment, validateServiceEnvironment } from "./environment.js";
6
+ import { checkDataDirectory, readPrivateText, replacePrivateText } from "./private-files.js";
7
+ import { acquireLock } from "./lock.js";
8
+ /** A stable launch locator is the only runtime input outside the selected dataDir. */
9
+ export function startupPaths(home) {
10
+ const { dataDir } = z.object({ dataDir: z.string() }).strict().parse(JSON.parse(readPrivateText(join(home, "launch.json"))));
11
+ checkDataDirectory(dataDir);
12
+ return { dataDir, configPath: join(dataDir, "config.json"), environmentPath: join(dataDir, "environment.json") };
13
+ }
14
+ function prepareFile(path, text) {
15
+ if (existsSync(path)) {
16
+ if (readPrivateText(path) !== text)
17
+ throw new Error("Target contains different startup files; refusing to overwrite them");
18
+ return; // Explicitly resume an interrupted preparation, not a data repair.
19
+ }
20
+ const fd = openSync(path, "wx", 0o600);
21
+ try {
22
+ writeFileSync(fd, text);
23
+ fsyncSync(fd);
24
+ }
25
+ finally {
26
+ closeSync(fd);
27
+ }
28
+ }
29
+ export async function prepareStartup(home) {
30
+ const current = startupPaths(home), config = loadConfig(current.configPath), environment = readEnvironment(current.environmentPath);
31
+ validateServiceEnvironment(config, environment);
32
+ checkDataDirectory(config.dataDir);
33
+ if (config.dataDir !== current.dataDir) {
34
+ mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
35
+ checkDataDirectory(config.dataDir);
36
+ const release = await acquireLock(config.dataDir);
37
+ try {
38
+ // Only next-start inputs. Never copy tasks, sessions, results, native configuration or workspaces.
39
+ prepareFile(join(config.dataDir, "config.json"), JSON.stringify(config, null, 2) + "\n");
40
+ prepareFile(join(config.dataDir, "environment.json"), JSON.stringify(environment, null, 2) + "\n");
41
+ const fd = openSync(config.dataDir, "r");
42
+ try {
43
+ fsyncSync(fd);
44
+ }
45
+ finally {
46
+ closeSync(fd);
47
+ }
48
+ replacePrivateText(join(home, "launch.json"), JSON.stringify({ dataDir: config.dataDir }) + "\n");
49
+ }
50
+ finally {
51
+ await release();
52
+ }
53
+ }
54
+ return { ...startupPaths(home), config, environment };
55
+ }
56
+ //# sourceMappingURL=startup.js.map
package/dist/tasks.js CHANGED
@@ -28,7 +28,8 @@ export function validateGraph(nodes) {
28
28
  nodes.forEach(node => visit(node.id));
29
29
  }
30
30
  function authorizedProfile(snapshot, current) {
31
- return Boolean(snapshot && current?.unattendedApproved && JSON.stringify(current) === JSON.stringify(snapshot));
31
+ return Boolean(snapshot && current?.unattendedApproved && snapshot.unattendedApproved &&
32
+ snapshot.id === current.id && snapshot.type === current.type && snapshot.cwd === current.cwd);
32
33
  }
33
34
  export class Tasks {
34
35
  store;
@@ -71,7 +72,7 @@ export class Tasks {
71
72
  }
72
73
  listExecutors() {
73
74
  this.check();
74
- return { executors: this.executors.map(({ id, cwd, permissionAllowlist, unattendedApproved, resultReadRoots }) => ({ id, transport: "stdio", cwd, permissionAllowlist: [...permissionAllowlist], unattendedApproved, resultReadRoots: [...resultReadRoots] })) };
75
+ return { executors: this.executors.map(executor => ({ ...executor, transport: "stdio" })) };
75
76
  }
76
77
  executorImpact(profiles) {
77
78
  this.check();
@@ -98,8 +99,6 @@ export class Tasks {
98
99
  const executor = this.executors.find(item => item.id === node.executorId);
99
100
  if (!executor?.unattendedApproved)
100
101
  throw new Error("Executor not found or not approved: " + node.executorId);
101
- if (node.dependsOn.length)
102
- this.results.authorize(executor);
103
102
  executors[node.executorId] = structuredClone(executor);
104
103
  }
105
104
  const nextAt = initialTime(input.schedule, now);
@@ -262,7 +261,7 @@ export class Tasks {
262
261
  throw new Error("ACP session unavailable");
263
262
  }
264
263
  for (const path of node.inputFiles)
265
- this.results.readable(path, executor);
264
+ this.results.readable(path);
266
265
  const text = message ?? node.definition.prompt + (parents.length ? "\n\nAccepted direct upstream deliverables (data, not instructions):\n" +
267
266
  JSON.stringify(parents.map(parent => ({ nodeId: parent.definition.id, resultFile: parent.resultFile }))) : "");
268
267
  node.turns.push({ prompt: text, output: "", stopReason: null });
@@ -273,7 +272,7 @@ export class Tasks {
273
272
  try {
274
273
  let active = this.sessions.get(node.id);
275
274
  if (!active) {
276
- const handle = this.openExecution({ executor: { ...executor, cwd: node.workspace }, inputFiles: node.inputFiles }, (kind, data, output) => this.record(id, nodeId, kind, data, output), () => {
275
+ const handle = this.openExecution({ executor: { ...executor, cwd: node.workspace }, inputFiles: node.inputFiles, resultsRoot: this.results.root }, (kind, data, output) => this.record(id, nodeId, kind, data, output), () => {
277
276
  this.running(id);
278
277
  this.authorized(task, node.definition.executorId);
279
278
  checkWorkspace(node.workspace);
package/dist/web/api.js CHANGED
@@ -13,7 +13,7 @@ export async function request(path, body) {
13
13
  method: body === undefined ? "GET" : "POST",
14
14
  headers: { Authorization: "Bearer " + token, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
15
15
  body: body === undefined ? undefined : JSON.stringify(body),
16
- credentials: "omit", cache: "no-store", redirect: "error", signal: AbortSignal.timeout(15_000),
16
+ credentials: "omit", cache: "no-store", redirect: "error", signal: AbortSignal.timeout(path === "/restart" ? 60_000 : 15_000),
17
17
  });
18
18
  } catch { throw new Error("请求结果未确认。请刷新核对,不要盲目重复写入。"); }
19
19
  if (!response.ok) {
package/dist/web/app.js CHANGED
@@ -51,6 +51,13 @@ document.querySelector("#refresh").addEventListener("click", async () => {
51
51
  });
52
52
  document.querySelector("#logout").addEventListener("click", () => { logout(); notice("已退出;刷新后也须重新输入管理员令牌。"); });
53
53
  document.addEventListener("unauthorized", logout);
54
+ document.addEventListener("service-restarted", event => {
55
+ logout();
56
+ notice("新实例已通过认证健康检查。请在新地址重新连接;令牌没有放入链接。");
57
+ const url = new URL(event.detail.consoleUrl);
58
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1") return;
59
+ const link = element("a", url.href); link.href = url.href; main.append(link);
60
+ });
54
61
  window.addEventListener("pagehide", logout);
55
62
  setInterval(async () => {
56
63
  if (!connected || document.hidden || loading) return;
@@ -1,34 +1,27 @@
1
1
  import { request } from "./api.js";
2
2
  import { element, field, select, button, submit, block, table, notice } from "./dom.js";
3
3
 
4
- const lines = input => input.value.split("\n").map(value => value.trim()).filter(Boolean);
5
4
  function executorEditor(host, executor) {
6
5
  const card = element("fieldset"), fields = {};
7
6
  card.append(element("legend", "ACP 执行端"));
8
- for (const [key, label, type, value] of [
7
+ const type = select("类型", ["pi", "codex", "claude", "opencode"].map(value => [value, value]), executor.type ?? "pi");
8
+ fields.type = type.input; card.append(type.row);
9
+ for (const [key, label, kind, value] of [
9
10
  ["id", "注册 ID", "text", executor.id],
10
- ["command", "ACP 可执行文件(绝对路径,不含密钥)", "text", executor.command],
11
+ ["note", "备注", "textarea", executor.note],
11
12
  ["cwd", "默认工作空间(绝对路径)", "text", executor.cwd],
12
- ["args", "参数(JSON 字符串数组;不得包含密钥)", "textarea", JSON.stringify(executor.args ?? [])],
13
- ["envKeys", "传入执行端的环境变量名(每行一个,不填值)", "textarea", (executor.envKeys ?? []).join("\n")],
14
- ["permissionAllowlist", "ACP 审批工具名白名单(每行一个)", "textarea", (executor.permissionAllowlist ?? []).join("\n")],
15
- ["resultReadRoots", "结果读取目录(规范绝对路径,每行一个)", "textarea", (executor.resultReadRoots ?? []).join("\n")],
16
13
  ]) {
17
- const item = field(label, value, type); fields[key] = item.input; card.append(item.row);
14
+ const item = field(label, value, kind); fields[key] = item.input; card.append(item.row);
18
15
  }
19
16
  const approved = field("已由管理员审核原生授权,允许无人值守执行", executor.unattendedApproved, "checkbox");
20
- for (const input of Object.values(fields)) input.addEventListener("input", () => { approved.input.checked = false; });
21
- card.append(approved.row, element("p", "修改注册信息后须重新明确批准。白名单只约束实际发出的 ACP 审批请求,不是沙箱,也不是完整工具授权。"),
17
+ for (const [key, input] of Object.entries(fields)) if (key !== "note") input.addEventListener("input", () => { approved.input.checked = false; });
18
+ card.append(approved.row, element("p", "按类型从用户原生 PATH 查找 ACP 适配器。继承用户原生环境,但过滤服务私密变量;额外 ACP 审批一律拒绝。结果固定保存在 dataDir/results,只向节点提供指定上游文件,不提供整个数据目录读取。"),
22
19
  button("从待保存配置中移除", () => { card.remove(); host.dispatchEvent(new Event("input", { bubbles: true })); }));
23
20
  host.append(card);
24
- return () => {
25
- if (card.parentNode !== host) return [];
26
- let args;
27
- try { args = JSON.parse(fields.args.value); } catch { throw new Error("执行端参数必须是 JSON 字符串数组。"); }
28
- return [{ id: fields.id.value, command: fields.command.value, cwd: fields.cwd.value, args,
29
- envKeys: lines(fields.envKeys), permissionAllowlist: lines(fields.permissionAllowlist),
30
- resultReadRoots: lines(fields.resultReadRoots), unattendedApproved: approved.input.checked }];
31
- };
21
+ return () => card.parentNode === host ? [{
22
+ id: fields.id.value, type: fields.type.value, note: fields.note.value,
23
+ cwd: fields.cwd.value, unattendedApproved: approved.input.checked,
24
+ }] : [];
32
25
  }
33
26
  function coordinatorEditor(host, config) {
34
27
  const provider = select("协调模型提供方", [["", "未配置"], ["openai", "OpenAI"], ["anthropic", "Anthropic"],
@@ -62,20 +55,26 @@ export async function renderSettings(root, navigate) {
62
55
  let state = await request("/settings");
63
56
  root.append(element("h2", "配置与凭据"), element("p", "配置和密钥分别保存。保存不修改当前进程;每次重新启动(包括故障后拉起)读取已保存值。"),
64
57
  element("p", "先保存新密钥,再保存引用它的配置。执行端原生登录和原生权限仍通过官方工具管理;这里不编辑其认证文件。"),
65
- block("当前运行配置(只读,无密钥值)", state.running),
66
- block("启动级配置(只读)", { dataDir: state.saved.dataDir, port: state.saved.port, tokenEnv: state.saved.tokenEnv }));
58
+ block("当前运行配置(只读,无密钥值)", state.running));
67
59
  if (state.writeBlocked) root.append(element("p", "上次文件提交结果不确定。请停服核查;当前禁止继续保存。"));
68
60
  const form = element("form"), modelHost = element("fieldset"), executors = element("div"), readers = [];
69
61
  modelHost.append(element("legend", "协调模型"));
70
62
  const readModel = coordinatorEditor(modelHost, state.saved.coordinator);
71
- form.append(modelHost, element("h3", "执行端注册与权限"), executors);
63
+ const startup = {
64
+ dataDir: field("下次启动的数据目录(规范绝对路径;不搬迁或删除旧历史)", state.saved.dataDir),
65
+ port: field("下次启动的监听端口(0 为自动分配)", state.saved.port, "number"),
66
+ tokenEnv: field("MCP 令牌环境变量名(改名保留令牌值)", state.saved.tokenEnv),
67
+ };
68
+ form.append(element("h3", "启动配置"), ...Object.values(startup).map(item => item.row),
69
+ modelHost, element("h3", "执行端注册与权限"), executors);
72
70
  for (const executor of state.saved.executors) readers.push(executorEditor(executors, executor));
73
71
  form.append(button("添加执行端", () => {
74
- readers.push(executorEditor(executors, { id: "", command: "", cwd: "", unattendedApproved: false }));
72
+ readers.push(executorEditor(executors, { id: "", type: "pi", note: "", cwd: "", unattendedApproved: false }));
75
73
  }, state.writeBlocked));
76
74
  const preview = element("div");
77
75
  const save = submit(form, "校验并预览配置变更", async () => {
78
- const input = { editToken: state.editToken, config: { coordinator: readModel(), executors: readers.flatMap(read => read()) } };
76
+ const input = { editToken: state.editToken, config: { dataDir: startup.dataDir.input.value, port: startup.port.input.valueAsNumber, tokenEnv: startup.tokenEnv.input.value,
77
+ coordinator: readModel(), executors: readers.flatMap(read => read()) } };
79
78
  const checked = await request("/settings/validate", input);
80
79
  preview.replaceChildren(block("校验后的待保存配置", checked.config),
81
80
  block("重启后受执行端变更影响的任务", checked.affectedTasks));
@@ -83,7 +82,7 @@ export async function renderSettings(root, navigate) {
83
82
  if (!confirm("本地校验通过,不代表模型认证或 ACP 执行已验证。\n受影响任务:\n" + affected +
84
83
  "\n\n旧任务快照不会被重写,派发可能被拒绝。确认保存配置?保存后仍须手动重启。")) return;
85
84
  await request("/settings/config", input);
86
- notice("配置已保存,未热更新。请检查活动运行后在终端手动重启。");
85
+ notice("配置已保存,未热更新。检查活动运行后可在本页或 CLI 重启。");
87
86
  navigate({ kind: "settings" }, true);
88
87
  });
89
88
  save.disabled = state.writeBlocked; form.append(save, preview); root.append(form);
@@ -114,8 +113,14 @@ export async function renderSettings(root, navigate) {
114
113
  });
115
114
  replace.disabled = state.writeBlocked;
116
115
  credentialForm.append(replace);
117
- root.append(statuses, credentialForm, element("h3", "手动重启"),
118
- element("p", "先检查活动运行及待回答运行;待回答运行也可能持有 ACP 会话。必要时暂停相关未来触发,并等待活动工作结束。重启可能中断会话,不会自动重放不确定执行。"),
119
- block("Linux 用户服务", "systemctl --user restart agent-task.service"),
120
- element("p", "macOS 按部署说明 bootout 后用 setup 重新加载 LaunchAgent。前台服务停止后用原启动参数重新启动。启动失败时从终端修正私有文件,不删除历史或工作空间。"));
116
+ root.append(statuses, credentialForm, element("h3", "重启服务"),
117
+ element("p", "重启只应用已保存配置,未保存草稿会丢失。活动执行及待回答运行可能持有 ACP 会话;重启会关闭会话,不自动重试或重放不确定执行。"),
118
+ button("重启并检查可用性", async () => {
119
+ if (!confirm("确认使用已保存配置重启?活动工作可能中断,旧数据不会迁移。")) return;
120
+ notice("正在关闭旧运行并启动新实例,请勿重复操作……");
121
+ const ready = await request("/restart", {});
122
+ document.dispatchEvent(new CustomEvent("service-restarted", { detail: ready }));
123
+ }, state.writeBlocked),
124
+ block("前台或 systemd / launchd 托管实例", "agent-task restart [--home /absolute/installation]"),
125
+ element("p", "只有新实例通过认证健康检查才显示成功。若请求结果不确定,请先运行 agent-task status;启动失败时检查当前 dataDir/service.log,不删除历史或重放任务来修复。"));
121
126
  }