@zq-silk/yui 2.9.0 → 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.
Files changed (65) hide show
  1. package/README.md +5 -3
  2. package/dist/cli/commandCatalog.js +21 -9
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/commands/globalRoleCommands.js +30 -9
  5. package/dist/commands/inboxCommands.js +79 -22
  6. package/dist/commands/taskCommands.js +6 -1
  7. package/dist/commands/taskProjectDeliveryCommand.js +56 -0
  8. package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
  9. package/dist/context/runContextPack.js +1 -0
  10. package/dist/context/runInputContract.js +1 -0
  11. package/dist/context/taskContext.js +1 -1
  12. package/dist/context/taskPhase.js +1 -1
  13. package/dist/controlPlaneCli.js +32 -17
  14. package/dist/controller/fileSchedulerStoreAdapter.js +59 -15
  15. package/dist/controller/globalInputDelivery.js +17 -7
  16. package/dist/controller/inboxTurnCheck.js +64 -0
  17. package/dist/controller/providerRetryAdmission.js +2 -0
  18. package/dist/external/gitlabPublicationVerifier.js +10 -28
  19. package/dist/grant/taskAuthorization.js +7 -2
  20. package/dist/interaction/notificationPresentation.js +9 -0
  21. package/dist/message/globalInboxRecovery.js +44 -0
  22. package/dist/message/globalInterrupt.js +1 -1
  23. package/dist/message/inboxReceipt.js +14 -6
  24. package/dist/message/inboxTurn.js +86 -0
  25. package/dist/message/message.js +11 -0
  26. package/dist/message/taskInboxOwnership.js +20 -6
  27. package/dist/repository/workspaceCleanupInspection.js +6 -1
  28. package/dist/storage/sqliteSchema.js +21 -0
  29. package/dist/storage/sqliteStore.js +13 -1
  30. package/dist/storage/storageVersions.js +1 -1
  31. package/dist/task/archiveDiagnostics.js +1 -1
  32. package/dist/task/archivePreflight.js +1 -1
  33. package/dist/task/projectDeliveryScope.js +20 -0
  34. package/dist/task/publicationToolObservation.js +4 -6
  35. package/dist/task/remoteDelivery.js +19 -8
  36. package/dist/task/remoteDeliveryService.js +5 -4
  37. package/dist/web/assets/client/i18n/en.js +4 -2
  38. package/dist/web/assets/client/i18n/zh-CN.js +4 -2
  39. package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
  40. package/dist/web/controllerWeb.js +73 -21
  41. package/dist/web/taskPreviews.js +21 -13
  42. package/dist/web/webAccess.js +109 -0
  43. package/dist/web/webServer.js +45 -44
  44. package/docs/architecture/capabilities-and-resources.md +6 -4
  45. package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
  46. package/docs/cli-information-contract.md +14 -2
  47. package/docs/cli-information-contract.zh-CN.md +9 -1
  48. package/docs/project-delivery-scope.md +59 -0
  49. package/docs/release-workflow.md +43 -0
  50. package/docs/release-workflow.zh-CN.md +35 -0
  51. package/docs/storage-baseline.md +14 -1
  52. package/docs/storage-baseline.zh-CN.md +11 -1
  53. package/docs/task-delivery.md +8 -2
  54. package/docs/task-delivery.zh-CN.md +6 -0
  55. package/docs/testing/verification-levels.md +2 -2
  56. package/docs/testing/verification-levels.zh-CN.md +2 -2
  57. package/i18n/README.zh-CN.md +6 -3
  58. package/package.json +1 -1
  59. package/skills/yui-leader/SKILL.md +26 -0
  60. package/skills/yui-leader/VERSION +1 -1
  61. package/skills/yui-operator/SKILL.md +31 -0
  62. package/skills/yui-operator/VERSION +1 -1
  63. package/skills/yui-runtime/SKILL.md +58 -5
  64. package/skills/yui-runtime/VERSION +1 -1
  65. package/skills/yui-runtime/references/recovery.md +21 -0
