@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 +4 -2
- package/bin/agent-task.mjs +7 -12
- package/deploy/agent-task.service.example +2 -2
- package/dist/acp.js +7 -6
- package/dist/admin.js +3 -3
- package/dist/config.js +15 -56
- package/dist/environment.js +13 -0
- package/dist/executor-runtime.js +37 -0
- package/dist/launcher.js +238 -0
- package/dist/lock.js +16 -0
- package/dist/main.js +74 -30
- package/dist/model.js +2 -5
- package/dist/node-execution.js +2 -3
- package/dist/permissions.js +3 -4
- package/dist/private-files.js +16 -2
- package/dist/results.js +9 -15
- package/dist/settings-contract.js +1 -1
- package/dist/settings.js +25 -8
- package/dist/startup.js +56 -0
- package/dist/tasks.js +5 -6
- package/dist/web/api.js +1 -1
- package/dist/web/app.js +7 -0
- package/dist/web/settings-view.js +33 -28
- package/dist/web.js +14 -2
- package/dist/worker.js +2 -2
- package/docs/architecture-guidelines.md +2 -2
- package/docs/deployment.md +40 -38
- package/docs/design-proposal.md +2 -2
- package/docs/release.md +8 -8
- package/docs/technical-design.md +3 -3
- package/docs/validation.md +17 -9
- package/docs/web-console.md +10 -16
- package/package.json +2 -2
- package/scripts/install-lib.mjs +17 -24
- package/scripts/probe-agent.mjs +8 -3
- package/scripts/service-runner.mjs +2 -7
- package/scripts/setup-wizard.mjs +2 -2
- package/scripts/setup.mjs +24 -20
- package/scripts/uninstall.mjs +13 -9
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 {
|
|
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 {
|
|
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" ||
|
|
20
|
-
console.error("
|
|
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 =
|
|
24
|
-
const base = baseEnvironment(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
33
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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),
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
process.
|
|
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
|
-
|
|
13
|
-
|
|
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;
|
package/dist/node-execution.js
CHANGED
|
@@ -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
|
|
4
|
+
export const createExecution = (environment) => (input, event, beforeStart) => {
|
|
6
5
|
const worker = fork(fileURLToPath(new URL("./worker.js", import.meta.url)), [], {
|
|
7
|
-
env:
|
|
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;
|
package/dist/permissions.js
CHANGED
|
@@ -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,
|
|
2
|
+
export function permissionDecision(request, cancelled = false) {
|
|
3
3
|
if (cancelled)
|
|
4
4
|
return { outcome: { outcome: "cancelled" } };
|
|
5
|
-
|
|
6
|
-
const
|
|
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
|
package/dist/private-files.js
CHANGED
|
@@ -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
|
|
5
|
-
export function readInputFile(
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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))
|
package/dist/startup.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, openSync, fsyncSync, closeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { readEnvironment, validateServiceEnvironment } from "./environment.js";
|
|
6
|
+
import { checkDataDirectory, readPrivateText, replacePrivateText } from "./private-files.js";
|
|
7
|
+
import { acquireLock } from "./lock.js";
|
|
8
|
+
/** A stable launch locator is the only runtime input outside the selected dataDir. */
|
|
9
|
+
export function startupPaths(home) {
|
|
10
|
+
const { dataDir } = z.object({ dataDir: z.string() }).strict().parse(JSON.parse(readPrivateText(join(home, "launch.json"))));
|
|
11
|
+
checkDataDirectory(dataDir);
|
|
12
|
+
return { dataDir, configPath: join(dataDir, "config.json"), environmentPath: join(dataDir, "environment.json") };
|
|
13
|
+
}
|
|
14
|
+
function prepareFile(path, text) {
|
|
15
|
+
if (existsSync(path)) {
|
|
16
|
+
if (readPrivateText(path) !== text)
|
|
17
|
+
throw new Error("Target contains different startup files; refusing to overwrite them");
|
|
18
|
+
return; // Explicitly resume an interrupted preparation, not a data repair.
|
|
19
|
+
}
|
|
20
|
+
const fd = openSync(path, "wx", 0o600);
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(fd, text);
|
|
23
|
+
fsyncSync(fd);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function prepareStartup(home) {
|
|
30
|
+
const current = startupPaths(home), config = loadConfig(current.configPath), environment = readEnvironment(current.environmentPath);
|
|
31
|
+
validateServiceEnvironment(config, environment);
|
|
32
|
+
checkDataDirectory(config.dataDir);
|
|
33
|
+
if (config.dataDir !== current.dataDir) {
|
|
34
|
+
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
35
|
+
checkDataDirectory(config.dataDir);
|
|
36
|
+
const release = await acquireLock(config.dataDir);
|
|
37
|
+
try {
|
|
38
|
+
// Only next-start inputs. Never copy tasks, sessions, results, native configuration or workspaces.
|
|
39
|
+
prepareFile(join(config.dataDir, "config.json"), JSON.stringify(config, null, 2) + "\n");
|
|
40
|
+
prepareFile(join(config.dataDir, "environment.json"), JSON.stringify(environment, null, 2) + "\n");
|
|
41
|
+
const fd = openSync(config.dataDir, "r");
|
|
42
|
+
try {
|
|
43
|
+
fsyncSync(fd);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
replacePrivateText(join(home, "launch.json"), JSON.stringify({ dataDir: config.dataDir }) + "\n");
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await release();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { ...startupPaths(home), config, environment };
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=startup.js.map
|
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 &&
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
["
|
|
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,
|
|
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.
|
|
21
|
-
card.append(approved.row, element("p", "
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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: "",
|
|
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: {
|
|
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", "
|
|
119
|
-
|
|
120
|
-
|
|
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
|
}
|