@zq-silk/yui 2.14.0 → 2.16.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 (70) hide show
  1. package/dist/config/configCatalog.js +1 -1
  2. package/dist/controlPlaneCli.js +1 -1
  3. package/dist/job/projectSync.js +3 -3
  4. package/dist/storage/sqliteSchema.js +12 -0
  5. package/dist/storage/storageVersions.js +1 -1
  6. package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
  7. package/dist/web/assets/assetManifest.js +23 -1
  8. package/dist/web/assets/client/app/catalog.js +40 -14
  9. package/dist/web/assets/client/app/login.js +156 -0
  10. package/dist/web/assets/client/app/main.js +33 -9
  11. package/dist/web/assets/client/app/manage.js +18 -0
  12. package/dist/web/assets/client/app/selection.js +4 -1
  13. package/dist/web/assets/client/app/taskView.js +22 -5
  14. package/dist/web/assets/client/i18n/en.js +28 -4
  15. package/dist/web/assets/client/i18n/zh-CN.js +28 -4
  16. package/dist/web/assets/client/index.js +8 -0
  17. package/dist/web/assets/client/layout/manageShell.js +30 -8
  18. package/dist/web/assets/client/layout/shortcuts.js +7 -3
  19. package/dist/web/assets/client/layout/sidebarNav.js +147 -0
  20. package/dist/web/assets/client/layout/sizing.js +5 -2
  21. package/dist/web/assets/client/layout/workspace.js +120 -21
  22. package/dist/web/assets/client/lib/api.js +5 -0
  23. package/dist/web/assets/client/lib/motion.js +59 -0
  24. package/dist/web/assets/client/lib/prefs.js +29 -2
  25. package/dist/web/assets/client/lib/session.js +21 -0
  26. package/dist/web/assets/client/lib/theme.js +11 -4
  27. package/dist/web/assets/client/ui/primitives.js +27 -0
  28. package/dist/web/assets/client/views/dock/terminal.js +4 -1
  29. package/dist/web/assets/client/views/overview.js +8 -5
  30. package/dist/web/assets/client/views/task/page.js +10 -10
  31. package/dist/web/assets/shared/brand.js +22 -0
  32. package/dist/web/assets/shared/geometry.js +7 -1
  33. package/dist/web/assets/shared/icons.js +5 -0
  34. package/dist/web/assets/shared/motion.js +74 -0
  35. package/dist/web/assets/shell/appRail.js +14 -0
  36. package/dist/web/assets/shell/dialogs.js +1 -0
  37. package/dist/web/assets/shell/dock.js +19 -6
  38. package/dist/web/assets/shell/index.js +6 -2
  39. package/dist/web/assets/shell/login.js +57 -0
  40. package/dist/web/assets/shell/manage.js +32 -20
  41. package/dist/web/assets/shell/settingsContent.js +1 -1
  42. package/dist/web/assets/shell/sidebar.js +16 -23
  43. package/dist/web/assets/styles/base.js +1 -1
  44. package/dist/web/assets/styles/index.js +4 -0
  45. package/dist/web/assets/styles/layout/responsive.js +63 -19
  46. package/dist/web/assets/styles/layout/workspace.js +53 -16
  47. package/dist/web/assets/styles/motion.js +51 -0
  48. package/dist/web/assets/styles/tokens.js +5 -3
  49. package/dist/web/assets/styles/ui/controls.js +1 -1
  50. package/dist/web/assets/styles/ui/forms.js +1 -1
  51. package/dist/web/assets/styles/ui/surfaces.js +1 -2
  52. package/dist/web/assets/styles/views/conversation.js +1 -1
  53. package/dist/web/assets/styles/views/dock.js +22 -2
  54. package/dist/web/assets/styles/views/login.js +45 -0
  55. package/dist/web/assets/styles/views/manage.js +25 -42
  56. package/dist/web/assets/styles/views/page.js +2 -3
  57. package/dist/web/assets/styles/views/records.js +1 -1
  58. package/dist/web/assets/styles/views/settings.js +1 -1
  59. package/dist/web/assets/styles/views/sidebar.js +25 -20
  60. package/dist/web/assets/styles/views/task.js +9 -15
  61. package/dist/web/assets/styles/views/terminal.js +1 -1
  62. package/dist/web/webAccess.js +129 -9
  63. package/dist/web/webServer.js +170 -14
  64. package/docs/release-workflow.md +67 -0
  65. package/docs/release-workflow.zh-CN.md +49 -0
  66. package/docs/storage-baseline.md +7 -1
  67. package/docs/storage-baseline.zh-CN.md +6 -1
  68. package/docs/testing/verification-levels.md +77 -11
  69. package/docs/testing/verification-levels.zh-CN.md +63 -9
  70. package/package.json +1 -1
