@zq-silk/yui 2.12.0 → 2.15.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 (37) hide show
  1. package/README.md +5 -0
  2. package/dist/config/configCatalog.js +1 -1
  3. package/dist/controlPlaneCli.js +1 -1
  4. package/dist/controller/runtime.js +2 -0
  5. package/dist/job/projectSync.js +3 -3
  6. package/dist/storage/sqliteSchema.js +12 -0
  7. package/dist/storage/storageVersions.js +1 -1
  8. package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
  9. package/dist/web/assets/assetManifest.js +23 -1
  10. package/dist/web/assets/client/app/login.js +156 -0
  11. package/dist/web/assets/client/app/settings.js +4 -0
  12. package/dist/web/assets/client/i18n/en.js +64 -1
  13. package/dist/web/assets/client/i18n/zh-CN.js +64 -1
  14. package/dist/web/assets/client/index.js +6 -0
  15. package/dist/web/assets/client/lib/api.js +5 -0
  16. package/dist/web/assets/client/lib/session.js +21 -0
  17. package/dist/web/assets/client/views/dock/terminal.js +4 -1
  18. package/dist/web/assets/client/views/permissions.js +129 -0
  19. package/dist/web/assets/shared/icons.js +1 -0
  20. package/dist/web/assets/shared/manageNav.js +1 -0
  21. package/dist/web/assets/shell/login.js +56 -0
  22. package/dist/web/assets/shell/settingsContent.js +4 -0
  23. package/dist/web/assets/styles/index.js +2 -0
  24. package/dist/web/assets/styles/views/login.js +42 -0
  25. package/dist/web/assets/styles/views/settings.js +13 -0
  26. package/dist/web/webAccess.js +129 -9
  27. package/dist/web/webPermissions.js +97 -0
  28. package/dist/web/webServer.js +209 -15
  29. package/docs/authorization.md +35 -0
  30. package/docs/release-workflow.md +80 -0
  31. package/docs/release-workflow.zh-CN.md +60 -0
  32. package/docs/storage-baseline.md +7 -1
  33. package/docs/storage-baseline.zh-CN.md +6 -1
  34. package/docs/testing/verification-levels.md +77 -11
  35. package/docs/testing/verification-levels.zh-CN.md +63 -9
  36. package/i18n/README.zh-CN.md +4 -0
  37. package/package.json +1 -1
@@ -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.
@@ -126,6 +176,44 @@ async function handleHttpRequest(request, response, store, dependencies, token,
126
176
  sendJson(response, 403, { error: "Invalid Yui web token.", disposition: "not-submitted" }, method === "HEAD");
127
177
  return;
128
178
  }
