@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.
- package/dist/config/configCatalog.js +1 -1
- package/dist/controlPlaneCli.js +1 -1
- 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/catalog.js +40 -14
- package/dist/web/assets/client/app/login.js +156 -0
- package/dist/web/assets/client/app/main.js +33 -9
- package/dist/web/assets/client/app/manage.js +18 -0
- package/dist/web/assets/client/app/selection.js +4 -1
- package/dist/web/assets/client/app/taskView.js +22 -5
- package/dist/web/assets/client/i18n/en.js +28 -4
- package/dist/web/assets/client/i18n/zh-CN.js +28 -4
- package/dist/web/assets/client/index.js +8 -0
- package/dist/web/assets/client/layout/manageShell.js +30 -8
- package/dist/web/assets/client/layout/shortcuts.js +7 -3
- package/dist/web/assets/client/layout/sidebarNav.js +147 -0
- package/dist/web/assets/client/layout/sizing.js +5 -2
- package/dist/web/assets/client/layout/workspace.js +120 -21
- package/dist/web/assets/client/lib/api.js +5 -0
- package/dist/web/assets/client/lib/motion.js +59 -0
- package/dist/web/assets/client/lib/prefs.js +29 -2
- package/dist/web/assets/client/lib/session.js +21 -0
- package/dist/web/assets/client/lib/theme.js +11 -4
- package/dist/web/assets/client/ui/primitives.js +27 -0
- package/dist/web/assets/client/views/dock/terminal.js +4 -1
- package/dist/web/assets/client/views/overview.js +8 -5
- package/dist/web/assets/client/views/task/page.js +10 -10
- package/dist/web/assets/shared/brand.js +22 -0
- package/dist/web/assets/shared/geometry.js +7 -1
- package/dist/web/assets/shared/icons.js +5 -0
- package/dist/web/assets/shared/motion.js +74 -0
- package/dist/web/assets/shell/appRail.js +14 -0
- package/dist/web/assets/shell/dialogs.js +1 -0
- package/dist/web/assets/shell/dock.js +19 -6
- package/dist/web/assets/shell/index.js +6 -2
- package/dist/web/assets/shell/login.js +57 -0
- package/dist/web/assets/shell/manage.js +32 -20
- package/dist/web/assets/shell/settingsContent.js +1 -1
- package/dist/web/assets/shell/sidebar.js +16 -23
- package/dist/web/assets/styles/base.js +1 -1
- package/dist/web/assets/styles/index.js +4 -0
- package/dist/web/assets/styles/layout/responsive.js +63 -19
- package/dist/web/assets/styles/layout/workspace.js +53 -16
- package/dist/web/assets/styles/motion.js +51 -0
- package/dist/web/assets/styles/tokens.js +5 -3
- package/dist/web/assets/styles/ui/controls.js +1 -1
- package/dist/web/assets/styles/ui/forms.js +1 -1
- package/dist/web/assets/styles/ui/surfaces.js +1 -2
- package/dist/web/assets/styles/views/conversation.js +1 -1
- package/dist/web/assets/styles/views/dock.js +22 -2
- package/dist/web/assets/styles/views/login.js +45 -0
- package/dist/web/assets/styles/views/manage.js +25 -42
- package/dist/web/assets/styles/views/page.js +2 -3
- package/dist/web/assets/styles/views/records.js +1 -1
- package/dist/web/assets/styles/views/settings.js +1 -1
- package/dist/web/assets/styles/views/sidebar.js +25 -20
- package/dist/web/assets/styles/views/task.js +9 -15
- package/dist/web/assets/styles/views/terminal.js +1 -1
- package/dist/web/webAccess.js +129 -9
- package/dist/web/webServer.js +170 -14
- package/docs/release-workflow.md +67 -0
- package/docs/release-workflow.zh-CN.md +49 -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/package.json +1 -1
package/dist/web/webAccess.js
CHANGED
|
@@ -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(
|
|
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 ? "
|
|
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
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
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
|
}
|
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.
|
|
@@ -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
|
-
|
|
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'");
|
package/docs/release-workflow.md
CHANGED
|
@@ -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、
|
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
|
|