@@ -1,8 +1,35 @@
1
- import { createHash, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
3
3
  import { isIP } from "node:net";
4
4
  import { networkInterfaces } from "node:os";
5
5
  import { isAbsolute } from "node:path";
6
+ export const DEFAULT_WEB_SESSION_POLICY = Object.freeze({
7
+ idleMs: 8 * 60 * 60 * 1000,
8
+ absoluteMs: 24 * 60 * 60 * 1000,
9
+ maxSessions: 32,
10
+ failureWindowMs: 60 * 1000,
11
+ maxFailures: 5,
12
+ now: () => Date.now()
13
+ });
14
+ /** A post-login destination must stay a same-origin document path. Anything
15
+ * else (absolute/protocol-relative URLs, backslashes, API/auth/asset paths)
16
+ * falls back to the workbench root. */
17
+ export function safeReturnPath(candidate) {
18
+ if (typeof candidate !== "string" || candidate.length > 2048 || !candidate.startsWith("/")
19
+ || candidate.startsWith("//") || /[\\\u0000-\u001f\u007f]/u.test(candidate))
20
+ return "/";
21
+ try {
22
+ const url = new URL(candidate, "http://return.invalid");
23
+ if (url.origin !== "http://return.invalid")
24
+ return "/";
25
+ if (/^\/(?:login|auth|api|assets|preview)(?:\/|$)/u.test(url.pathname))
26
+ return "/";
27
+ return url.pathname + url.search;
28
+ }
29
+ catch {
30
+ return "/";
31
+ }
32
+ }
6
33
  export function isLoopbackAddress(host) {
7
34
  return host === "localhost" || host === "::1" || /^127\./.test(host) && isIP(host) === 4;
8
35
  }
@@ -39,7 +66,7 @@ function passwordDigest(path) {
39
66
  const password = readFileSync(fd, "utf8").replace(/\r?\n$/, "");
40
67
  if (!/^[\x21-\x7e]{32,128}$/.test(password))
41
68
  throw new Error("invalid password");
42
- return createHash("sha256").update(`Basic ${Buffer.from(`yui:${password}`).toString("base64")}`).digest();
69
+ return createHash("sha256").update(password).digest();
43
70
  }
44
71
  catch {
45
72
  // Never include file contents, OS error text or a submitted credential.
@@ -50,7 +77,7 @@ function passwordDigest(path) {
50
77
  closeSync(fd);
51
78
  }
52
79
  }
53
- export function createWebAccess(options, interfaces = networkInterfaces()) {
80
+ export function createWebAccess(options, interfaces = networkInterfaces(), policy = DEFAULT_WEB_SESSION_POLICY) {
54
81
  validateWebAccessOptions(options);
55
82
  const lan = !isLoopbackAddress(options.host);
56
83
  const digest = options.authFile === undefined ? undefined : passwordDigest(options.authFile);
@@ -65,6 +92,41 @@ export function createWebAccess(options, interfaces = networkInterfaces()) {
65
92
  const allowed = new Set(!lan || wildcard ? [...addresses, "127.0.0.1", "::1", "localhost"] : addresses);
66
93
  const urlHost = (host) => isIP(host) === 6 ? `[${host}]` : host;
67
94
  const urls = addresses.map(host => `http://${urlHost(host)}:${options.port}`);
95
+ // Cookies are scoped by host, not port: include the port so two listeners on
96
+ // one machine cannot overwrite each other's session.
97
+ const cookieName = `yui_session_${options.port}`;
98
+ // Keyed by a hash of the opaque cookie value; the raw value is never stored.
99
+ const sessions = new Map();
100
+ const failures = new Map();
101
+ const sessionKey = (request) => {
102
+ const header = request.headers.cookie;
103
+ if (typeof header !== "string" || header.length > 8192)
104
+ return undefined;
105
+ for (const part of header.split(";")) {
106
+ const index = part.indexOf("=");
107
+ if (index > 0 && part.slice(0, index).trim() === cookieName) {
108
+ const value = part.slice(index + 1).trim();
109
+ return /^[A-Za-z0-9_-]{43}$/.test(value) ? createHash("sha256").update(value).digest("hex") : undefined;
110
+ }
111
+ }
112
+ return undefined;
113
+ };
114
+ const live = (key, touch) => {
115
+ if (key === undefined)
116
+ return false;
117
+ const session = sessions.get(key);
118
+ if (!session)
119
+ return false;
120
+ const now = policy.now();
121
+ if (now - session.seen >= policy.idleMs || now - session.created >= policy.absoluteMs) {
122
+ sessions.delete(key);
123
+ return false;
124
+ }
125
+ if (touch)
126
+ session.seen = now;
127
+ return true;
128
+ };
129
+ const cookie = (value, maxAgeSeconds) => `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
68
130
  const warnings = lan ? [
69
131
  "HTTP is not encrypted. Use only a trusted network; credentials and data are visible to network observers.",
70
132
  ...(wildcard ? ["Wildcard binding listens on all matching interfaces, including public ones. Yui does not open firewall ports or tunnels."] : []),
@@ -72,7 +134,8 @@ export function createWebAccess(options, interfaces = networkInterfaces()) {
72
134
  ] : [];
73
135
  return {
74
136
  lan,
75
- authentication: digest ? "basic" : "local",
137
+ authentication: digest ? "password" : "local",
138
+ passwordRequired: digest !== undefined,
76
139
  urls,
77
140
  warnings,
78
141
  acceptsHost(request) {
@@ -98,12 +161,69 @@ export function createWebAccess(options, interfaces = networkInterfaces()) {
98
161
  return request.headers["sec-fetch-site"] !== "cross-site"
99
162
  && (origin === undefined ? !requireOrigin : origin === `http://${request.headers.host}`);
100
163
  },
164
+ /** Every protected request/handshake checks the listener's session table. */
101
165
  authenticated(request) {
166
+ return !digest || live(sessionKey(request), true);
167
+ },
168
+ /** True when the request carries this listener's cookie name, valid or not. */
169
+ presentedSession(request) {
170
+ return typeof request.headers.cookie === "string"
171
+ && request.headers.cookie.split(";").some(part => part.trim().startsWith(`${cookieName}=`));
172
+ },
173
+ /** Opaque identity for binding long-lived connections; not a credential. */
174
+ sessionOf(request) {
175
+ const key = sessionKey(request);
176
+ return live(key, false) ? key : undefined;
177
+ },
178
+ sessionActive(key) { return !digest || live(key, false); },
179
+ login(request, password) {
102
180
  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
- }
181
+ return { ok: false, reason: "unconfigured" };
182
+ const now = policy.now();
183
+ const address = request.socket.remoteAddress ?? "unknown";
184
+ const recent = (failures.get(address) ?? []).filter(at => now - at < policy.failureWindowMs);
185
+ if (recent.length >= policy.maxFailures) {
186
+ failures.set(address, recent);
187
+ return { ok: false, reason: "throttled",
188
+ retryAfterSeconds: Math.max(1, Math.ceil((recent[0] + policy.failureWindowMs - now) / 1000)) };
189
+ }
190
+ const valid = typeof password === "string" && password.length <= 256
191
+ && timingSafeEqual(createHash("sha256").update(password).digest(), digest);
192
+ if (!valid) {
193
+ recent.push(now);
194
+ failures.delete(address);
195
+ failures.set(address, recent);
196
+ // Bound memory: forget the oldest addresses first.
197
+ while (failures.size > 1024)
198
+ failures.delete(failures.keys().next().value);
199
+ return { ok: false, reason: "invalid" };
200
+ }
201
+ failures.delete(address);
202
+ for (const [key, session] of sessions) {
203
+ if (now - session.seen >= policy.idleMs || now - session.created >= policy.absoluteMs)
204
+ sessions.delete(key);
205
+ }
206
+ while (sessions.size >= policy.maxSessions) {
207
+ let oldest;
208
+ let seen = Infinity;
209
+ for (const [key, session] of sessions)
210
+ if (session.seen < seen) {
211
+ seen = session.seen;
212
+ oldest = key;
213
+ }
214
+ sessions.delete(oldest);
215
+ }
216
+ const value = randomBytes(32).toString("base64url");
217
+ sessions.set(createHash("sha256").update(value).digest("hex"), { created: now, seen: now });
218
+ return { ok: true, cookie: cookie(value, Math.floor(policy.absoluteMs / 1000)) };
219
+ },
220
+ /** Revokes the presented session (if any) and returns a clearing cookie. */
221
+ logout(request) {
222
+ const key = sessionKey(request);
223
+ if (key !== undefined)
224
+ sessions.delete(key);
225
+ return cookie("", 0);
226
+ },
227
+ clearCookie() { return cookie("", 0); }
108
228
  };
109
229
  }