179
+ if (pathname === "/api/permissions" || pathname.startsWith("/api/permissions/")) {
180
+ if (method !== "GET") {
181
+ sendJson(response, 405, { error: "Permission templates are read-only.", disposition: "not-submitted" }, method === "HEAD");
182
+ return;
183
+ }
184
+ const allowed = {
185
+ "/api/permissions": [],
186
+ "/api/permissions/commands": ["q", "role", "category", "cursor"],
187
+ "/api/permissions/command": ["id", "role"],
188
+ "/api/permissions/capabilities": ["q", "cursor"]
189
+ };
190
+ if (!Object.hasOwn(allowed, pathname)) {
191
+ sendJson(response, 404, { error: "Unknown permission template route." }, false);
192
+ return;
193
+ }
194
+ if (!dependencies.permissions) {
195
+ sendJson(response, 503, { error: "Permission templates unavailable." }, false);
196
+ return;
197
+ }
198
+ try {
199
+ const query = new URL(request.url, "http://localhost").searchParams;
200
+ if ([...query.keys()].some(key => !allowed[pathname].includes(key) || query.getAll(key).length !== 1)) {
201
+ throw new Error("Unknown or repeated template query.");
202
+ }
203
+ const params = Object.fromEntries(query);
204
+ const service = dependencies.permissions;
205
+ const value = pathname === "/api/permissions" ? service.index()
206
+ : pathname.endsWith("/commands") ? service.commands(params)
207
+ : pathname.endsWith("/command") ? service.command(params.id ?? "", params.role)
208
+ : service.capabilities(params);
209
+ sendJson(response, 200, value, false);
210
+ }
211
+ catch (error) {
212
+ sendJson(response, 400, { error: error instanceof Error ? error.message : "Invalid template query.",
213
+ disposition: "not-submitted" }, false);
214
+ }
215
+ return;
216
+ }
129
217
  if (pathname.startsWith("/preview/") && dependencies.previews) {
130
218
  await dependencies.previews.serve(request, response);
131
219
  return;
@@ -632,7 +720,7 @@ async function handleHttpRequest(request, response, store, dependencies, token,
632
720
  try {
633
721
  const assetVersion = new URL(request.url, "http://localhost").searchParams.get("v");
634
722
  const asset = findWebAsset(pathname, assetVersion);
635
- const managementPage = /^\/(?:models(?:\/(?:providers|catalog|quota))?|settings(?:\/(?:preferences|skills|jobs|server|connection))?)\/?$/.test(pathname);
723
+ const managementPage = /^\/(?:models(?:\/(?:providers|catalog|quota))?|settings(?:\/(?:preferences|skills|jobs|permissions|server|connection))?)\/?$/.test(pathname);
636
724
  if (pathname === "/" || pathname === "/index.html" || managementPage) {
637
725
  sendText(response, 200, "text/html; charset=utf-8", prepareWebDocument(managementPage ? MODELS_HTML.replace("__YUI_WEB_TOKEN__", token) : dashboardHtml(token), managementPage ? "/assets/js/app/manage.js" : "/assets/app.js"), method === "HEAD");
638
726
  }
@@ -736,7 +824,7 @@ async function handleHttpRequest(request, response, store, dependencies, token,
736
824
  response.setHeader("allow", "GET, HEAD");
737
825
  sendJson(response, 405, { error: "Method not allowed.", disposition: "not-submitted" }, method === "HEAD");
738
826
  }
739
- async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token, access) {
827
+ async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token, access, terminalSessions) {
740
828
  try {
741
829
  const url = new URL(request.url ?? "/", "http://localhost");
742
830
  if (!access.acceptsHost(request) || !access.acceptsOrigin(request, true) || !access.authenticated(request)) {
@@ -758,15 +846,26 @@ async function handleTerminalUpgrade(request, socket, head, webSocketServer, ter
758
846
  return;
759
847
  }
760
848
  const terminalRequest = parseTerminalRequest(url.searchParams);
849
+ const session = access.sessionOf(request);
761
850
  webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
762
- void connectTerminal(webSocket, terminalPort, terminalRequest);
851
+ terminalSessions.set(webSocket, session);
852
+ void connectTerminal(webSocket, terminalPort, terminalRequest, () => access.sessionActive(session));
763
853
  });
764
854
  }
765
855
  catch {
766
856
  rejectUpgrade(socket, 400, "Bad Request");
767
857
  }
768
858
  }
769
- 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
+ };
770
869
  let disconnected = false;
