@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.
- package/README.md +5 -0
- package/dist/config/configCatalog.js +1 -1
- package/dist/controlPlaneCli.js +1 -1
- package/dist/controller/runtime.js +2 -0
- package/dist/job/projectSync.js +3 -3
- package/dist/storage/sqliteSchema.js +12 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
- package/dist/web/assets/assetManifest.js +23 -1
- package/dist/web/assets/client/app/login.js +156 -0
- package/dist/web/assets/client/app/settings.js +4 -0
- package/dist/web/assets/client/i18n/en.js +64 -1
- package/dist/web/assets/client/i18n/zh-CN.js +64 -1
- package/dist/web/assets/client/index.js +6 -0
- package/dist/web/assets/client/lib/api.js +5 -0
- package/dist/web/assets/client/lib/session.js +21 -0
- package/dist/web/assets/client/views/dock/terminal.js +4 -1
- package/dist/web/assets/client/views/permissions.js +129 -0
- package/dist/web/assets/shared/icons.js +1 -0
- package/dist/web/assets/shared/manageNav.js +1 -0
- package/dist/web/assets/shell/login.js +56 -0
- package/dist/web/assets/shell/settingsContent.js +4 -0
- package/dist/web/assets/styles/index.js +2 -0
- package/dist/web/assets/styles/views/login.js +42 -0
- package/dist/web/assets/styles/views/settings.js +13 -0
- package/dist/web/webAccess.js +129 -9
- package/dist/web/webPermissions.js +97 -0
- package/dist/web/webServer.js +209 -15
- package/docs/authorization.md +35 -0
- package/docs/release-workflow.md +80 -0
- package/docs/release-workflow.zh-CN.md +60 -0
- package/docs/storage-baseline.md +7 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +77 -11
- package/docs/testing/verification-levels.zh-CN.md +63 -9
- package/i18n/README.zh-CN.md +4 -0
- package/package.json +1 -1
package/dist/web/webServer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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'");
|
package/docs/authorization.md
CHANGED
|
@@ -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
|
package/docs/release-workflow.md
CHANGED
|
@@ -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、
|
package/docs/storage-baseline.md
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
# Storage baseline 1.0
|
|
4
4
|
|
|
5
|
-
Current storage is 1.
|
|
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.
|
|
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
|
|