@@ -1,7 +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
+ import { createWebAccess, safeReturnPath, validateWebAccessOptions } from "./webAccess.js";
5
5
  import { CliError, usageError } from "../errors/cliError.js";
6
6
  import { parseTaskCatalogOptions } from "../context/taskCatalog.js";
7
7
  import { parseTaskSearchOptions, searchTaskBodies } from "../context/taskSearch.js";
@@ -10,9 +10,11 @@ import { readWebTaskActivity } from "./webTaskActivity.js";
10
10
  import { WebRequestRejected } from "./webMutation.js";
11
11
  import { MODELS_HTML } from "./assets/shell/models.js";
12
12
  import { TASK_SUBMISSION_INTENTS } from "../message/message.js";
13
- import { DASHBOARD_HTML, findWebAsset, prepareWebDocument, WEB_ASSET_VERSION } from "./assets/assetManifest.js";
13
+ import { DASHBOARD_HTML, findWebAsset, isLoginAsset, LOGIN_HTML, prepareWebDocument, WEB_ASSET_VERSION } from "./assets/assetManifest.js";
14
14
  import { buildWebTaskCatalog, buildWebPageSessions, buildWebTaskDetail } from "./webSnapshot.js";
15
15
  const MAX_JSON_BODY_BYTES = 16 * 1024;
16
+ const MAX_LOGIN_BODY_BYTES = 4 * 1024;
17
+ const TERMINAL_SESSION_SWEEP_MS = 30 * 1000;
16
18
  const MAX_TERMINAL_MESSAGE_BYTES = 64 * 1024;
17
19
  const MAX_TERMINAL_BUFFERED_BYTES = 1024 * 1024;