771
870
  const markDisconnected = () => {
772
871
  disconnected = true;
@@ -793,6 +892,11 @@ async function connectTerminal(webSocket, terminalPort, request) {
793
892
  connection.close();
794
893
  return;
795
894
  }
895
+ if (!authorized()) {
896
+ connection.close();
897
+ expire();
898
+ return;
899
+ }
796
900
  let closed = false;
797
901
  let stopData = () => { };
798
902
  let stopExit = () => { };
@@ -804,9 +908,20 @@ async function connectTerminal(webSocket, terminalPort, request) {
804
908
  stopExit();
805
909
  connection.close();
806
910
  };
911
+ const permitted = () => {
912
+ if (closed)
913
+ return false;
914
+ if (authorized())
915
+ return true;
916
+ close();
917
+ expire();
918
+ return false;
919
+ };
807
920
  webSocket.once("close", close);
808
921
  webSocket.once("error", close);
809
922
  stopData = connection.onData((data) => {
923
+ if (!permitted())
924
+ return;
810
925
  if (webSocket.bufferedAmount > MAX_TERMINAL_BUFFERED_BYTES) {
811
926
  webSocket.close(1013, "Terminal client is too slow");
812
927
  return;
@@ -818,6 +933,8 @@ async function connectTerminal(webSocket, terminalPort, request) {
818
933
  return;
819
934
  }
820
935
  stopExit = connection.onExit((exit) => {
936
+ if (!permitted())
937
+ return;
821
938
  sendWebSocket(webSocket, { type: "exit", ...exit });
822
939
  webSocket.close(1000, "Terminal detached");
823
940
  close();
@@ -827,6 +944,8 @@ async function connectTerminal(webSocket, terminalPort, request) {
827
944
  return;
828
945
  }
829
946
  webSocket.on("message", (payload, binary) => {
947
+ if (!permitted())
948
+ return;
830
949
  if (binary) {
831
950
  webSocket.close(1003, "Invalid terminal message");
832
951
  return;
@@ -844,6 +963,8 @@ async function connectTerminal(webSocket, terminalPort, request) {
844
963
  webSocket.close(1003, "Invalid terminal message");
845
964
  }
846
965
  });
966
+ if (!permitted())
967
+ return;
847
968
  sendWebSocket(webSocket, {
848
969
  type: "ready",
849
970
  readOnly: connection.readOnly,
@@ -1047,6 +1168,79 @@ async function readJsonBody(request, limit = MAX_JSON_BODY_BYTES) {
1047
1168
  throw new Error("Request body must be valid JSON.");
1048
1169
  }
1049
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
+ }
1050
1244
  function setSecurityHeaders(response) {
1051
1245
  response.setHeader("cache-control", "no-store");
1052
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'");
@@ -111,6 +111,41 @@ remains a limited template, while execution reports the original storage/identit
111
111
  failure. Complete launch hints can still show a role's static help on a broken
112
112
  Home; they cannot authorize reads or effects.
113
113
 
114
+ ## Read-only Web templates
115
+
116
+ Open **Settings → Command permissions** (`/settings/permissions`) to compare
117
+ CLI discovery for ordinary terminals/unmanaged sessions, Operator, GlobalRole,
118
+ Task Leader, Worker and Reviewer. Configured GlobalRole names are selectable;
119
+ they use the existing global discovery audience, not a new role impersonation
120
+ path. Search, category/role filters and 30-row pages keep the view bounded.
121
+ Expand a command for its audience-filtered usage and scope explanation.
122
+
123
+ “Visible” means the existing `discoveryCommandTree` retains the executable
124
+ command; “Hidden” means it does not. Neither is an exact authorization
125
+ assessment. Worker and Reviewer can share a template while having different
126
+ Assignment/Review and workspace constraints. Planning/delivery, lifecycle,
127
+ revocation and resource/Grant checks remain in their existing execution owners.
128
+ The directory cannot express every domain condition and this page does not
129
+ attempt to infer them from command names.
130
+
131
+ The adjacent **Built-in capability contracts** view projects
132
+ `BUILTIN_CAPABILITIES`, the same descriptors registered in `CapabilityRegistry`,
133
+ including required permission categories, input names, declared effect and
134
+ semantic source. It does not invent a CLI-to-capability mapping or interpret
135
+ those categories as role grants. Task-local plugin descriptors are excluded.
136
+ Exact `hidden` / `requestable` / `authorized` observations still require the
137
+ existing authenticated discovery path and target input; this page performs no
138
+ such assessment and emits no `access` field.
139
+
140
+ The `/api/permissions` metadata, `/commands`, `/command` and `/capabilities`
141
+ routes are GET-only, use the existing Web authentication, Host/Origin and page
142
+ token protection, and reject target/identity arguments. The only Store read is
143
+ the configured global role list, projected to names. No Session, Task contents,
144
+ Grant, plugin instance or Provider is read/acquired, and no operation is invoked.
145
+ New catalog commands enter automatically; do not maintain a Web ACL.
146
+ `web-permissions.test.js` checks full projection coverage, option filtering,
147
+ private-read/write exclusion and the HTTP read-only boundary.
148
+
114
149
  ## Verification and maintenance
115
150
 
116
151
  `cli-authority.test.js` exercises isolated launcher-backed public reads, Draft
@@ -51,6 +51,76 @@ 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.15.0 compatibility and migration notes
55
+
56
+ This minor replaces the browser's HTTP Basic prompt with a standalone English/
57
+ Chinese sign-in page, inline errors and safe return to the requested page.
58
+ The credential file format and `--auth-file` stay unchanged, but HTTP Basic
59
+ `Authorization` headers are no longer accepted. Scripts must obtain a session
60
+ through same-origin JSON `POST /auth/login` and retain its cookie; status now
61
+ reports `local`/`password`, not `local`/`basic`. See [Web access](web-access.md).
62
+
63
+ Sessions are listener-local and in memory, with an 8-hour inactivity limit and
64
+ 24-hour absolute limit. There is no sign-out button; the backend logout endpoint
65
+ and session revocation remain. Every terminal relay boundary checks the session.
66
+ Stopping or restarting Web ends all sessions. HTTP remains unencrypted and is
67
+ for trusted networks only; this is not multi-user authentication or public TLS
68
+ hosting.
69
+
70
+ This release also includes timezone-aligned default Project sync (PR #481):
71
+ new defaults use `0 */6 * * *` with a captured explicit timezone. Storage moves
72
+ from **1.20 to 1.21** through the centralized migration. Only provably unedited
73
+ revision-1 built-in defaults are converted, with original bytes retained;
74
+ higher revisions and customized/disabled plans are preserved. See
75
+ [Project sync](project-sync.md) for explicit schedule updates. Earlier supported
76
+ Homes use the existing contiguous same-major migration chain.
77
+
78
+ With separate upgrade authorization, use `yui update --version 2.15.0`; the
79
+ updater owns backup, migration and Controller handover. Do not edit storage
80
+ manually or open upgraded Homes with an older binary. Minor numbering is not
81
+ a blanket compatibility promise.
82
+
83
+ Release gates reuse exact-commit Core CI and install the same checksummed
84
+ runtime archive on Linux x64 and macOS x64/arm64 with Node 20/22/24/26 before
85
+ npm publication. These are isolated deterministic checks, not real-model
86
+ validation. Publishing does not update the global CLI, migrate a production
87
+ Home, restart its Controller or deploy a production Web listener.
88
+
89
+ ### 2.14.0 compatibility and migration notes
90
+
91
+ The `v2.13.0` tag is retained as failed release evidence: its publication gates
92
+ failed before npm publication. This replacement minor retains the feature below
93
+ and fixes the package-smoke handover observation: one Controller snapshot and a
94
+ bounded 60-second multi-turn wait, preserving the input, Host identity, terminal
95
+ and no-replay assertions. Production runtime timeouts and release gates are
96
+ unchanged; the full fresh-install matrix must pass before publication.
97
+
98
+ This minor adds Settings → Permissions: a read-only command-by-role discovery
99
+ matrix and built-in capability contracts, with bounded search, filters and
100
+ details. The view derives from the existing command catalog, discovery projection
101
+ and capability registry; it is not a second ACL or an authorization engine.
102
+
103
+ Role-template visibility is not an exact invocation's authorization result.
104
+ Task, Project, Assignment, lifecycle, resource and Grant boundaries still apply
105
+ at execution time. Viewing a role does not impersonate it, execute a command,
106
+ issue or revoke authorization, or consume the viewed operation's Grant.
107
+ The new GET endpoints retain the existing Web access and request protections.
108
+ Existing CLI authorization, discovery and default permissions are unchanged.
109
+ See [authorization](authorization.md).
110
+
111
+ Storage remains **1.20**; this feature adds no storage migration or protocol
112
+ change. Earlier supported Homes still use the existing contiguous same-major
113
+ migration chain. With separate update authorization, use
114
+ `yui update --version 2.14.0`; the updater owns preflight, backup, migration when
115
+ needed, and Controller handover. Do not open upgraded Homes with an older binary
116
+ or edit storage manually. Minor numbering is not a general compatibility promise.
117
+
118
+ Release gates require exact-commit Core CI and fresh installation of the same
119
+ runtime tarball on Linux x64 and macOS x64/arm64 with Node 20/22/24/26.
120
+ These are deterministic isolated checks, not real-model validation. Publication
121
+ does not update a global CLI, migrate a production Home, replace its Controller,
122
+ or deploy or start a production Web listener.
123
+
54
124
  ### 2.12.0 compatibility and migration notes
55
125
 
56
126
  This minor completes the scoped Job platform: explicit business results and fixed
@@ -792,6 +862,16 @@ The tag-triggered `publish.yml` workflow is the only maintained release smoke.
792
862
  It reuses the exact commit that passed core CI and adds only artifact assembly,
793
863
  fresh installation, and provenance checks required to publish.
794
864
 
865
+ Business contracts and full Jobs/Schedules wiring run once in Core CI on Linux
866
+ Node 24. Release still installs the same checksummed archive on all three
867
+ platforms × Node 20/22/24/26: every install checks CLI/setup/native helpers,
868
+ Doctor/PTY and real SQLite SQL/GC. Node 24 additionally checks real process
869
+ lifecycle on each platform. See the [coverage table](testing/verification-levels.md#ci-and-release).
870
+ Dependency installation does not build the project; each source job builds once
871
+ and verifies native dependencies afterwards. Smoke logs separate phase timings.
872
+ Post-publication registry checks and byte-identical GitHub assets are distinct
873
+ evidence and are not replaced by pre-publication smoke.
874
+
795
875
  That workflow authenticates through npm Trusted Publishing (OIDC), so the
796
876
  release identity lives in two places outside the tag: `repository`, `bugs`, and
797
877
  `homepage` are copied verbatim from the source `package.json` into the published
@@ -36,6 +36,59 @@ Agent 选择一个预先声明的计划,设施从持久状态驱动该计划
36
36
  不能将不兼容冒称为兼容,也不为回避版本选择新增历史兼容机制。包版本与协议、
37
37
  持久存储契约仍然独立:兼容性检查和必要迁移要求不变,下文存储规则不授予发版权限。
38
38
 
39
+ ### 2.15.0 兼容性及迁移说明
40
+
41
+ 本 minor 以独立中英文登录页替代浏览器 HTTP Basic 弹窗,支持页内错误提示及安全回跳。
42
+ 凭证文件格式和 `--auth-file` 不变,但不再接受 HTTP Basic `Authorization` 请求头。
43
+ 脚本须通过同源 JSON `POST /auth/login` 获取会话并保留 Cookie;状态中的认证方式改为
44
+ `local`/`password`,不再是 `local`/`basic`。详见 [Web 访问](web-access.md)。
45
+
46
+ 会话仅保存在当前监听器内存中,闲置上限为 8 小时,绝对上限为 24 小时。
47
+ 界面不提供登出按钮,但保留后端注销与会话撤销能力;终端每个数据转发边界均检查会话。
48
+ 停止或重启 Web 会结束所有会话。HTTP 仍未加密,只用于可信网络;
49
+ 本功能不是多用户认证或公网 TLS 托管。
50
+
51
+ 本版本也包含按时区时钟对齐的默认 Project 同步(PR #481):
52
+ 新默认规则使用 `0 */6 * * *`,保存明确的时区。存储通过集中迁移从 **1.20 升至 1.21**。
53
+ 仅转换可证明未编辑过的 revision-1 内置默认规则,并保留原始字节;
54
+ 更高修订以及自定义、禁用计划保持不变。显式切换计划的方法见
55
+ [Project 同步](project-sync.zh-CN.md)。更早的受支持 Home 使用既有同 major 连续迁移链。
56
+
57
+ 获得独立升级授权后可执行 `yui update --version 2.15.0`,由 updater 负责备份、
58
+ 迁移及 Controller 交接。不要手工修改存储或以旧二进制打开已升级 Home;
59
+ minor 编号不是一般性的向后兼容承诺。
60
+
61
+ 发布门禁复用精确 commit 的 Core CI,并在 Linux x64、macOS x64/arm64 与
62
+ Node 20/22/24/26 的组合上全新安装同一份带校验和的运行时包,全部通过后发布 npm。
63
+ 这些是隔离、确定性验证,不是真实模型验证。发布不会更新全局 CLI、迁移生产 Home、
64
+ 重启其 Controller 或部署生产 Web 监听器。
65
+
66
+ ### 2.14.0 兼容性及迁移说明
67
+
68
+ 保留 `v2.13.0` 标签作为失败发布证据:其发布门禁在 npm 发布前失败。
69
+ 本次替代 minor 保留下述功能,并修正包 smoke 的接续观察方式:使用一次 Controller
70
+ 快照和有界的 60 秒多回合等待,保留输入、Host 身份、终态及不重放断言。
71
+ 生产运行时超时和发布门禁不变;发布前仍须通过完整的全新安装矩阵。
72
+
73
+ 本 minor 新增 Settings → Permissions:命令×角色的只读发现矩阵与内置能力契约,
74
+ 支持有界搜索、筛选和详情。视图派生自现有 command catalog、discovery 投影及
75
+ capability registry,不维护第二份 ACL,也不是新的授权引擎。
76
+
77
+ 角色模板的可见性不代表某次具体调用已获授权。Task、Project、Assignment、生命周期、
78
+ 资源和 Grant 边界仍在执行时检查。查看角色不会冒充该角色、执行命令、签发或撤销授权,
79
+ 也不会消费被查看操作的 Grant。新增 GET 接口沿用现有 Web 访问及请求保护。
80
+ 既有 CLI 授权、命令发现和默认权限不变。参见[授权说明](authorization.md)。
81
+
82
+ 存储保持 **1.20**;本功能不新增存储迁移或协议变更。更早的受支持 Home 仍通过现有
83
+ 连续同 major 迁移链升级。另获更新授权后使用 `yui update --version 2.14.0`;
84
+ 更新器负责预检、备份、必要迁移及 Controller 接续。不要用旧二进制打开已升级的 Home,
85
+ 也不要手改存储。minor 编号不是通用的兼容性承诺。
86
+
87
+ 发布门禁要求精确提交的 Core CI,并在 Linux x64、macOS x64/arm64 与
88
+ Node 20/22/24/26 上全新安装同一个 runtime tarball。这些是确定性隔离检查,
89
+ 不是真实模型验证。发布不会更新全局 CLI、迁移生产 Home、替换其 Controller,
90
+ 也不会部署或启动生产 Web。
91
+
39
92
  ### 2.12.0 兼容性及迁移说明
40
93
 
41
94
  本 minor 完成作用域明确的 Job 平台:显式业务结果及固定产物、Schedule 目标与服务
@@ -601,6 +654,13 @@ yui task grant revoke task-15 capability-grant-1
601
654
  由 tag 触发的 `publish.yml` 工作流是唯一维护的发布冒烟。它复用那个通过了 core CI 的
602
655
  确切 commit,只增加发布所需的产物装配、全新安装和 provenance 检查。
603
656
 
657
+ 业务契约与完整 Jobs/Schedules 链路仅在 Core CI 的 Linux Node 24 执行一次。
658
+ 发布仍在三平台 × Node 20/22/24/26 全部安装同一个已校验 tarball:每组都验证
659
+ CLI/setup/原生 helper、Doctor/PTY 和真实 SQLite SQL/GC;各平台 Node 24 额外验证
660
+ 真实进程生命周期。参见[分层覆盖表](testing/verification-levels.zh-CN.md#ci-与发布)。
661
+ 依赖安装不再隐含构建,每个源码作业只构建一次,再验证原生依赖;smoke 输出分段耗时。
662
+ 发布后的 registry 检查和 GitHub 资产逐字节核验是独立证据,不被发布前 smoke 替代。
663
+
604
664
  该工作流通过 npm Trusted Publishing(OIDC)认证,因此发布身份存在于 tag 之外的两处:
605
665
  `repository`、`bugs` 和 `homepage` 由 `assemble-runtime-package.mjs` 从源 `package.json`
606
666
  逐字复制进已发布 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