@@ -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 starting = false;
8
- const close = async () => {
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
- await new Promise((resolve, reject) => {
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).length)
61
+ if (Object.keys(params).some(key => key !== "caller"))
27
62
  throw new Error("Web status takes no arguments.");
28
- return current ? { id: current.id, url: current.url } : null;
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) => key !== "id") || typeof params.id !== "string")
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 close();
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
- if (starting || current)
47
- throw new Error("A Web listener is already starting or running in this Controller.");
48
- starting = true;
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
- const server = await startYuiWebServer(store, options, dependencies);
51
- const url = `http://${options.host === "::1" ? "[::1]" : options.host}:${options.port}`;
52
- current = { id: params.id, server, url };
53
- return { id: params.id, url };
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
- starting = false;
108
+ startup = undefined;
57
109
  }
58
110
  }
59
111
  };
@@ -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 (!match) {
113
- fail(403, "Invalid preview ticket.");
124
+ if (request.method !== "GET" && request.method !== "HEAD") {
125
+ fail(405, "Preview supports GET and HEAD only.");
114
126
  return;
115
127
  }
116
- const [, taskId, jobId, ticket, path] = match;
117
- const expected = key(taskId, jobId);
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
- if (request.method !== "GET" && request.method !== "HEAD") {
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 + url.search, request.method);
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 + url.search);
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
+ }
@@ -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
- if (!new Set(["127.0.0.1", "::1", "localhost"]).has(host)) {
36
- throw usageError("Web host must be a loopback address (127.0.0.1, ::1, or localhost).", webUsage());
41
+ const options = { host, port, ...(authFile === undefined ? {} : { authFile }) };
42
+ try {
43
+ validateWebAccessOptions(options);
37
44
  }
38
- if (!Number.isInteger(port) || port < 1 || port > 65_535) {
39
- throw usageError("Web port must be an integer between 1 and 65535.", webUsage());
45
+ catch (error) {
46
+ throw usageError(error.message, webUsage());
40
47
  }
41
- return { host, port };
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
- await new Promise((resolve, reject) => {
79
- server.once("error", reject);
80
- server.listen(options.port, options.host, () => {
81
- server.off("error", reject);
82
- resolve();
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
- if (!isLoopbackHost(headerValue(request, "host"))) {
91
- sendJson(response, 403, { error: "Invalid Host.", disposition: "not-submitted" }, method === "HEAD");
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
- // The loopback page token is the actual user ingress. A request body cannot
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");
@@ -714,10 +735,10 @@ async function handleHttpRequest(request, response, store, dependencies, token,
714
735
  response.setHeader("allow", "GET, HEAD");
715
736
  sendJson(response, 405, { error: "Method not allowed.", disposition: "not-submitted" }, method === "HEAD");
716
737
  }
717
- async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token) {
738
+ async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token, access) {
718
739
  try {
719
740
  const url = new URL(request.url ?? "/", "http://localhost");
720
- if (!isLoopbackHost(headerValue(request, "host"))) {
741
+ if (!access.acceptsHost(request) || !access.acceptsOrigin(request, true) || !access.authenticated(request)) {
721
742
  rejectUpgrade(socket, 403, "Forbidden");
722
743
  return;
723
744
  }
@@ -1079,26 +1100,6 @@ function headerValue(request, name) {
1079
1100
  const value = request.headers[name];
1080
1101
  return Array.isArray(value) ? value[0] : value;
1081
1102
  }
1082
- function isLoopbackHost(value) {
1083
- if (value === undefined)
1084
- return false;
1085
- let url;
1086
- try {
1087
- url = new URL(`http://${value}`);
1088
- }
1089
- catch {
1090
- return false;
1091
- }
1092
- if (url.username.length > 0
1093
- || url.password.length > 0
1094
- || url.pathname !== "/"
1095
- || url.search.length > 0
1096
- || url.hash.length > 0) {
1097
- return false;
1098
- }
1099
- const hostname = url.hostname.toLowerCase();
1100
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
1101
- }
1102
1103
  function safeIdentity(value, label) {
1103
1104
  if (value === null || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(value)) {
1104
1105
  throw new Error(`${label} is invalid.`);
@@ -1122,8 +1123,8 @@ function escapeHtmlAttribute(value) {
1122
1123
  .replaceAll(">", "&gt;");
1123
1124
  }
1124
1125
  function webUsageError() {
1125
- return usageError("Web usage: yui web [--host <loopback>] [--port <port>].", webUsage());
1126
+ return usageError("Invalid Web options.", webUsage());
1126
1127
  }
1127
1128
  function webUsage() {
1128
- return "Usage: yui web [--host <127.0.0.1|::1|localhost>] [--port <1-65535>]";
1129
+ return "Usage: yui web [--host <IP|localhost>] [--port <1-65535>] [--auth-file <absolute-path>]";
1129
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 Web listener is started and stopped by the Controller and allows loopback
126
- only (`127.0.0.1`, `::1` or `localhost`; default port 4173). `yui web` opens this
127
- local surface, not a remote multi-user service or an OS sandbox.
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 the loopback Host. These controls act
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 启停,仅允许 loopback(`127.0.0.1`、`::1` 或
92
- `localhost`;默认端口 4173)。`yui web` 打开的是本地 Surface,不是远程多用户
93
- 服务或 OS 沙箱。
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
- 也检查 loopback Host。这些控制使用受信任本地用户身份,不由请求正文选择 Role。
97
+ 也检查精确允许的 Host/端口和 Origin。这些控制使用受信任本地用户身份,不由请求正文选择 Role。
97
98
  它们支持修改 Task 元数据、发送消息、回答 InputRequest,以及对 Task 或 Global
98
99
  Role 显式 `queue / steer / interrupt`。受管 Agent 的能力 RPC 仍使用自身 Session
99
100
  认证与范围;浏览器 token 不是 Agent 或插件绕过这些边界的途径。
@@ -54,10 +54,22 @@ use family discovery when a broader requirement/history audit is needed.
54
54
  budget inside a 32 KiB response budget. Single inline values are at most
55
55
  2 KiB; large values and Message/Run/WorkItem/Knowledge bodies use references.
56
56
  Each collection contributes at most eight sampled records at entry.
57
- - Global entry samples up to eight unacknowledged inputs, including claimed or
58
- uncertain ones. It does not repeat recent history, Profile bodies, frozen Skill
57
+ - Global entry samples up to eight current-turn/available inputs, including
58
+ uncertain ones. Historical missing receipts are a separate count and explicit
59
+ `inbox list --view history` read; absence of ACK is not proof of unexecuted work.
60
+ Continue its pending page with `role message list <role> --current --cursor`.
61
+ It does not repeat recent history, Profile bodies, frozen Skill
59
62
  packages or accumulated retry details. The Manifest is the Profile pointer;
60
63
  explicit Message and Session retry reads retain history and original evidence.
64
+ - `inbox list --view current|history|all` uses the current managed identity,
65
+ prioritizes exact current-turn input and shares originals with automatic
66
+ delivery. `claim` and `ack` accept 1–16 exact IDs with independent results.
67
+ ACK records semantic disposition (including legal routing or supersession),
68
+ not Task completion. Exact terminals emit one bounded handling check without
69
+ ACK or business replay; reminder-only input cannot self-wake indefinitely.
70
+ `inbox retire` disposes only obsolete proven-unsubmitted Global input with an
71
+ explicit reason, preserving its original target. Task retirement remains
72
+ the Leader's existing `task message retire` operation.
61
73
  - Run entry samples up to eight records per live category and 16 frozen refs.
62
74
  Pointer continuation uses `--view refs` and `pointerPage.nextCursor`; full pack
63
75
  document continuation uses `--view full`. Neither changes the frozen authority.
@@ -44,8 +44,16 @@ Run 列表接受 Task 或 `task/work` 目标。
44
44
  - 当前 Task Context 至多 64 条,记录与 attention 共 24 KiB,响应预算 32 KiB。
45
45
  单个内联值至多 2 KiB;大值及 Message/Run/WorkItem/Knowledge 正文用引用。
46
46
  入口每个集合最多抽样八条。
47
- - Global 入口最多八条未确认处理的输入,保留已领取或效果未知项;不重复 recent、
47
+ - Global 入口最多八条本轮/可用输入,保留效果未知项;历史缺回执单独计数,
48
+ 通过 `inbox list --view history` 显式发现,不把缺 ACK 当作未执行。
49
+ pending 页用 `role message list <role> --current --cursor` 续读;不重复 recent、
48
50
  Profile 正文、历史 Skill 包和累计 retry 详情。Profile 由 Manifest 精确指向。
51
+ - `inbox list --view current|history|all` 优先展示精确本轮输入,与自动投递共用原始记录。
52
+ `claim`、`ack` 接受 1–16 个精确 ID,分别返回结果。ACK 表示语义处置,
53
+ 包括合法转交或被后续要求替代,不等于 Task 完成。
54
+ 精确终态只生成一次有界检查通知,不自动 ACK 或重放业务;纯提醒不会无限自唤醒。
55
+ `inbox retire` 仅显式退役已过时且可证明未提交的 Global 输入,保留原始目标和理由;
56
+ Task 输入继续使用 Leader 的 `task message retire`。
49
57
  - Run 入口最多 16 个冻结引用和每类八条活跃观察,附总数。`--view refs` 配合
50
58
  `pointerPage.nextCursor` 发现更多引用;`--view full` 显式读取完整冻结包。
51
59
  若 `input.directiveOmitted` 为 true,行动前用 `--view input` 全文读取分配指令;
@@ -0,0 +1,59 @@
1
+ # Task Project delivery scope
2
+
3
+ Project bindings, delivery obligations, observed code changes and remote merge
4
+ facts have different meanings. A Task may retain a temporary implementation in
5
+ one Project while delivering its accepted result from another. Do not delete a
6
+ binding or fabricate a Publication to express that decision.
7
+
8
+ Read the original scope instruction and `yui task remote-delivery <task>`.
9
+ An authorized user, Operator or current delivery Leader can record one bounded,
10
+ append-only decision:
11
+
12
+ ```sh
13
+ yui task project delivery task-1 project-2 \
14
+ --requirement excluded \
15
+ --reason "The accepted standalone implementation replaces the temporary one" \
16
+ --source-message message-3 \
17
+ --purpose "Only deliver the standalone implementation" \
18
+ --if-current none
19
+ ```
20
+
21
+ The Message must be original user/Operator input in this Task and the purpose
22
+ must be an exact quote. The Agent judges whether it authorizes this Project
23
+ change, including an explicit delegation of scope judgment; the CLI verifies
24
+ identity/provenance, not natural-language permission. A Role report, convenient
25
+ reason or invented quote is not authorization to abandon unmet requirements.
26
+
27
+ Use the latest `scopeDecision.id` from remote-delivery instead of `none` for
28
+ subsequent decisions. To restore delivery, record `--requirement required` with
29
+ its current guard and an appropriate original source. A stale guard fails
30
+ without overwriting the current decision. After an uncertain write, inspect
31
+ the decision before retrying; do not issue a new decision blindly.
32
+
33
+ Active and completed, unarchived Tasks with enabled execution accept these
34
+ management decisions. Completed Tasks stay completed; frozen heads, acceptance
35
+ and Publication history remain intact. No new execution or cleanup permission
36
+ is created. Archived, cancelled and stopped Tasks cannot use this operation to
37
+ resume or replace lifecycle intent.
38
+
39
+ The shared projection exposes `deliveryRequirement`, `scopeDecision` and
40
+ `deliveryRequired` alongside unchanged `codeDelivery` and merge/verification
41
+ facts. `codeProjectCount` still counts all changed or unknown Projects;
42
+ `requiredCodeProjectCount` is the obligation denominator, and
43
+ `excludedProjectCount` identifies explicit exclusions. Aggregate merged/verified
44
+ counts and `allMerged`/`allVerified` cover required code Projects only. A zero
45
+ denominator yields `status: none` (no delivery obligation), not evidence that
46
+ excluded code was merged. Missing heads/bases are unknown, never no-change.
47
+
48
+ CLI, current context, Web and ordinary archive use that projection. An unchanged
49
+ Project needs no exclusion; a required changed Project needs exact verified
50
+ remote coverage. Ordinary archive still requires settled WorkItems, inputs,
51
+ reviews, integrations/jobs and stopped execution. Physical cleanup separately
52
+ protects dirty files and excluded, unverified local commits. Logical archive can
53
+ succeed with retained resources, which remain owned and inspectable through
54
+ archive diagnostics and explicit cleanup. Exclusion never authorizes deletion
55
+ or force archive, nor changes Project-global state.
56
+
57
+ Storage 1.11 → 1.12 only admits the new event semantics through the centralized
58
+ minor upgrade chain. It does not rewrite old records or invent scope decisions.
59
+ No new table, workflow state, scan or background recovery service is added.