18
20
  export function parseWebCommandOptions(args) {
@@ -57,8 +59,19 @@ export function createYuiWebServer(store, dependencies = {}, options = { host: "
57
59
  perMessageDeflate: false,
58
60
  maxPayload: MAX_TERMINAL_MESSAGE_BYTES
59
61
  });
62
+ // Terminal sockets outlive their handshake: bind each to its browser session
63
+ // and close it when that session is signed out or expires.
64
+ const terminalSessions = new WeakMap();
65
+ const sweepTerminals = () => {
66
+ for (const socket of webSocketServer.clients) {
67
+ if (!access.sessionActive(terminalSessions.get(socket)))
68
+ socket.terminate();
69
+ }
70
+ };
71
+ const sweep = access.passwordRequired ? setInterval(sweepTerminals, TERMINAL_SESSION_SWEEP_MS) : undefined;
72
+ sweep?.unref();
60
73
  const server = createServer((request, response) => {
61
- void handleHttpRequest(request, response, store, httpDependencies, token, now, access).catch(() => {
74
+ void handleHttpRequest(request, response, store, httpDependencies, token, now, access, sweepTerminals).catch(() => {
62
75
  if (!response.headersSent)
63
76
  setSecurityHeaders(response);
64
77
  if (!response.writableEnded) {
@@ -67,13 +80,15 @@ export function createYuiWebServer(store, dependencies = {}, options = { host: "
67
80
  });
68
81
  });
69
82
  server.on("upgrade", (request, socket, head) => {
70
- void handleTerminalUpgrade(request, socket, head, webSocketServer, dependencies.terminal, token, access);
83
+ void handleTerminalUpgrade(request, socket, head, webSocketServer, dependencies.terminal, token, access, terminalSessions);
71
84
  });
72
85
  let terminalsClosed = false;
73
86
  const closeTerminals = () => {
74
87
  if (terminalsClosed)
75
88
  return;
76
89
  terminalsClosed = true;
90
+ if (sweep)
91
+ clearInterval(sweep);
77
92
  for (const socket of webSocketServer.clients)
78
93
  socket.terminate();
79
94
  webSocketServer.close();
@@ -98,7 +113,7 @@ export async function startYuiWebServer(store, options, dependencies = {}) {
98
113
  }
99
114
  return server;
100
115
  }
101
- async function handleHttpRequest(request, response, store, dependencies, token, now, access) {
116
+ async function handleHttpRequest(request, response, store, dependencies, token, now, access, sweepTerminals) {
102
117
  setSecurityHeaders(response);
103
118
  const method = request.method ?? "GET";
104
119
  const previewAuthenticated = dependencies.previews?.authenticated(request) === true;
@@ -106,19 +121,54 @@ async function handleHttpRequest(request, response, store, dependencies, token,
106
121
  sendJson(response, 403, { error: "Invalid Host or Origin.", disposition: "not-submitted" }, method === "HEAD");
107
122
  return;
108
123
  }
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");
112
- return;
113
- }
114
124
  let pathname;
125
+ let search;
115
126
  try {
116
- pathname = new URL(request.url ?? "/", "http://localhost").pathname;
127
+ const url = new URL(request.url ?? "/", "http://localhost");
128
+ pathname = url.pathname;
129
+ search = url.search;
117
130
  }
118
131
  catch {
119
132
  sendJson(response, 400, { error: "Invalid URL.", disposition: "not-submitted" }, method === "HEAD");
120
133
  return;
121
134
  }
135
+ // The sign-in page, its static assets and the session endpoints are the only
136
+ // routes reachable without a session. They expose no Task data or page token.
137
+ if (pathname === "/login" || pathname === "/auth/login" || pathname === "/auth/logout") {
138
+ await handleAuthRoute(request, response, pathname, search, access, sweepTerminals);
139
+ return;
140
+ }
141
+ if ((method === "GET" || method === "HEAD") && isLoginAsset(pathname)) {
142
+ const assetVersion = new URL(request.url, "http://localhost").searchParams.get("v");
143
+ const asset = findWebAsset(pathname, assetVersion);
144
+ if (asset) {
145
+ sendAsset(response, asset, method === "HEAD", assetVersion === WEB_ASSET_VERSION);
146
+ return;
147
+ }
148
+ }
149
+ if (!previewAuthenticated && !access.authenticated(request)) {
150
+ const expired = access.presentedSession(request);
151
+ if (expired)
152
+ response.setHeader("set-cookie", access.clearCookie());
153
+ // Browser navigations go to the sign-in page; data, asset, preview and
154
+ // write requests get a plain 401. No WWW-Authenticate challenge is sent,
155
+ // so browsers never show their native credential prompt.
156
+ if ((method === "GET" || method === "HEAD") && !/^\/(?:api|assets|preview)\//u.test(pathname)) {
157
+ const next = safeReturnPath(pathname + search);
158
+ const query = new URLSearchParams({ ...(next === "/" ? {} : { next }), ...(expired ? { reason: "expired" } : {}) }).toString();
159
+ response.statusCode = 303;
160
+ response.setHeader("location", `/login${query ? `?${query}` : ""}`);
161
+ response.setHeader("content-length", 0);
162
+ response.end();
163
+ return;
164
+ }
165
+ sendJson(response, 401, {
166
+ error: expired ? "Your Yui Web session has expired. Sign in again." : "Sign in to Yui Web first.",
167
+ code: "authentication-required",
168
+ disposition: "not-submitted"
169
+ }, method === "HEAD");
170
+ return;
171
+ }
122
172
  // Authentication precedes every document/preview. The page token remains
123
173
  // an independent CSRF boundary. A request body cannot
124
174
  // select Operator/Leader authority; all API reads use this boundary too.
@@ -774,7 +824,7 @@ async function handleHttpRequest(request, response, store, dependencies, token,
774
824
  response.setHeader("allow", "GET, HEAD");
775
825
  sendJson(response, 405, { error: "Method not allowed.", disposition: "not-submitted" }, method === "HEAD");
776
826
  }
777
- async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token, access) {
827
+ async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token, access, terminalSessions) {
778
828
  try {
779
829
  const url = new URL(request.url ?? "/", "http://localhost");
780
830
  if (!access.acceptsHost(request) || !access.acceptsOrigin(request, true) || !access.authenticated(request)) {
@@ -796,15 +846,26 @@ async function handleTerminalUpgrade(request, socket, head, webSocketServer, ter
796
846
  return;
797
847
  }
798
848
  const terminalRequest = parseTerminalRequest(url.searchParams);
849
+ const session = access.sessionOf(request);
799
850
  webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
800
- void connectTerminal(webSocket, terminalPort, terminalRequest);
851
+ terminalSessions.set(webSocket, session);
852
+ void connectTerminal(webSocket, terminalPort, terminalRequest, () => access.sessionActive(session));
801
853
  });
802
854
  }
803
855
  catch {
804
856
  rejectUpgrade(socket, 400, "Bad Request");
805
857
  }
806
858
  }
807
- async function connectTerminal(webSocket, terminalPort, request) {
859
+ async function connectTerminal(webSocket, terminalPort, request,
860
+ // The socket's browser session is rechecked at every boundary that would
861
+ // relay data: the periodic sweep only reclaims idle sockets, it is not the
862
+ // authorization for input or output.
863
+ authorized) {
864
+ const expire = () => {
865
+ if (webSocket.readyState === WebSocket.OPEN || webSocket.readyState === WebSocket.CONNECTING) {
866
+ webSocket.close(1008, "Session expired");
867
+ }
868
+ };
808
869
  let disconnected = false;
809
870
  const markDisconnected = () => {
810
871
  disconnected = true;
@@ -831,6 +892,11 @@ async function connectTerminal(webSocket, terminalPort, request) {
831
892
  connection.close();
832
893
  return;
833
894
  }
895
+ if (!authorized()) {
896
+ connection.close();
897
+ expire();
898
+ return;
899
+ }
834
900
  let closed = false;
835
901
  let stopData = () => { };
836
902
  let stopExit = () => { };
@@ -842,9 +908,20 @@ async function connectTerminal(webSocket, terminalPort, request) {
842
908
  stopExit();
843
909
  connection.close();
844
910
  };
911
+ const permitted = () => {
912
+ if (closed)
913
+ return false;
914
+ if (authorized())
915
+ return true;
916
+ close();
917
+ expire();
918
+ return false;
919
+ };
845
920
  webSocket.once("close", close);
846
921
  webSocket.once("error", close);
847
922
  stopData = connection.onData((data) => {
923
+ if (!permitted())
924
+ return;
848
925
  if (webSocket.bufferedAmount > MAX_TERMINAL_BUFFERED_BYTES) {
849
926
  webSocket.close(1013, "Terminal client is too slow");
850
927
  return;
@@ -856,6 +933,8 @@ async function connectTerminal(webSocket, terminalPort, request) {
856
933
  return;
857
934
  }
858
935
  stopExit = connection.onExit((exit) => {
936
+ if (!permitted())
937
+ return;
859
938
  sendWebSocket(webSocket, { type: "exit", ...exit });
860
939
  webSocket.close(1000, "Terminal detached");
861
940
  close();
@@ -865,6 +944,8 @@ async function connectTerminal(webSocket, terminalPort, request) {
865
944
  return;
866
945
  }
867
946
  webSocket.on("message", (payload, binary) => {
947
+ if (!permitted())
948
+ return;
868
949
  if (binary) {
869
950
  webSocket.close(1003, "Invalid terminal message");
870
951
  return;
@@ -882,6 +963,8 @@ async function connectTerminal(webSocket, terminalPort, request) {
882
963
  webSocket.close(1003, "Invalid terminal message");
883
964
  }
884
965
  });
966
+ if (!permitted())
967
+ return;
885
968
  sendWebSocket(webSocket, {
886
969
  type: "ready",
887
970
  readOnly: connection.readOnly,
@@ -1085,6 +1168,79 @@ async function readJsonBody(request, limit = MAX_JSON_BODY_BYTES) {
1085
1168
  throw new Error("Request body must be valid JSON.");
1086
1169
  }
1087
1170
  }
1171
+ /** Sign-in page and session endpoints. The page carries no page token; login
1172
+ * and logout require a same-origin JSON POST, so a cross-site form cannot
1173
+ * sign the browser in or out. */
1174
+ async function handleAuthRoute(request, response, pathname, search, access, sweepTerminals) {
1175
+ const method = request.method ?? "GET";
1176
+ if (pathname === "/login") {
1177
+ if (method !== "GET" && method !== "HEAD") {
1178
+ response.setHeader("allow", "GET, HEAD");
1179
+ sendJson(response, 405, { error: "Method not allowed.", disposition: "not-submitted" }, method === "HEAD");
1180
+ return;
1181
+ }
1182
+ if (!access.passwordRequired || access.authenticated(request)) {
1183
+ response.statusCode = 303;
1184
+ response.setHeader("location", safeReturnPath(new URLSearchParams(search).get("next")));
1185
+ response.setHeader("content-length", 0);
1186
+ response.end();
1187
+ return;
1188
+ }
1189
+ if (access.presentedSession(request))
1190
+ response.setHeader("set-cookie", access.clearCookie());
1191
+ sendText(response, 200, "text/html; charset=utf-8", prepareWebDocument(LOGIN_HTML, "/assets/js/app/login.js"), method === "HEAD");
1192
+ return;
1193
+ }
1194
+ if (method !== "POST") {
1195
+ response.setHeader("allow", "POST");
1196
+ sendJson(response, 405, { error: "Method not allowed.", disposition: "not-submitted" }, method === "HEAD");
1197
+ return;
1198
+ }
1199
+ if (!access.acceptsOrigin(request, true)
1200
+ || !/^application\/json(?:;|$)/iu.test(headerValue(request, "content-type") ?? "")) {
1201
+ sendJson(response, 403, { error: "Sign-in requests must be same-origin JSON.", disposition: "not-submitted" }, false);
1202
+ return;
1203
+ }
1204
+ if (pathname === "/auth/logout") {
1205
+ response.setHeader("set-cookie", access.logout(request));
1206
+ sweepTerminals();
1207
+ sendJson(response, 200, { location: access.passwordRequired ? "/login?reason=signed-out" : "/" }, false);
1208
+ return;
1209
+ }
1210
+ let body;
1211
+ try {
1212
+ body = await readJsonBody(request, MAX_LOGIN_BODY_BYTES);
1213
+ }
1214
+ catch {
1215
+ sendJson(response, 400, { error: "Invalid sign-in request.", code: "invalid-request", disposition: "not-submitted" }, false);
1216
+ return;
1217
+ }
1218
+ if (!body || typeof body !== "object" || Array.isArray(body)
1219
+ || Object.keys(body).some(key => key !== "password" && key !== "next")
1220
+ || typeof body.password !== "string") {
1221
+ sendJson(response, 400, { error: "Invalid sign-in request.", code: "invalid-request", disposition: "not-submitted" }, false);
1222
+ return;
1223
+ }
1224
+ const { password, next } = body;
1225
+ const result = access.login(request, password);
1226
+ if (result.ok) {
1227
+ response.setHeader("set-cookie", result.cookie);
1228
+ sendJson(response, 200, { location: safeReturnPath(next) }, false);
1229
+ }
1230
+ else if (result.reason === "throttled") {
1231
+ response.setHeader("retry-after", String(result.retryAfterSeconds));
1232
+ sendJson(response, 429, { error: "Too many failed sign-in attempts. Wait and try again.",
1233
+ code: "throttled", retryAfterSeconds: result.retryAfterSeconds, disposition: "not-submitted" }, false);
1234
+ }
1235
+ else if (result.reason === "unconfigured") {
1236
+ sendJson(response, 404, { error: "Password sign-in is not configured for this listener.",
1237
+ code: "password-not-configured", disposition: "not-submitted" }, false);
1238
+ }
1239
+ else {
1240
+ // Never echo the submitted value.
1241
+ sendJson(response, 401, { error: "Incorrect password.", code: "invalid-credential", disposition: "not-submitted" }, false);
1242
+ }
1243
+ }
1088
1244
  function setSecurityHeaders(response) {
1089
1245
  response.setHeader("cache-control", "no-store");
1090
1246
  response.setHeader("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
@@ -51,6 +51,63 @@ merely to avoid a version decision. Package versions remain distinct from protoc
51
51
  and persistent-storage contracts: their compatibility checks and required migrations
52
52
  are unchanged, and the storage rules below do not authorize a package release.
53
53
 
54
+ ### 2.16.0 compatibility and migration notes
55
+
56
+ This minor refreshes the Web workbench (PR #484). One global navigation rail
57
+ (Workbench / Models / Settings) is shared by the workbench and management pages,
58
+ and the task sidebar can be collapsed. Dialogs, menus, presses and column layout
59
+ changes use one motion source; reduced-motion preferences, narrow layouts and
60
+ hidden documents get instant updates. The 「結」 brand glyph is replaced by a
61
+ vector capital-Y mark drawn from the active theme's tokens, and each theme has a
62
+ matching SVG favicon that follows theme switches.
63
+
64
+ These are presentation changes only. Web APIs, sign-in sessions, `--auth-file`,
65
+ CLI commands and Controller/Host protocols are unchanged. Scripts or browser
66
+ automation that matched the old brand glyph or previous navigation markup must
67
+ use the current page structure. Storage remains **1.21**; there is no migration.
68
+
69
+ With separate upgrade authorization, use `yui update --version 2.16.0`; the
70
+ updater owns preflight, backup and Controller handover. A running Web listener
71
+ serves the new assets after it is restarted. Release gates reuse exact-commit
72
+ Core CI and the existing fresh-install matrix (Linux x64, macOS x64/arm64,
73
+ Node 20/22/24/26). Publishing does not update the global CLI, migrate a
74
+ production Home, restart its Controller or deploy a production Web listener.
75
+
76
+ ### 2.15.0 compatibility and migration notes
77
+
78
+ This minor replaces the browser's HTTP Basic prompt with a standalone English/
79
+ Chinese sign-in page, inline errors and safe return to the requested page.
80
+ The credential file format and `--auth-file` stay unchanged, but HTTP Basic
81
+ `Authorization` headers are no longer accepted. Scripts must obtain a session
82
+ through same-origin JSON `POST /auth/login` and retain its cookie; status now
83
+ reports `local`/`password`, not `local`/`basic`. See [Web access](web-access.md).
84
+
85
+ Sessions are listener-local and in memory, with an 8-hour inactivity limit and
86
+ 24-hour absolute limit. There is no sign-out button; the backend logout endpoint
87
+ and session revocation remain. Every terminal relay boundary checks the session.
88
+ Stopping or restarting Web ends all sessions. HTTP remains unencrypted and is
89
+ for trusted networks only; this is not multi-user authentication or public TLS
90
+ hosting.
91
+
92
+ This release also includes timezone-aligned default Project sync (PR #481):
93
+ new defaults use `0 */6 * * *` with a captured explicit timezone. Storage moves
94
+ from **1.20 to 1.21** through the centralized migration. Only provably unedited
95
+ revision-1 built-in defaults are converted, with original bytes retained;
96
+ higher revisions and customized/disabled plans are preserved. See
97
+ [Project sync](project-sync.md) for explicit schedule updates. Earlier supported
98
+ Homes use the existing contiguous same-major migration chain.
99
+
100
+ With separate upgrade authorization, use `yui update --version 2.15.0`; the
101
+ updater owns backup, migration and Controller handover. Do not edit storage
102
+ manually or open upgraded Homes with an older binary. Minor numbering is not
103
+ a blanket compatibility promise.
104
+
105
+ Release gates reuse exact-commit Core CI and install the same checksummed
106
+ runtime archive on Linux x64 and macOS x64/arm64 with Node 20/22/24/26 before
107
+ npm publication. These are isolated deterministic checks, not real-model
108
+ validation. Publishing does not update the global CLI, migrate a production
109
+ Home, restart its Controller or deploy a production Web listener.
110
+
54
111
  ### 2.14.0 compatibility and migration notes
55
112
 
56
113
  The `v2.13.0` tag is retained as failed release evidence: its publication gates
@@ -827,6 +884,16 @@ The tag-triggered `publish.yml` workflow is the only maintained release smoke.
827
884
  It reuses the exact commit that passed core CI and adds only artifact assembly,
828
885
  fresh installation, and provenance checks required to publish.
829
886
 
887
+ Business contracts and full Jobs/Schedules wiring run once in Core CI on Linux
888
+ Node 24. Release still installs the same checksummed archive on all three
889
+ platforms × Node 20/22/24/26: every install checks CLI/setup/native helpers,
890
+ Doctor/PTY and real SQLite SQL/GC. Node 24 additionally checks real process
891
+ lifecycle on each platform. See the [coverage table](testing/verification-levels.md#ci-and-release).
892
+ Dependency installation does not build the project; each source job builds once
893
+ and verifies native dependencies afterwards. Smoke logs separate phase timings.
894
+ Post-publication registry checks and byte-identical GitHub assets are distinct
895
+ evidence and are not replaced by pre-publication smoke.
896
+
830
897
  That workflow authenticates through npm Trusted Publishing (OIDC), so the
831
898
  release identity lives in two places outside the tag: `repository`, `bugs`, and
832
899
  `homepage` are copied verbatim from the source `package.json` into the published
@@ -36,6 +36,48 @@ Agent 选择一个预先声明的计划,设施从持久状态驱动该计划
36
36
  不能将不兼容冒称为兼容,也不为回避版本选择新增历史兼容机制。包版本与协议、
37
37
  持久存储契约仍然独立:兼容性检查和必要迁移要求不变,下文存储规则不授予发版权限。
38
38
 
39
+ ### 2.16.0 兼容性及迁移说明
40
+
41
+ 本 minor 更新 Web 工作台(PR #484):工作台与管理页共用一条全局导航栏
42
+ (工作台 / 模型 / 设置),任务侧栏可折叠。弹窗、菜单、按压与分栏布局变化使用统一动效来源;
43
+ 开启减少动态效果、窄屏布局或页面不可见时直接更新。品牌标志由「結」改为矢量大写 Y,
44
+ 颜色取自当前主题令牌;每个主题配有对应的 SVG favicon,随主题切换。
45
+
46
+ 以上仅为界面变化。Web API、登录会话、`--auth-file`、CLI 命令以及 Controller/Host 协议均不变。
47
+ 依赖旧品牌字形或旧导航结构的脚本、浏览器自动化需改用当前页面结构。存储仍为 **1.21**,无迁移。
48
+
49
+ 另行获得升级授权后,使用 `yui update --version 2.16.0`;更新器负责预检、备份与 Controller 交接。
50
+ 正在运行的 Web 监听器需重启后才会提供新资源。发布门禁复用同一提交的 Core CI 与既有全新安装矩阵
51
+ (Linux x64、macOS x64/arm64,Node 20/22/24/26)。发布不会更新全局 CLI、迁移生产 Home、
52
+ 重启其 Controller 或部署生产 Web 监听器。
53
+
54
+ ### 2.15.0 兼容性及迁移说明
55
+
56
+ 本 minor 以独立中英文登录页替代浏览器 HTTP Basic 弹窗,支持页内错误提示及安全回跳。
57
+ 凭证文件格式和 `--auth-file` 不变,但不再接受 HTTP Basic `Authorization` 请求头。
58
+ 脚本须通过同源 JSON `POST /auth/login` 获取会话并保留 Cookie;状态中的认证方式改为
59
+ `local`/`password`,不再是 `local`/`basic`。详见 [Web 访问](web-access.md)。
60
+
61
+ 会话仅保存在当前监听器内存中,闲置上限为 8 小时,绝对上限为 24 小时。
62
+ 界面不提供登出按钮,但保留后端注销与会话撤销能力;终端每个数据转发边界均检查会话。
63
+ 停止或重启 Web 会结束所有会话。HTTP 仍未加密,只用于可信网络;
64
+ 本功能不是多用户认证或公网 TLS 托管。
65
+
66
+ 本版本也包含按时区时钟对齐的默认 Project 同步(PR #481):
67
+ 新默认规则使用 `0 */6 * * *`,保存明确的时区。存储通过集中迁移从 **1.20 升至 1.21**。
68
+ 仅转换可证明未编辑过的 revision-1 内置默认规则,并保留原始字节;
69
+ 更高修订以及自定义、禁用计划保持不变。显式切换计划的方法见
70
+ [Project 同步](project-sync.zh-CN.md)。更早的受支持 Home 使用既有同 major 连续迁移链。
71
+
72
+ 获得独立升级授权后可执行 `yui update --version 2.15.0`,由 updater 负责备份、
73
+ 迁移及 Controller 交接。不要手工修改存储或以旧二进制打开已升级 Home;
74
+ minor 编号不是一般性的向后兼容承诺。
75
+
76
+ 发布门禁复用精确 commit 的 Core CI,并在 Linux x64、macOS x64/arm64 与
77
+ Node 20/22/24/26 的组合上全新安装同一份带校验和的运行时包,全部通过后发布 npm。
78
+ 这些是隔离、确定性验证,不是真实模型验证。发布不会更新全局 CLI、迁移生产 Home、
79
+ 重启其 Controller 或部署生产 Web 监听器。
80
+
39
81
  ### 2.14.0 兼容性及迁移说明
40
82
 
41
83
  保留 `v2.13.0` 标签作为失败发布证据:其发布门禁在 npm 发布前失败。
@@ -627,6 +669,13 @@ yui task grant revoke task-15 capability-grant-1
627
669
  由 tag 触发的 `publish.yml` 工作流是唯一维护的发布冒烟。它复用那个通过了 core CI 的
628
670
  确切 commit,只增加发布所需的产物装配、全新安装和 provenance 检查。
629
671
 
672
+ 业务契约与完整 Jobs/Schedules 链路仅在 Core CI 的 Linux Node 24 执行一次。
673
+ 发布仍在三平台 × Node 20/22/24/26 全部安装同一个已校验 tarball:每组都验证
674
+ CLI/setup/原生 helper、Doctor/PTY 和真实 SQLite SQL/GC;各平台 Node 24 额外验证
675
+ 真实进程生命周期。参见[分层覆盖表](testing/verification-levels.zh-CN.md#ci-与发布)。
676
+ 依赖安装不再隐含构建,每个源码作业只构建一次,再验证原生依赖;smoke 输出分段耗时。
677
+ 发布后的 registry 检查和 GitHub 资产逐字节核验是独立证据,不被发布前 smoke 替代。
678
+
630
679
  该工作流通过 npm Trusted Publishing(OIDC)认证,因此发布身份存在于 tag 之外的两处:
631
680
  `repository`、`bugs` 和 `homepage` 由 `assemble-runtime-package.mjs` 从源 `package.json`
632
681
  逐字复制进已发布 manifest,而该包的 npm Trusted Publisher 条目点明 GitHub owner、
@@ -2,7 +2,13 @@
2
2
 
3
3
  # Storage baseline 1.0
4
4
 
5
- Current storage is 1.20. The 1.19 → 1.20 transition adds Agent Job configurations,
5
+ Current storage is 1.21. The 1.20 → 1.21 transition converts provably unedited
6
+ built-in Project sync defaults to timezone-explicit clock schedules, archiving
7
+ their original bytes under migration 21. Ambiguous higher revisions and custom
8
+ plans remain unchanged; no Git work or reauthorization occurs. See
9
+ [Project sync](project-sync.md) for the conservative upgrade boundary.
10
+
11
+ The 1.19 → 1.20 transition adds Agent Job configurations,
6
12
  optional Job ownership on Global Roles, and the `agent_job_runs` table for
7
13
  exact original-message/native-attempt evidence and explicit business results.
8
14
  Existing Role, Session, process Run and artifact identities are unchanged;
@@ -2,7 +2,12 @@
2
2
 
3
3
  # 存储基线 1.0
4
4
 
5
- 当前存储为 1.20。1.19 → 1.20 增加 Agent Job 配置、Global Role 的可选 Job
5
+ 当前存储为 1.21。1.20 → 1.21 将可证明未经修改的旧内置 Project 同步默认计划
6
+ 转换为显式时区的整点计划,在 migration 21 下归档原始字节。更高 revision 的
7
+ 来源不明确计划及自定义计划保留,不执行 Git 或重新授权。保守转换边界见
8
+ [Project 同步](project-sync.zh-CN.md)。
9
+
10
+ 1.19 → 1.20 增加 Agent Job 配置、Global Role 的可选 Job
6
11
  归属和 `agent_job_runs` 表,保存原始消息、准确原生尝试与显式业务结果。
7
12
  保留现有 Role、Session、进程 Run 和固定产物身份;迁移不创建 Agent 或原生会话。
8
13