@zq-silk/yui 2.8.1 → 2.10.0
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 +5 -3
- package/dist/cli/commandCatalog.js +56 -21
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionPolicy.js +2 -2
- package/dist/commands/durableJobCommands.js +50 -0
- package/dist/commands/globalRoleCommands.js +30 -9
- package/dist/commands/inboxCommands.js +79 -22
- package/dist/commands/projectCommands.js +4 -33
- package/dist/commands/scheduleCommands.js +45 -0
- package/dist/commands/taskActivationCommands.js +19 -5
- package/dist/commands/taskCommands.js +8 -2
- package/dist/commands/taskFactCommands.js +24 -12
- package/dist/commands/taskProjectDeliveryCommand.js +56 -0
- package/dist/commands/taskPublicationCommands.js +6 -3
- package/dist/commands/taskPublicationVerifyCommand.js +74 -28
- package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
- package/dist/context/runContextPack.js +25 -4
- package/dist/context/runInputContract.js +1 -0
- package/dist/context/taskContext.js +53 -16
- package/dist/context/taskPhase.js +99 -0
- package/dist/controlPlaneCli.js +38 -17
- package/dist/controller/controller.js +35 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +75 -16
- package/dist/controller/globalInputDelivery.js +17 -7
- package/dist/controller/inboxTurnCheck.js +64 -0
- package/dist/controller/jobControl.js +42 -12
- package/dist/controller/jobDefinitionControl.js +304 -0
- package/dist/controller/jobSupervisor.js +47 -32
- package/dist/controller/providerRetryAdmission.js +2 -0
- package/dist/controller/runtime.js +11 -2
- package/dist/controller/scheduleControl.js +176 -0
- package/dist/coordination/workMailbox.js +3 -3
- package/dist/external/githubPublicationVerifier.js +14 -5
- package/dist/external/gitlabPublicationVerifier.js +12 -29
- package/dist/grant/taskAuthorization.js +7 -2
- package/dist/interaction/notificationPresentation.js +12 -3
- package/dist/interaction/operatorPresentation.js +5 -0
- package/dist/job/builtinJob.js +58 -0
- package/dist/job/durableJob.js +72 -49
- package/dist/job/jobDefinition.js +68 -0
- package/dist/job/jobRunner.js +2 -1
- package/dist/job/projectRefreshRunner.js +48 -0
- package/dist/message/globalInboxRecovery.js +44 -0
- package/dist/message/globalInterrupt.js +1 -1
- package/dist/message/inboxReceipt.js +14 -6
- package/dist/message/inboxTurn.js +86 -0
- package/dist/message/message.js +11 -0
- package/dist/message/taskInboxOwnership.js +20 -6
- package/dist/repository/projectRefresh.js +58 -0
- package/dist/repository/taskWorkspacePreparer.js +2 -1
- package/dist/repository/workspaceCleanupInspection.js +6 -1
- package/dist/schedule/schedule.js +108 -0
- package/dist/scheduler/operatorEvent.js +2 -0
- package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
- package/dist/storage/contextRecords.js +8 -0
- package/dist/storage/jobSchema.js +15 -0
- package/dist/storage/maintenanceCaller.js +1 -1
- package/dist/storage/recordValidation.js +5 -0
- package/dist/storage/scheduleSchema.js +12 -0
- package/dist/storage/sqliteSchema.js +95 -24
- package/dist/storage/sqliteStore.js +123 -10
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +5 -0
- package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
- package/dist/task/archiveDiagnostics.js +1 -1
- package/dist/task/archivePreflight.js +1 -1
- package/dist/task/projectDeliveryScope.js +20 -0
- package/dist/task/publicationToolObservation.js +126 -0
- package/dist/task/remoteDelivery.js +19 -8
- package/dist/task/remoteDeliveryService.js +5 -4
- package/dist/task/taskActivationService.js +35 -0
- package/dist/web/assets/client/app/settings.js +7 -1
- package/dist/web/assets/client/i18n/en.js +65 -2
- package/dist/web/assets/client/i18n/zh-CN.js +65 -2
- package/dist/web/assets/client/index.js +2 -0
- package/dist/web/assets/client/views/jobs.js +222 -0
- package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
- package/dist/web/assets/client/views/task/page.js +11 -1
- package/dist/web/assets/shared/manageNav.js +1 -0
- package/dist/web/assets/shared/taskTabs.js +2 -2
- package/dist/web/assets/shell/settingsContent.js +3 -0
- package/dist/web/assets/styles/index.js +2 -0
- package/dist/web/assets/styles/views/jobs.js +19 -0
- package/dist/web/controllerWeb.js +73 -21
- package/dist/web/taskPreviews.js +21 -13
- package/dist/web/webAccess.js +109 -0
- package/dist/web/webJobs.js +52 -0
- package/dist/web/webServer.js +76 -44
- package/docs/architecture/capabilities-and-resources.md +6 -4
- package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
- package/docs/cli-information-contract.md +57 -4
- package/docs/cli-information-contract.zh-CN.md +41 -2
- package/docs/job-runs.md +224 -0
- package/docs/project-delivery-scope.md +59 -0
- package/docs/release-workflow.md +86 -0
- package/docs/release-workflow.zh-CN.md +68 -0
- package/docs/schedules.md +132 -0
- package/docs/storage-baseline.md +31 -1
- package/docs/storage-baseline.zh-CN.md +25 -1
- package/docs/task-delivery.md +74 -2
- package/docs/task-delivery.zh-CN.md +39 -1
- package/docs/testing/verification-levels.md +6 -2
- package/docs/testing/verification-levels.zh-CN.md +6 -3
- package/i18n/README.zh-CN.md +6 -3
- package/package.json +4 -3
- package/skills/yui-leader/SKILL.md +44 -1
- package/skills/yui-leader/VERSION +1 -1
- package/skills/yui-leader/references/planning.md +9 -1
- package/skills/yui-operator/SKILL.md +31 -0
- package/skills/yui-operator/VERSION +1 -1
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-reviewer/VERSION +1 -1
- package/skills/yui-runtime/SKILL.md +67 -8
- package/skills/yui-runtime/VERSION +1 -1
- package/skills/yui-runtime/references/context-ordering.md +103 -0
- package/skills/yui-runtime/references/publication.md +44 -4
- package/skills/yui-runtime/references/recovery.md +21 -0
- package/skills/yui-worker/SKILL.md +5 -0
- package/skills/yui-worker/VERSION +1 -1
|
@@ -1,59 +1,111 @@
|
|
|
1
1
|
import { parseWebCommandOptions, startYuiWebServer } from "./webServer.js";
|
|
2
|
+
import { parseDurableJobCaller } from "../controller/jobControl.js";
|
|
3
|
+
import { requireManagedGlobalCaller } from "../runtime/managedCaller.js";
|
|
2
4
|
/** HTTP listener ownership only, alongside the Controller's one Kernel Host.
|
|
3
5
|
* The authenticated Controller control socket starts/stops the exact listener;
|
|
4
6
|
* no capability identity is accepted through this lifecycle API. */
|
|
5
7
|
export function createControllerWeb(store, dependencies) {
|
|
6
8
|
let current;
|
|
7
|
-
let
|
|
8
|
-
|
|
9
|
+
let shutdown = false;
|
|
10
|
+
let startup;
|
|
11
|
+
let closing;
|
|
12
|
+
const closeListener = async () => {
|
|
13
|
+
if (closing)
|
|
14
|
+
return closing;
|
|
9
15
|
const owned = current;
|
|
10
16
|
if (!owned)
|
|
11
17
|
return;
|
|
12
|
-
current = undefined;
|
|
13
18
|
owned.server.closeTerminals();
|
|
14
|
-
|
|
19
|
+
closing = new Promise((resolve, reject) => {
|
|
15
20
|
owned.server.close((error) => error ? reject(error) : resolve());
|
|
16
21
|
owned.server.closeAllConnections();
|
|
17
22
|
});
|
|
23
|
+
try {
|
|
24
|
+
await closing;
|
|
25
|
+
current = undefined;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
closing = undefined;
|
|
29
|
+
}
|
|
18
30
|
};
|
|
19
31
|
return {
|
|
20
|
-
close
|
|
32
|
+
async close() {
|
|
33
|
+
shutdown = true;
|
|
34
|
+
// A Controller shutdown must also own a bind still in flight.
|
|
35
|
+
if (startup)
|
|
36
|
+
await startup.catch(() => undefined);
|
|
37
|
+
await closeListener();
|
|
38
|
+
},
|
|
21
39
|
async dispatch(method, value) {
|
|
22
40
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
23
41
|
throw new Error("Invalid Web listener request.");
|
|
24
42
|
const params = value;
|
|
43
|
+
// The control socket proves the local Home/user boundary. Managed
|
|
44
|
+
// callers additionally retain their current native identity; no Web
|
|
45
|
+
// token or browser-user credential is issued by this API.
|
|
46
|
+
const caller = parseDurableJobCaller(params.caller);
|
|
47
|
+
if (caller.scope === "user") {
|
|
48
|
+
if (Object.keys(caller).length !== 1)
|
|
49
|
+
throw new Error("Invalid local Web caller.");
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
if (caller.scope !== "global" || caller.role !== "operator" || caller.taskId !== undefined || caller.runId !== undefined) {
|
|
53
|
+
throw new Error("Only the current global Operator may manage Web listeners.");
|
|
54
|
+
}
|
|
55
|
+
requireManagedGlobalCaller(store, {
|
|
56
|
+
YUI_SESSION_SCOPE: "global", YUI_ROLE: caller.role, YUI_AGENT_ID: caller.agentId,
|
|
57
|
+
YUI_ADAPTER_ID: caller.adapterId, YUI_NATIVE_SESSION_ID: caller.nativeSessionId
|
|
58
|
+
});
|
|
59
|
+
}
|
|
25
60
|
if (method === "web.status") {
|
|
26
|
-
if (Object.keys(params).
|
|
61
|
+
if (Object.keys(params).some(key => key !== "caller"))
|
|
27
62
|
throw new Error("Web status takes no arguments.");
|
|
28
|
-
return current ? {
|
|
63
|
+
return current ? { ...current.status, state: closing ? "stopping" : "running" }
|
|
64
|
+
: startup ? { state: "starting" } : null;
|
|
29
65
|
}
|
|
30
66
|
if (method === "web.stop") {
|
|
31
|
-
if (Object.keys(params).some((key) =>
|
|
67
|
+
if (Object.keys(params).some((key) => !["id", "caller"].includes(key)) || typeof params.id !== "string")
|
|
32
68
|
throw new Error("Expected Web listener id.");
|
|
69
|
+
if (startup)
|
|
70
|
+
throw new Error("Web listener is starting; query status before retrying.");
|
|
33
71
|
if (!current)
|
|
34
72
|
return { stopped: false };
|
|
35
|
-
if (current.id !== params.id)
|
|
73
|
+
if (current.status.id !== params.id)
|
|
36
74
|
throw new Error("Web listener identity changed.");
|
|
37
|
-
await
|
|
75
|
+
await closeListener();
|
|
38
76
|
return { stopped: true };
|
|
39
77
|
}
|
|
40
|
-
if (method !== "web.start" || Object.keys(params).some((key) => !["id", "host", "port"].includes(key))
|
|
78
|
+
if (method !== "web.start" || Object.keys(params).some((key) => !["id", "host", "port", "authFile", "caller"].includes(key))
|
|
41
79
|
|| typeof params.id !== "string" || !params.id.trim()
|
|
42
|
-
|| typeof params.host !== "string" || typeof params.port !== "number"
|
|
80
|
+
|| typeof params.host !== "string" || typeof params.port !== "number"
|
|
81
|
+
|| (params.authFile !== undefined && typeof params.authFile !== "string")) {
|
|
43
82
|
throw new Error("Expected id, host and port for Web start.");
|
|
44
83
|
}
|
|
45
|
-
const options = parseWebCommandOptions(["--host", params.host, "--port", String(params.port)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
84
|
+
const options = parseWebCommandOptions(["--host", params.host, "--port", String(params.port),
|
|
85
|
+
...(typeof params.authFile === "string" ? ["--auth-file", params.authFile] : [])]);
|
|
86
|
+
if (shutdown)
|
|
87
|
+
throw new Error("Controller Web lifecycle is shutting down.");
|
|
88
|
+
if (startup || closing)
|
|
89
|
+
throw new Error("Web listener lifecycle is busy; query status before retrying.");
|
|
90
|
+
if (current) {
|
|
91
|
+
if (current.status.host === options.host && current.status.port === options.port && current.authFile === options.authFile) {
|
|
92
|
+
return { ...current.status, state: "running", alreadyRunning: true };
|
|
93
|
+
}
|
|
94
|
+
throw new Error("A different Web listener is already running. Inspect --status and explicitly --stop before changing its configuration.");
|
|
95
|
+
}
|
|
49
96
|
try {
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
97
|
+
startup = startYuiWebServer(store, options, dependencies);
|
|
98
|
+
const server = await startup;
|
|
99
|
+
const { urls, authentication, warnings } = server.access;
|
|
100
|
+
const status = { id: params.id, host: options.host, port: options.port,
|
|
101
|
+
url: urls[0] ?? null, urls, authentication, warnings };
|
|
102
|
+
current = { status, server, authFile: options.authFile };
|
|
103
|
+
if (shutdown)
|
|
104
|
+
throw new Error("Controller shut down before Web startup completed.");
|
|
105
|
+
return { ...status, state: "running", alreadyRunning: false };
|
|
54
106
|
}
|
|
55
107
|
finally {
|
|
56
|
-
|
|
108
|
+
startup = undefined;
|
|
57
109
|
}
|
|
58
110
|
}
|
|
59
111
|
};
|
package/dist/web/taskPreviews.js
CHANGED
|
@@ -13,6 +13,16 @@ export function createTaskPreviews(store, home) {
|
|
|
13
13
|
const secret = randomBytes(32);
|
|
14
14
|
const key = (taskId, jobId) => createHmac("sha256", secret).update(JSON.stringify([taskId, jobId])).digest("base64url");
|
|
15
15
|
const prefix = (job) => `/preview/${encodeURIComponent(job.taskId)}/${encodeURIComponent(job.id)}/${key(job.taskId, job.id)}/`;
|
|
16
|
+
const ticketTarget = (request) => {
|
|
17
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
18
|
+
const match = /^\/preview\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/(.*)$/.exec(url.pathname);
|
|
19
|
+
if (!match || (request.method !== "GET" && request.method !== "HEAD"))
|
|
20
|
+
return undefined;
|
|
21
|
+
const [, taskId, jobId, ticket, path] = match;
|
|
22
|
+
const expected = key(taskId, jobId);
|
|
23
|
+
return ticket.length === expected.length && timingSafeEqual(Buffer.from(ticket), Buffer.from(expected))
|
|
24
|
+
? { taskId: taskId, jobId: jobId, path: path, search: url.search } : undefined;
|
|
25
|
+
};
|
|
16
26
|
const registered = (taskId, jobId) => {
|
|
17
27
|
const job = store.getDurableJob(taskId, jobId);
|
|
18
28
|
if (!store.getTask(taskId) || !job || job.taskId !== taskId || !isHttpPreview(job.env)) {
|
|
@@ -52,6 +62,10 @@ export function createTaskPreviews(store, home) {
|
|
|
52
62
|
return result;
|
|
53
63
|
};
|
|
54
64
|
return {
|
|
65
|
+
// Issued only by the authenticated list API. This credential authorizes
|
|
66
|
+
// one preview's reads, not the main page, APIs, writes or Terminal.
|
|
67
|
+
// Opaque sandbox fetch/module requests intentionally carry no user auth.
|
|
68
|
+
authenticated(request) { return ticketTarget(request) !== undefined; },
|
|
55
69
|
async list(taskId) {
|
|
56
70
|
if (!store.getTask(taskId))
|
|
57
71
|
throw new WebRequestRejected("Task not found.");
|
|
@@ -102,27 +116,21 @@ export function createTaskPreviews(store, home) {
|
|
|
102
116
|
});
|
|
103
117
|
},
|
|
104
118
|
async serve(request, response) {
|
|
105
|
-
const url = new URL(request.url ?? "/", "http://localhost");
|
|
106
|
-
const match = /^\/preview\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/(.*)$/.exec(url.pathname);
|
|
107
119
|
const fail = (status, detail) => {
|
|
108
120
|
response.statusCode = status;
|
|
109
121
|
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
110
122
|
response.end(detail);
|
|
111
123
|
};
|
|
112
|
-
if (
|
|
113
|
-
fail(
|
|
124
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
125
|
+
fail(405, "Preview supports GET and HEAD only.");
|
|
114
126
|
return;
|
|
115
127
|
}
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
if (ticket.length !== expected.length || !timingSafeEqual(Buffer.from(ticket), Buffer.from(expected))) {
|
|
128
|
+
const target = ticketTarget(request);
|
|
129
|
+
if (!target) {
|
|
119
130
|
fail(403, "Invalid preview ticket.");
|
|
120
131
|
return;
|
|
121
132
|
}
|
|
122
|
-
|
|
123
|
-
fail(405, "Preview supports GET and HEAD only.");
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
133
|
+
const { taskId, jobId, path, search } = target;
|
|
126
134
|
let job;
|
|
127
135
|
try {
|
|
128
136
|
job = registered(taskId, jobId);
|
|
@@ -134,7 +142,7 @@ export function createTaskPreviews(store, home) {
|
|
|
134
142
|
return;
|
|
135
143
|
}
|
|
136
144
|
try {
|
|
137
|
-
const result = await read(job, "/" + path +
|
|
145
|
+
const result = await read(job, "/" + path + search, request.method);
|
|
138
146
|
const base = prefix(job);
|
|
139
147
|
// No upstream CSP, cookies, auth, redirects, or CORS escape this boundary.
|
|
140
148
|
const source = `http://${request.headers.host}${base}`;
|
|
@@ -155,7 +163,7 @@ export function createTaskPreviews(store, home) {
|
|
|
155
163
|
fail(502, "External/absolute redirects are not supported in preview.");
|
|
156
164
|
return;
|
|
157
165
|
}
|
|
158
|
-
const target = new URL(result.location, "http://preview.invalid/" + path +
|
|
166
|
+
const target = new URL(result.location, "http://preview.invalid/" + path + search);
|
|
159
167
|
response.setHeader("location", base + target.pathname.slice(1) + target.search + target.hash);
|
|
160
168
|
}
|
|
161
169
|
response.statusCode = result.status;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { isAbsolute } from "node:path";
|
|
6
|
+
export function isLoopbackAddress(host) {
|
|
7
|
+
return host === "localhost" || host === "::1" || /^127\./.test(host) && isIP(host) === 4;
|
|
8
|
+
}
|
|
9
|
+
/** Only numeric private addresses are advertised/accepted for LAN access.
|
|
10
|
+
* DNS aliases and proxy headers are deliberately not an authentication input. */
|
|
11
|
+
export function isPrivateAddress(host) {
|
|
12
|
+
return isIP(host) === 4
|
|
13
|
+
? /^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(host)
|
|
14
|
+
: isIP(host) === 6 && /^f[cd]/i.test(host);
|
|
15
|
+
}
|
|
16
|
+
export function validateWebAccessOptions(options) {
|
|
17
|
+
if (!isLoopbackAddress(options.host) && !isPrivateAddress(options.host)
|
|
18
|
+
&& options.host !== "0.0.0.0" && options.host !== "::") {
|
|
19
|
+
throw new Error("Web host must be localhost, a loopback/private IP address, 0.0.0.0 or ::.");
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) {
|
|
22
|
+
throw new Error("Web port must be an integer between 1 and 65535.");
|
|
23
|
+
}
|
|
24
|
+
if (!isLoopbackAddress(options.host) && !options.authFile) {
|
|
25
|
+
throw new Error("Non-loopback Web access requires --auth-file <absolute-path>.");
|
|
26
|
+
}
|
|
27
|
+
if (options.authFile !== undefined && !isAbsolute(options.authFile)) {
|
|
28
|
+
throw new Error("--auth-file must be an absolute path to an owner-only password file.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function passwordDigest(path) {
|
|
32
|
+
let fd;
|
|
33
|
+
try {
|
|
34
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
35
|
+
const stat = fstatSync(fd);
|
|
36
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()
|
|
37
|
+
|| stat.size < 32 || stat.size > 256)
|
|
38
|
+
throw new Error("invalid credential file");
|
|
39
|
+
const password = readFileSync(fd, "utf8").replace(/\r?\n$/, "");
|
|
40
|
+
if (!/^[\x21-\x7e]{32,128}$/.test(password))
|
|
41
|
+
throw new Error("invalid password");
|
|
42
|
+
return createHash("sha256").update(`Basic ${Buffer.from(`yui:${password}`).toString("base64")}`).digest();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Never include file contents, OS error text or a submitted credential.
|
|
46
|
+
throw new Error("Cannot load Web credential: use a regular owner-only (0600) file containing a unique 32–128 character ASCII password. Then restart Web.");
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (fd !== undefined)
|
|
50
|
+
closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function createWebAccess(options, interfaces = networkInterfaces()) {
|
|
54
|
+
validateWebAccessOptions(options);
|
|
55
|
+
const lan = !isLoopbackAddress(options.host);
|
|
56
|
+
const digest = options.authFile === undefined ? undefined : passwordDigest(options.authFile);
|
|
57
|
+
const wildcard = options.host === "0.0.0.0" || options.host === "::";
|
|
58
|
+
const canonicalHost = (host) => isIP(host) === 6
|
|
59
|
+
? new URL(`http://[${host}]`).hostname.slice(1, -1) : host;
|
|
60
|
+
const addresses = (wildcard
|
|
61
|
+
? [...new Set(Object.values(interfaces).flatMap(entries => entries ?? [])
|
|
62
|
+
.filter(entry => isPrivateAddress(entry.address) && (options.host === "::" || entry.family === "IPv4"))
|
|
63
|
+
.map(entry => entry.address))]
|
|
64
|
+
: [options.host]).map(canonicalHost);
|
|
65
|
+
const allowed = new Set(!lan || wildcard ? [...addresses, "127.0.0.1", "::1", "localhost"] : addresses);
|
|
66
|
+
const urlHost = (host) => isIP(host) === 6 ? `[${host}]` : host;
|
|
67
|
+
const urls = addresses.map(host => `http://${urlHost(host)}:${options.port}`);
|
|
68
|
+
const warnings = lan ? [
|
|
69
|
+
"HTTP is not encrypted. Use only a trusted network; credentials and data are visible to network observers.",
|
|
70
|
+
...(wildcard ? ["Wildcard binding listens on all matching interfaces, including public ones. Yui does not open firewall ports or tunnels."] : []),
|
|
71
|
+
...(!urls.length ? ["No usable private interface address was found. Check interfaces or bind an explicit private address; reachability is not verified."] : [])
|
|
72
|
+
] : [];
|
|
73
|
+
return {
|
|
74
|
+
lan,
|
|
75
|
+
authentication: digest ? "basic" : "local",
|
|
76
|
+
urls,
|
|
77
|
+
warnings,
|
|
78
|
+
acceptsHost(request) {
|
|
79
|
+
const host = request.headers.host;
|
|
80
|
+
if (!host)
|
|
81
|
+
return false;
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(`http://${host}`);
|
|
84
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
85
|
+
return allowed.has(hostname) && host === `${urlHost(hostname)}${request.socket.localPort === 80 ? "" : `:${request.socket.localPort}`}`
|
|
86
|
+
&& !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
acceptsOrigin(request, requireOrigin = false, scopedPreview = false) {
|
|
93
|
+
const origin = request.headers.origin;
|
|
94
|
+
// Existing preview tickets authenticate only GET/HEAD of one sandboxed
|
|
95
|
+
// Job. Its opaque-origin modules/fetches must not inherit user credentials.
|
|
96
|
+
if (scopedPreview && (origin === undefined || origin === "null"))
|
|
97
|
+
return true;
|
|
98
|
+
return request.headers["sec-fetch-site"] !== "cross-site"
|
|
99
|
+
&& (origin === undefined ? !requireOrigin : origin === `http://${request.headers.host}`);
|
|
100
|
+
},
|
|
101
|
+
authenticated(request) {
|
|
102
|
+
if (!digest)
|
|
103
|
+
return true;
|
|
104
|
+
const authorization = request.headers.authorization;
|
|
105
|
+
return typeof authorization === "string" && authorization.length <= 512
|
|
106
|
+
&& timingSafeEqual(createHash("sha256").update(authorization).digest(), digest);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
|
|
2
|
+
import { createJobDefinitionControl } from "../controller/jobDefinitionControl.js";
|
|
3
|
+
import { createScheduleControl } from "../controller/scheduleControl.js";
|
|
4
|
+
import { requireKnownFields } from "../domain/validation.js";
|
|
5
|
+
import { WebRequestRejected, webLocalMutation } from "./webMutation.js";
|
|
6
|
+
/** Local-user ingress only, behind the Web token/origin boundary. Never use
|
|
7
|
+
* this adapter for managed RPC callers. The route fixes scope; the server
|
|
8
|
+
* resolves the current Operator binding on every request. No invented Session
|
|
9
|
+
* or durable second authority: the public services revalidate the binding.
|
|
10
|
+
*/
|
|
11
|
+
export function createWebJobs(store, home) {
|
|
12
|
+
function invoke(scope, family, value, write, now) {
|
|
13
|
+
const apply = (tx) => {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
15
|
+
throw new Error("Expected an operation.");
|
|
16
|
+
requireKnownFields(value, family === "jobs"
|
|
17
|
+
? ["op", "id", "title", "configuration", "expectedRevision", "runId", "requestId"]
|
|
18
|
+
: ["op", "id", "jobId", "rule", "expectedRevision"], "Web Job/Schedule request");
|
|
19
|
+
const input = value;
|
|
20
|
+
const reads = family === "jobs" ? ["list", "show", "runs", "run-get", "logs"] : ["list", "show"];
|
|
21
|
+
const writes = family === "jobs" ? ["define", "update", "run", "run-cancel"] : ["create", "update", "enable", "disable"];
|
|
22
|
+
if (!(write ? writes : reads).includes(String(input.op)))
|
|
23
|
+
throw new Error("Operation is not available on this endpoint.");
|
|
24
|
+
if (scope.kind === "task" && !tx.getTask(scope.taskId))
|
|
25
|
+
throw new Error("Task not found.");
|
|
26
|
+
const session = activeLiveRoleAgentSession(tx.getGlobalRoleSessionSet("operator"));
|
|
27
|
+
if (!session)
|
|
28
|
+
throw new Error("A current Operator Session is required for Job management.");
|
|
29
|
+
const caller = { scope: "global", role: "operator", nativeSessionId: session.nativeSessionId,
|
|
30
|
+
agentId: session.agentId, adapterId: session.adapterId };
|
|
31
|
+
const { requestId, ...fields } = input;
|
|
32
|
+
if (input.op === "run" && (typeof requestId !== "string" || !requestId.trim() || requestId.length > 200)) {
|
|
33
|
+
throw new Error("Immediate execution requires a stable requestId.");
|
|
34
|
+
}
|
|
35
|
+
const params = { ...fields, scope, caller,
|
|
36
|
+
...(input.op === "run" ? { trigger: { source: "manual", key: requestId } } : {}) };
|
|
37
|
+
return (family === "jobs" ? createJobDefinitionControl(tx, home) : createScheduleControl(tx, home))(params, now);
|
|
38
|
+
};
|
|
39
|
+
if (write)
|
|
40
|
+
return webLocalMutation(store, apply);
|
|
41
|
+
try {
|
|
42
|
+
return store.transaction(apply);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw new WebRequestRejected(error instanceof Error ? error.message : String(error));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
read: (scope, family, value, now) => invoke(scope, family, value, false, now),
|
|
50
|
+
mutate: (scope, family, value, now) => invoke(scope, family, value, true, now)
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/web/webServer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import WebSocket, { WebSocketServer } from "ws";
|
|
4
|
+
import { createWebAccess, validateWebAccessOptions } from "./webAccess.js";
|
|
4
5
|
import { CliError, usageError } from "../errors/cliError.js";
|
|
5
6
|
import { parseTaskCatalogOptions } from "../context/taskCatalog.js";
|
|
6
7
|
import { parseTaskSearchOptions, searchTaskBodies } from "../context/taskSearch.js";
|
|
@@ -17,6 +18,7 @@ const MAX_TERMINAL_BUFFERED_BYTES = 1024 * 1024;
|
|
|
17
18
|
export function parseWebCommandOptions(args) {
|
|
18
19
|
let host = "127.0.0.1";
|
|
19
20
|
let port = 4173;
|
|
21
|
+
let authFile;
|
|
20
22
|
for (let index = 0; index < args.length; index += 1) {
|
|
21
23
|
const option = args[index];
|
|
22
24
|
const value = args[index + 1];
|
|
@@ -28,19 +30,25 @@ export function parseWebCommandOptions(args) {
|
|
|
28
30
|
port = Number(value);
|
|
29
31
|
index += 1;
|
|
30
32
|
}
|
|
33
|
+
else if (option === "--auth-file" && value !== undefined) {
|
|
34
|
+
authFile = value;
|
|
35
|
+
index += 1;
|
|
36
|
+
}
|
|
31
37
|
else {
|
|
32
38
|
throw webUsageError();
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
const options = { host, port, ...(authFile === undefined ? {} : { authFile }) };
|
|
42
|
+
try {
|
|
43
|
+
validateWebAccessOptions(options);
|
|
37
44
|
}
|
|
38
|
-
|
|
39
|
-
throw usageError(
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw usageError(error.message, webUsage());
|
|
40
47
|
}
|
|
41
|
-
return
|
|
48
|
+
return options;
|
|
42
49
|
}
|
|
43
|
-
export function createYuiWebServer(store, dependencies = {}) {
|
|
50
|
+
export function createYuiWebServer(store, dependencies = {}, options = { host: "127.0.0.1", port: 4173 }) {
|
|
51
|
+
const access = createWebAccess(options);
|
|
44
52
|
const now = dependencies.now ?? (() => new Date());
|
|
45
53
|
const token = dependencies.token ?? randomBytes(24).toString("base64url");
|
|
46
54
|
const httpDependencies = { ...dependencies, previews: dependencies.previews?.() };
|
|
@@ -50,7 +58,7 @@ export function createYuiWebServer(store, dependencies = {}) {
|
|
|
50
58
|
maxPayload: MAX_TERMINAL_MESSAGE_BYTES
|
|
51
59
|
});
|
|
52
60
|
const server = createServer((request, response) => {
|
|
53
|
-
void handleHttpRequest(request, response, store, httpDependencies, token, now).catch(() => {
|
|
61
|
+
void handleHttpRequest(request, response, store, httpDependencies, token, now, access).catch(() => {
|
|
54
62
|
if (!response.headersSent)
|
|
55
63
|
setSecurityHeaders(response);
|
|
56
64
|
if (!response.writableEnded) {
|
|
@@ -59,7 +67,7 @@ export function createYuiWebServer(store, dependencies = {}) {
|
|
|
59
67
|
});
|
|
60
68
|
});
|
|
61
69
|
server.on("upgrade", (request, socket, head) => {
|
|
62
|
-
void handleTerminalUpgrade(request, socket, head, webSocketServer, dependencies.terminal, token);
|
|
70
|
+
void handleTerminalUpgrade(request, socket, head, webSocketServer, dependencies.terminal, token, access);
|
|
63
71
|
});
|
|
64
72
|
let terminalsClosed = false;
|
|
65
73
|
const closeTerminals = () => {
|
|
@@ -71,24 +79,36 @@ export function createYuiWebServer(store, dependencies = {}) {
|
|
|
71
79
|
webSocketServer.close();
|
|
72
80
|
};
|
|
73
81
|
server.on("close", closeTerminals);
|
|
74
|
-
return Object.assign(server, { closeTerminals });
|
|
82
|
+
return Object.assign(server, { closeTerminals, access });
|
|
75
83
|
}
|
|
76
84
|
export async function startYuiWebServer(store, options, dependencies = {}) {
|
|
77
|
-
const server = createYuiWebServer(store, dependencies);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
server.
|
|
82
|
-
|
|
85
|
+
const server = createYuiWebServer(store, dependencies, options);
|
|
86
|
+
try {
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
server.once("error", reject);
|
|
89
|
+
server.listen(options.port, options.host, () => {
|
|
90
|
+
server.off("error", reject);
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
83
93
|
});
|
|
84
|
-
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
server.closeTerminals();
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
85
99
|
return server;
|
|
86
100
|
}
|
|
87
|
-
async function handleHttpRequest(request, response, store, dependencies, token, now) {
|
|
101
|
+
async function handleHttpRequest(request, response, store, dependencies, token, now, access) {
|
|
88
102
|
setSecurityHeaders(response);
|
|
89
103
|
const method = request.method ?? "GET";
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
const previewAuthenticated = dependencies.previews?.authenticated(request) === true;
|
|
105
|
+
if (!access.acceptsHost(request) || !access.acceptsOrigin(request, access.lan && method !== "GET" && method !== "HEAD", previewAuthenticated)) {
|
|
106
|
+
sendJson(response, 403, { error: "Invalid Host or Origin.", disposition: "not-submitted" }, method === "HEAD");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!previewAuthenticated && !access.authenticated(request)) {
|
|
110
|
+
response.setHeader("www-authenticate", 'Basic realm="Yui Web", charset="UTF-8"');
|
|
111
|
+
sendJson(response, 401, { error: "Web authentication required. Use username yui and the configured password. To revoke access, stop Web; to rotate the password, replace the credential file and restart Web.", disposition: "not-submitted" }, method === "HEAD");
|
|
92
112
|
return;
|
|
93
113
|
}
|
|
94
114
|
let pathname;
|
|
@@ -99,7 +119,8 @@ async function handleHttpRequest(request, response, store, dependencies, token,
|
|
|
99
119
|
sendJson(response, 400, { error: "Invalid URL.", disposition: "not-submitted" }, method === "HEAD");
|
|
100
120
|
return;
|
|
101
121
|
}
|
|
102
|
-
//
|
|
122
|
+
// Authentication precedes every document/preview. The page token remains
|
|
123
|
+
// an independent CSRF boundary. A request body cannot
|
|
103
124
|
// select Operator/Leader authority; all API reads use this boundary too.
|
|
104
125
|
if (pathname.startsWith("/api/") && !tokenMatches(headerValue(request, "x-yui-web-token"), token)) {
|
|
105
126
|
sendJson(response, 403, { error: "Invalid Yui web token.", disposition: "not-submitted" }, method === "HEAD");
|
|
@@ -252,6 +273,37 @@ async function handleHttpRequest(request, response, store, dependencies, token,
|
|
|
252
273
|
}
|
|
253
274
|
return;
|
|
254
275
|
}
|
|
276
|
+
const jobTarget = /^\/api\/(?:settings|(tasks)\/([^/]+))\/(jobs|schedules)$/.exec(pathname);
|
|
277
|
+
if (jobTarget) {
|
|
278
|
+
try {
|
|
279
|
+
if (!dependencies.jobs)
|
|
280
|
+
throw new WebRequestRejected("Job management unavailable.");
|
|
281
|
+
const scope = jobTarget[1] ? { kind: "task", taskId: decodeURIComponent(jobTarget[2]) }
|
|
282
|
+
: { kind: "global" };
|
|
283
|
+
const family = jobTarget[3];
|
|
284
|
+
let value;
|
|
285
|
+
if (method === "GET") {
|
|
286
|
+
const query = new URL(request.url, "http://localhost").searchParams;
|
|
287
|
+
if ([...query.keys()].some(key => !["op", "id", "runId"].includes(key)))
|
|
288
|
+
throw new WebRequestRejected("Unknown Job query.");
|
|
289
|
+
value = dependencies.jobs.read(scope, family, Object.fromEntries(query), now());
|
|
290
|
+
}
|
|
291
|
+
else if (method === "POST") {
|
|
292
|
+
if (headerValue(request, "origin") !== `http://${headerValue(request, "host")}`) {
|
|
293
|
+
throw new WebRequestRejected("Job changes require a same-origin request.");
|
|
294
|
+
}
|
|
295
|
+
value = dependencies.jobs.mutate(scope, family, await readMutationBody(request), now());
|
|
296
|
+
}
|
|
297
|
+
else
|
|
298
|
+
throw new WebRequestRejected("Method not allowed.");
|
|
299
|
+
sendJson(response, 200, value, false);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
sendJson(response, 409, { error: error instanceof Error ? error.message : "Job operation failed.",
|
|
303
|
+
disposition: error instanceof WebRequestRejected ? "not-submitted" : "unknown" }, false);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
255
307
|
if (pathname === "/api/settings" || pathname === "/api/settings/group" || pathname === "/api/settings/capabilities") {
|
|
256
308
|
try {
|
|
257
309
|
if (!dependencies.settings)
|
|
@@ -683,10 +735,10 @@ async function handleHttpRequest(request, response, store, dependencies, token,
|
|
|
683
735
|
response.setHeader("allow", "GET, HEAD");
|
|
684
736
|
sendJson(response, 405, { error: "Method not allowed.", disposition: "not-submitted" }, method === "HEAD");
|
|
685
737
|
}
|
|
686
|
-
async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token) {
|
|
738
|
+
async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token, access) {
|
|
687
739
|
try {
|
|
688
740
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
689
|
-
if (!
|
|
741
|
+
if (!access.acceptsHost(request) || !access.acceptsOrigin(request, true) || !access.authenticated(request)) {
|
|
690
742
|
rejectUpgrade(socket, 403, "Forbidden");
|
|
691
743
|
return;
|
|
692
744
|
}
|
|
@@ -1048,26 +1100,6 @@ function headerValue(request, name) {
|
|
|
1048
1100
|
const value = request.headers[name];
|
|
1049
1101
|
return Array.isArray(value) ? value[0] : value;
|
|
1050
1102
|
}
|
|
1051
|
-
function isLoopbackHost(value) {
|
|
1052
|
-
if (value === undefined)
|
|
1053
|
-
return false;
|
|
1054
|
-
let url;
|
|
1055
|
-
try {
|
|
1056
|
-
url = new URL(`http://${value}`);
|
|
1057
|
-
}
|
|
1058
|
-
catch {
|
|
1059
|
-
return false;
|
|
1060
|
-
}
|
|
1061
|
-
if (url.username.length > 0
|
|
1062
|
-
|| url.password.length > 0
|
|
1063
|
-
|| url.pathname !== "/"
|
|
1064
|
-
|| url.search.length > 0
|
|
1065
|
-
|| url.hash.length > 0) {
|
|
1066
|
-
return false;
|
|
1067
|
-
}
|
|
1068
|
-
const hostname = url.hostname.toLowerCase();
|
|
1069
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
1070
|
-
}
|
|
1071
1103
|
function safeIdentity(value, label) {
|
|
1072
1104
|
if (value === null || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(value)) {
|
|
1073
1105
|
throw new Error(`${label} is invalid.`);
|
|
@@ -1091,8 +1123,8 @@ function escapeHtmlAttribute(value) {
|
|
|
1091
1123
|
.replaceAll(">", ">");
|
|
1092
1124
|
}
|
|
1093
1125
|
function webUsageError() {
|
|
1094
|
-
return usageError("Web
|
|
1126
|
+
return usageError("Invalid Web options.", webUsage());
|
|
1095
1127
|
}
|
|
1096
1128
|
function webUsage() {
|
|
1097
|
-
return "Usage: yui web [--host <
|
|
1129
|
+
return "Usage: yui web [--host <IP|localhost>] [--port <1-65535>] [--auth-file <absolute-path>]";
|
|
1098
1130
|
}
|
|
@@ -122,12 +122,14 @@ catalog; there is no second catalog or Host. A CLI contribution uses the
|
|
|
122
122
|
capability's original name. A Web panel accepts only controlled text, an HTTP(S)
|
|
123
123
|
link or a JSON query description — not author scripts or arbitrary HTML.
|
|
124
124
|
|
|
125
|
-
The
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
The Controller owns the single Web listener, defaulting to `127.0.0.1:4173`.
|
|
126
|
+
Explicit trusted-LAN binding requires a private credential file; authentication
|
|
127
|
+
precedes documents, APIs, previews and Terminal. See [controlled Web access](../web-access.md).
|
|
128
|
+
Current Operator lifecycle calls are revalidated separately from browser access.
|
|
129
|
+
This is still a single-user surface, not a multi-user service or an OS sandbox.
|
|
128
130
|
|
|
129
131
|
The page supplies a token that authenticates all HTTP API reads and writes via
|
|
130
|
-
`x-yui-web-token`; the server also checks
|
|
132
|
+
`x-yui-web-token`; the server also checks exact allowed Host/port and Origin. These controls act
|
|
131
133
|
as the trusted local user, not a Role selected by the request body. They support
|
|
132
134
|
Task metadata edits, messages, InputRequest answers, and explicit
|
|
133
135
|
`queue / steer / interrupt` for Task or Global Roles. Managed Agent capability
|
|
@@ -88,12 +88,13 @@ Surface contribution 由 Registry 当前获授权目录派生,没有第二份
|
|
|
88
88
|
CLI contribution 使用能力原名称。Web panel 只接受受控 text、HTTP(S) link 或
|
|
89
89
|
JSON query 描述,不接受作者脚本或任意 HTML。
|
|
90
90
|
|
|
91
|
-
Web listener 由 Controller
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
唯一 Web listener 由 Controller 启停,默认 `127.0.0.1:4173`。显式可信内网
|
|
92
|
+
监听要求私有凭证文件,在页面、API、预览和 Terminal 之前认证。
|
|
93
|
+
当前 Operator 的生命周期权限与浏览器访问分开复核。
|
|
94
|
+
详见[受控 Web 访问](../web-access.md);它仍是单用户 Surface,不是多用户服务或 OS 沙箱。
|
|
94
95
|
|
|
95
96
|
页面提供 token,通过 `x-yui-web-token` 认证所有 HTTP API 读取与写入;服务端
|
|
96
|
-
|
|
97
|
+
也检查精确允许的 Host/端口和 Origin。这些控制使用受信任本地用户身份,不由请求正文选择 Role。
|
|
97
98
|
它们支持修改 Task 元数据、发送消息、回答 InputRequest,以及对 Task 或 Global
|
|
98
99
|
Role 显式 `queue / steer / interrupt`。受管 Agent 的能力 RPC 仍使用自身 Session
|
|
99
100
|
认证与范围;浏览器 token 不是 Agent 或插件绕过这些边界的途径。
|