@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
package/README.md CHANGED
@@ -199,6 +199,11 @@ Settings; saving it does not switch the current view or Session.
199
199
  MCP forms and URL confirmations can be completed in Terminal or declined in
200
200
  Conversation. Native additional input is saved as a user Message with its exact
201
201
  steer receipt before submission.
202
+
203
+ **Settings → Command permissions** provides a searchable, read-only command ×
204
+ role discovery matrix and built-in capability contracts. Visibility is not
205
+ authorization for a specific call; this view never changes rules or Grants.
206
+ See [discovery and authorization](docs/authorization.md#read-only-web-templates).
202
207
  Attachments and native configuration changes
203
208
  are not supported by this managed text-input surface.
204
209
  The detail has four tabs: Overview (user input, progress and key Decisions),
@@ -8,7 +8,7 @@ export const CONFIG_DOMAINS = ["system", "runtime", "workflow", "resources", "to
8
8
  export const CONFIG_DEFINITIONS = Object.freeze([
9
9
  { key: "default-agent", domain: "system", property: "defaultAgent", label: "Default Agent", summary: "Configured Agent used when a Task or Role does not select one explicitly.", takesEffect: "Future Role and Task defaults; existing bindings are unchanged." },
10
10
  { key: "default-workspace", domain: "system", property: "defaultWorkspace", label: "Default workspace", summary: "Absolute workspace root outside YUI_HOME used when no workspace is selected.", takesEffect: "Future workspace selection; existing Roles and worktrees are unchanged." },
11
- { key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering; durable JSON remains UTC." },
11
+ { key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps and new default Project sync plans (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering and newly installed Project sync plans; existing Schedule rules stay unchanged and durable timestamps remain UTC." },
12
12
  { key: "reconciliation-interval-seconds", domain: "runtime", property: "reconciliationIntervalSeconds", label: "Reconciliation interval", summary: "Recovery reconciliation interval, 5-300 seconds (default: 120).", takesEffect: "The running Controller is refreshed after the value is saved." },
13
13
  { key: "controller-task-concurrency", domain: "runtime", property: "controllerTaskConcurrency", label: "Controller Task concurrency", summary: "Maximum Tasks reconciled concurrently, 1-32 (default: 4).", takesEffect: "After the Controller restarts." },
14
14
  { key: "runtime-health", domain: "runtime", property: "runtimeHealth", label: "Runtime health thresholds", summary: "Quiet, checkpoint-overdue, and read-only diagnostic thresholds in seconds; values must be strictly increasing (defaults: 300/900/1800).", takesEffect: "CLI and Web projections immediately; scheduler diagnostics after the Controller restarts." },
@@ -689,7 +689,7 @@ export async function main() {
689
689
  const describe = (status) => status
690
690
  ? `Web listener: ${status.state}\n`
691
691
  + (status.alreadyRunning ? "Existing listener reused; credentials were not reloaded.\n" : "")
692
- + (status.authentication === "basic" ? "Browser authentication: username yui; use the password from your private credential file.\n" : "")
692
+ + (status.authentication === "password" ? "Browser sign-in: open the URL and enter the password from your private credential file on the Yui sign-in page.\n" : "")
693
693
  + (status.urls ?? []).map(url => `Yui web control room: ${url}\n`).join("")
694
694
  + (status.warnings ?? []).map(warning => `Warning: ${warning}\n`).join("")
695
695
  : "Web is not running. Start it explicitly; Controller recovery does not restore listeners.\n";
@@ -45,6 +45,7 @@ import { archiveOrdinaryTask } from "../task/ordinaryArchive.js";
45
45
  import { createWebSettings } from "../web/webSettings.js";
46
46
  import { createModelManagement } from "../models/modelManagement.js";
47
47
  import { createWebSkills } from "../web/webSkills.js";
48
+ import { createWebPermissions } from "../web/webPermissions.js";
48
49
  import { createWebJobs } from "../web/webJobs.js";
49
50
  import { isTaskOwnedWorkspace, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
50
51
  import { AgentRuntimeObserver } from "./agentRuntimeObserver.js";
@@ -417,6 +418,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
417
418
  const web = createControllerWeb(store, {
418
419
  previews: () => createTaskPreviews(store, home),
419
420
  skills: createWebSkills(store),
421
+ permissions: createWebPermissions(store),
420
422
  jobs: createWebJobs(store, home),
421
423
  models: createModelManagement(store),
422
424
  settings: createWebSettings(store, {
@@ -7,9 +7,9 @@ import { requireRefreshProject, projectRefreshTargetDigest } from "../repository
7
7
  import { createJobDefinition, reviseJobDefinition, validateJobDefinition } from "./jobDefinition.js";
8
8
  import { nextScheduleDue, validateScheduleRule } from "../schedule/schedule.js";
9
9
  import { scheduleServiceBinding } from "./scheduleService.js";
10
+ import { resolveTimeZone } from "../config/timeZone.js";
10
11
  const scope = { kind: "global" };
11
12
  const unavailable = "Project sync unavailable: ";
12
- export const PROJECT_SYNC_INTERVAL_MS = 6 * 60 * 60_000;
13
13
  /** Called under the maintenance lock immediately before Git, not a timer probe. */
14
14
  export function requireProjectSyncCheckout(store, job) {
15
15
  requireProjectSyncTarget(store, job);
@@ -86,8 +86,8 @@ export function reconcileProjectSync(store, now, onError = () => { }) {
86
86
  reason = unavailable + (error instanceof Error ? error.message : String(error));
87
87
  }
88
88
  if (!plan) {
89
- const rule = { kind: "interval", anchor: new Date(now.getTime() + PROJECT_SYNC_INTERVAL_MS).toISOString(),
90
- everyMs: PROJECT_SYNC_INTERVAL_MS };
89
+ const rule = { kind: "cron", expression: "0 */6 * * *",
90
+ timezone: resolveTimeZone(tx.getConfig().timeZone) };
91
91
  const grantId = randomUUID();
92
92
  plan = { schemaVersion: 1, id, scope, jobId: id, target: { kind: "existing-job" }, rule,
93
93
  builtin: { projectId, enabled: true }, revision: 1, generation: 1,
@@ -10,6 +10,7 @@ import { GLOBAL_SESSION_TABLE_SQL, SESSION_HISTORY_UPGRADE_SQL } from "./session
10
10
  import { migrateJobDefinitions } from "./upgrade/jobDefinitionMigration.js";
11
11
  import { migrateJobBusinessResults } from "./upgrade/jobBusinessResultMigration.js";
12
12
  import { migrateScheduleDispatch } from "./upgrade/scheduleDispatchMigration.js";
13
+ import { migrateProjectSyncClock } from "./upgrade/projectSyncClockMigration.js";
13
14
  import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
14
15
  export { BASELINE_SCHEMA_SQL };
15
16
  export const SQLITE_SCHEMA_TABLES = [...BASELINE_SCHEMA_TABLES, "job_definitions", "global_job_runs", "schedules", "agent_job_runs"];
@@ -233,6 +234,15 @@ const MINOR_UPGRADES = Object.freeze([{
233
234
  // Existing records and immutable artifacts remain byte-for-byte unchanged.
234
235
  // Declares Agent definitions/Runs and optional Job ownership of a non-Task Role.
235
236
  sql: AGENT_JOB_SCHEMA_SQL
237
+ }, {
238
+ fromVersion: "1.20",
239
+ toVersion: "1.21",
240
+ name: "project-sync-clock",
241
+ introducedIn: "next",
242
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
243
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
244
+ sql: "",
245
+ dataMigration: "project-sync-clock"
236
246
  }]);
237
247
  export function storageMinorUpgradePlan(from) {
238
248
  if (from === CURRENT_STORAGE_VERSION)
@@ -363,6 +373,8 @@ export function applySqliteMinorUpgrades(db, home, createdRoots = []) {
363
373
  migrateJobBusinessResults(db);
364
374
  if (step.dataMigration === "schedule-dispatch")
365
375
  migrateScheduleDispatch(db);
376
+ if (step.dataMigration === "project-sync-clock")
377
+ migrateProjectSyncClock(db);
366
378
  const { major, minor } = storageVersionParts(step.toVersion);
367
379
  db.prepare("UPDATE storage_schema SET major=?,minor=?,checksum=? WHERE id=1")
368
380
  .run(major, minor, step.targetChecksum);
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.20";
1
+ export const CURRENT_STORAGE_VERSION = "1.21";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -0,0 +1,30 @@
1
+ import { resolveTimeZone } from "../../config/timeZone.js";
2
+ import { nextScheduleDue, validateSchedule } from "../../schedule/schedule.js";
3
+ /** 1.20 had no rule-edit provenance. Only revision 1 proves no user mutation.
4
+ * Higher revisions may be dispatch, recovery OR an equal-valued user edit;
5
+ * preserve them instead of guessing from interval values or generation.
6
+ */
7
+ export function migrateProjectSyncClock(db) {
8
+ const now = new Date();
9
+ const rows = db.prepare("SELECT rowid,scope_key,schedule_id,payload FROM schedules").all();
10
+ for (const row of rows) {
11
+ const old = JSON.parse(row.payload);
12
+ if (!old.builtin || old.revision !== 1)
13
+ continue;
14
+ validateSchedule(old);
15
+ // Confirm the declared install shape, in addition to the mutation evidence.
16
+ if (old.generation !== 1 || old.rule.kind !== "interval" || old.rule.everyMs !== 21_600_000
17
+ || Date.parse(old.rule.anchor) !== Date.parse(old.createdAt) + 21_600_000)
18
+ continue;
19
+ const config = db.prepare("SELECT payload FROM config WHERE id=1").get();
20
+ const rule = { kind: "cron", expression: "0 */6 * * *",
21
+ timezone: resolveTimeZone(config ? JSON.parse(config.payload).timeZone : undefined) };
22
+ const after = new Date(Math.max(now.getTime(), Date.parse(old.processedThrough ?? "") || 0));
23
+ const next = validateSchedule({ ...old, rule, revision: old.revision + 1, generation: old.generation + 1,
24
+ processedThrough: after.toISOString(), nextDue: nextScheduleDue(rule, after), updatedAt: now.toISOString() });
25
+ db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
26
+ .run(21, "schedules", `${row.scope_key}/${row.schedule_id}`, row.payload);
27
+ db.prepare("UPDATE schedules SET payload=?,next_due=? WHERE rowid=?")
28
+ .run(JSON.stringify(next), next.nextDue, row.rowid);
29
+ }
30
+ }
@@ -8,6 +8,7 @@ import { APP_SCRIPT, CLIENT_MODULES } from "./client/index.js";
8
8
  import { THEME_BOOTSTRAP_SCRIPT } from "./client/lib/theme.js";
9
9
  import { FONT_WOFF2_BASE64 } from "./fontData.js";
10
10
  import { DASHBOARD_HTML } from "./shell/index.js";
11
+ import { LOGIN_HTML } from "./shell/login.js";
11
12
  import { STYLESHEETS } from "./styles/index.js";
12
13
  const require = createRequire(import.meta.url);
13
14
  const CSS = "text/css; charset=utf-8";
@@ -22,7 +23,28 @@ export const WEB_ASSETS = Object.freeze({
22
23
  "/assets/vendor/addon-fit.mjs": vendorAsset("@xterm/addon-fit/lib/addon-fit.mjs", JS),
23
24
  "/assets/vendor/xterm.css": vendorAsset("@xterm/xterm/css/xterm.css", CSS)
24
25
  });
25
- export { DASHBOARD_HTML };
26
+ export { DASHBOARD_HTML, LOGIN_HTML };
27
+ // The sign-in document's static closure (its stylesheets, fonts, theme script
28
+ // and module import graph). Only these assets are served without a session;
29
+ // every other asset, like every document and API, requires sign-in.
30
+ const LOGIN_ASSETS = (() => {
31
+ const found = new Set();
32
+ const visit = (body) => {
33
+ for (const [path] of body.matchAll(/\/assets\/[\w./-]+/g)) {
34
+ const asset = WEB_ASSETS[path];
35
+ if (!asset || found.has(path))
36
+ continue;
37
+ found.add(path);
38
+ if (asset.encoding !== "base64")
39
+ visit(asset.body);
40
+ }
41
+ };
42
+ visit(LOGIN_HTML);
43
+ return found;
44
+ })();
45
+ export function isLoginAsset(pathname) {
46
+ return LOGIN_ASSETS.has(pathname);
47
+ }
26
48
  // One content revision covers the complete import graph: changing a dependency
27
49
  // invalidates its importers too. Only these static assets may be cached.
28
50
  export const WEB_ASSET_VERSION = createHash("sha256")
@@ -0,0 +1,156 @@
1
+ export const LOGIN_SCRIPT = String.raw `
2
+ // Sign-in page controller. Posts the password once per submission as
3
+ // same-origin JSON; the server sets an HttpOnly session cookie and returns a
4
+ // validated same-origin destination. The password is never written to the
5
+ // URL, storage or console, and the field is cleared after a rejection.
6
+ import { createI18n } from "/assets/js/lib/i18n.js";
7
+
8
+ const form = document.querySelector("#login-form");
9
+ const password = document.querySelector("#login-password");
10
+ const reveal = document.querySelector("#login-reveal");
11
+ const submit = document.querySelector("#login-submit");
12
+ const submitLabel = document.querySelector("#login-submit-label");
13
+ const errorBox = document.querySelector("#login-error");
14
+ const notice = document.querySelector("#login-notice");
15
+ const transport = document.querySelector("#login-transport");
16
+
17
+ const i18n = createI18n(document.querySelector("#locale-select"));
18
+ const t = i18n.t;
19
+ const params = new URLSearchParams(window.location.search);
20
+ const next = params.get("next") || "/";
21
+ const reason = params.get("reason");
22
+ let busy = false;
23
+ let lockedUntil = 0;
24
+ let lockTimer = null;
25
+ let currentError = null;
26
+
27
+ function isLoopbackHost(host) {
28
+ return host === "localhost" || host === "[::1]" || /^127\./.test(host);
29
+ }
30
+
31
+ function showError(key, values) {
32
+ currentError = { key: key, values: values || {} };
33
+ let text = t(key);
34
+ Object.keys(currentError.values).forEach(function (name) { text = text.split("{" + name + "}").join(String(currentError.values[name])); });
35
+ errorBox.textContent = text;
36
+ errorBox.hidden = false;
37
+ password.setAttribute("aria-invalid", key === "login.error.network" || key === "login.error.origin" ? "false" : "true");
38
+ }
39
+
40
+ function clearError() {
41
+ currentError = null;
42
+ errorBox.hidden = true;
43
+ errorBox.textContent = "";
44
+ password.setAttribute("aria-invalid", "false");
45
+ }
46
+
47
+ function setBusy(value) {
48
+ busy = value;
49
+ form.setAttribute("aria-busy", value ? "true" : "false");
50
+ password.readOnly = value;
51
+ updateSubmit();
52
+ }
53
+
54
+ function updateSubmit() {
55
+ const locked = Date.now() < lockedUntil;
56
+ submit.disabled = busy || locked;
57
+ submitLabel.textContent = t(busy ? "login.submitting" : "login.submit");
58
+ }
59
+
60
+ function renderStatic() {
61
+ document.title = t("login.title");
62
+ if (reason === "expired" || reason === "signed-out") {
63
+ notice.className = "login-notice " + (reason === "expired" ? "tone-warn" : "tone-ok");
64
+ notice.textContent = t(reason === "expired" ? "login.notice.expired" : "login.notice.signedOut");
65
+ notice.hidden = false;
66
+ }
67
+ const revealed = reveal.getAttribute("aria-pressed") === "true";
68
+ reveal.setAttribute("aria-label", t(revealed ? "login.hide" : "login.show"));
69
+ reveal.title = t(revealed ? "login.hide" : "login.show");
70
+ if (currentError) showError(currentError.key, currentError.values);
71
+ updateSubmit();
72
+ }
73
+
74
+ function lockFor(seconds) {
75
+ lockedUntil = Date.now() + seconds * 1000;
76
+ window.clearInterval(lockTimer);
77
+ const tick = function () {
78
+ const remaining = Math.ceil((lockedUntil - Date.now()) / 1000);
79
+ if (remaining <= 0) {
80
+ window.clearInterval(lockTimer);
81
+ lockTimer = null;
82
+ clearError();
83
+ updateSubmit();
84
+ return;
85
+ }
86
+ showError("login.error.throttled", { seconds: remaining });
87
+ updateSubmit();
88
+ };
89
+ tick();
90
+ lockTimer = window.setInterval(tick, 1000);
91
+ }
92
+
93
+ reveal.addEventListener("click", function () {
94
+ const revealed = reveal.getAttribute("aria-pressed") !== "true";
95
+ reveal.setAttribute("aria-pressed", revealed ? "true" : "false");
96
+ password.type = revealed ? "text" : "password";
97
+ renderStatic();
98
+ password.focus();
99
+ });
100
+
101
+ password.addEventListener("input", function () {
102
+ if (currentError && currentError.key !== "login.error.throttled") clearError();
103
+ });
104
+
105
+ form.addEventListener("submit", async function (event) {
106
+ event.preventDefault();
107
+ if (busy || Date.now() < lockedUntil) return;
108
+ if (!password.value) {
109
+ showError("login.error.empty");
110
+ password.focus();
111
+ return;
112
+ }
113
+ clearError();
114
+ setBusy(true);
115
+ let response;
116
+ let body = null;
117
+ try {
118
+ response = await fetch("/auth/login", {
119
+ method: "POST", credentials: "same-origin", cache: "no-store",
120
+ headers: { "content-type": "application/json", accept: "application/json" },
121
+ body: JSON.stringify({ password: password.value, next: next })
122
+ });
123
+ try { body = await response.json(); } catch {}
124
+ } catch {
125
+ setBusy(false);
126
+ showError("login.error.network");
127
+ password.focus();
128
+ return;
129
+ }
130
+ if (response.ok && body && typeof body.location === "string" && body.location.startsWith("/") && !body.location.startsWith("//")) {
131
+ // Keep the busy state while the browser navigates to the destination.
132
+ window.location.replace(body.location + window.location.hash);
133
+ return;
134
+ }
135
+ setBusy(false);
136
+ if (response.status === 429) {
137
+ lockFor(Math.max(1, Number(body && body.retryAfterSeconds) || Number(response.headers.get("retry-after")) || 30));
138
+ } else if (response.status === 401) {
139
+ password.value = "";
140
+ showError("login.error.invalid");
141
+ } else if (response.status === 403) {
142
+ showError("login.error.origin");
143
+ } else if (response.status === 404 && body && body.code === "password-not-configured") {
144
+ window.location.replace("/");
145
+ return;
146
+ } else {
147
+ showError("login.error.network");
148
+ }
149
+ password.focus();
150
+ });
151
+
152
+ i18n.subscribe(renderStatic);
153
+ transport.hidden = window.location.protocol !== "http:" || isLoopbackHost(window.location.hostname);
154
+ renderStatic();
155
+ password.focus();
156
+ `;
@@ -9,6 +9,7 @@ import { clearPreference, readSessionAccessMode, writeSessionAccessMode } from "
9
9
  import { i18n, createManageShell, pageSections, registerManagePage, navigateManage } from "/assets/js/layout/manageShell.js";
10
10
  import { mountSkills } from "/assets/js/views/skills.js";
11
11
  import { mountJobs } from "/assets/js/views/jobs.js";
12
+ import { mountPermissions } from "/assets/js/views/permissions.js";
12
13
 
13
14
  const $ = selector => document.querySelector(selector);
14
15
  const t = i18n.t;
@@ -20,12 +21,14 @@ function readSection() {
20
21
  }
21
22
  let section = readSection();
22
23
  let jobs;
24
+ let permissions;
23
25
  const shell = createManageShell({ page: "settings", t, onNavigate: navigate });
24
26
  function showSection(next) {
25
27
  section = next;
26
28
  document.querySelectorAll("#settings-root > .manage-section[data-section]").forEach(panel => { panel.hidden = panel.dataset.section !== next; });
27
29
  shell.set(next);
28
30
  if (next === "jobs" && !jobs) jobs = mountJobs($("#global-jobs"), t);
31
+ if (next === "permissions" && !permissions) permissions = mountPermissions($("#permission-browser"), t);
29
32
  }
30
33
  function navigate(next) {
31
34
  navigateManage({ page: "settings", section: next });
@@ -39,6 +42,7 @@ registerManagePage("settings", { show: next => showSection(next.section) });
39
42
  i18n.subscribe(() => shell.render());
40
43
  i18n.subscribe(() => {
41
44
  if (jobs && !jobs.isDirty() && !jobs.isBusy()) jobs = mountJobs($("#global-jobs"), t);
45
+ permissions?.translate();
42
46
  });
43
47
  showSection(section);
44
48
  const skills = mountSkills($("#skill-browser"), t, { heading: false, onDirty: value => markDirty("skills", value) });
@@ -3,6 +3,52 @@
3
3
  // domain vocabulary (statuses, owners, actions) keeps the exact product terms.
4
4
  // Placeholders such as {count} are filled by fill() in lib/format.
5
5
  export const EN = {
6
+ "settings.section.permissions": "Command permissions",
7
+ "permissions.intro": "Read-only discovery templates, not authorization for a specific call. No rules or Grants are changed.",
8
+ "permissions.notice": "Compare CLI discovery by role. Visible means included in that role's default help/completion, not permission to execute. Choosing a role does not switch your identity.",
9
+ "permissions.commands": "Command × role matrix",
10
+ "permissions.capabilities": "Built-in capability contracts",
11
+ "permissions.view": "View",
12
+ "permissions.search": "Find a command or capability",
13
+ "permissions.role": "Role template",
14
+ "permissions.category": "Command category",
15
+ "permissions.allRoles": "Compare all role templates",
16
+ "permissions.allCategories": "All categories",
17
+ "permissions.configured": "Configured GlobalRole",
18
+ "permissions.role.public": "Terminal / unmanaged",
19
+ "permissions.role.operator": "Operator",
20
+ "permissions.role.global": "GlobalRole",
21
+ "permissions.role.leader": "Task Leader",
22
+ "permissions.role.worker": "Task Worker",
23
+ "permissions.role.reviewer": "Task Reviewer",
24
+ "permissions.role.unbound": "Unbound identity",
25
+ "permissions.command": "Command",
26
+ "permissions.visible": "Visible",
27
+ "permissions.hidden": "Hidden",
28
+ "permissions.tableHint": "Discovery only · select a command for role-specific usage and boundaries. Scroll horizontally to compare roles.",
29
+ "permissions.reason.visible": "Included by the current catalog projection. Options below are filtered by the same role audience as CLI help.",
30
+ "permissions.reason.hidden": "Not discoverable as an executable command in this audience's catalog. This is not a missing-Grant assessment.",
31
+ "permissions.unevaluated": "No specific authorization assessment performed. No target, live Session or Grant has been inspected.",
32
+ "permissions.boundaries": "Execution still checks the actual Session, Task/lifecycle, planning or delivery authority, Assignment/Review, Project/workspace and resource/Grant bounds as applicable. The catalog cannot enumerate every domain check; never infer authorization from visibility, command name or a query effect.",
33
+ "permissions.boundary.public": "Ordinary terminals and unmanaged sessions share the public surface. This does not grant a managed identity or authorize unrequested effects.",
34
+ "permissions.boundary.operator": "Requires the current Operator binding and the domain's operation-specific checks. An offline template cannot establish that binding.",
35
+ "permissions.boundary.global": "Configured GlobalRoles share this discovery audience, not Operator authority. Role configuration and exact domain scope remain authoritative.",
36
+ "permissions.boundary.leader": "Own Task and captured Project scope. Planning Sessions do not acquire delivery authority just because a Task becomes active.",
37
+ "permissions.boundary.assignment": "Worker and Reviewer share discovery templates, but their exact Assignment/Review, frozen references and writable workspace may differ.",
38
+ "permissions.boundary.unbound": "Incomplete/conflicting managed identity has no public fallback. Historical or revoked Sessions are rechecked at execution even if offline help is visible.",
39
+ "permissions.capabilityNotice": "Built-in descriptors from the existing registry definition, not a second CLI-to-capability map. Permission categories and required inputs are contracts, not role grants. Task-local plugin descriptors and live assessments are intentionally excluded.",
40
+ "permissions.effect": "Declared effect",
41
+ "permissions.requiredPermissions": "Required permission categories",
42
+ "permissions.requiredInputs": "Required target inputs",
43
+ "permissions.inputs": "All input fields",
44
+ "permissions.legend": "Meaning, limits and sources",
45
+ "permissions.states": "Exact capability discovery may return hidden, requestable (with a bounded request route), or authorized (rechecked at execution). Without exact input there is no access assessment; this page never labels a role/command pair authorized or requestable.",
46
+ "permissions.sources": "Sources",
47
+ "permissions.previous": "Previous",
48
+ "permissions.next": "Next",
49
+ "permissions.loading": "Loading templates…",
50
+ "permissions.empty": "No matching templates.",
51
+ "permissions.retry": "Retry read",
6
52
  "tabs.jobs": "Jobs",
7
53
  "settings.section.jobs": "Jobs & schedules",
8
54
  "jobs.builtinSync": "Built-in Project sync · safe fast-forward only",
@@ -1180,5 +1226,22 @@ export const EN = {
1180
1226
  "discussion.older": "Show {count} earlier messages",
1181
1227
  "discussion.olderLoading": "Reading earlier messages…",
1182
1228
  "record.raw": "Raw record",
1183
- "record.reading": "Reading the exact record…"
1229
+ "record.reading": "Reading the exact record…",
1230
+ "login.title": "Sign in · Yui",
1231
+ "login.heading": "Sign in to Yui",
1232
+ "login.subtitle": "Enter the Web access password from this listener's private credential file.",
1233
+ "login.password": "Password",
1234
+ "login.show": "Show password",
1235
+ "login.hide": "Hide password",
1236
+ "login.submit": "Sign in",
1237
+ "login.submitting": "Signing in…",
1238
+ "login.error.empty": "Enter the password.",
1239
+ "login.error.invalid": "Incorrect password. Check the credential file and try again.",
1240
+ "login.error.throttled": "Too many failed attempts. Try again in {seconds} s.",
1241
+ "login.error.origin": "This address is not accepted by the Web listener. Open one of the URLs printed by yui web.",
1242
+ "login.error.network": "Cannot reach Yui Web. Check that the listener is running, then try again.",
1243
+ "login.notice.expired": "Your session has expired. Sign in again to continue.",
1244
+ "login.notice.signedOut": "You have signed out.",
1245
+ "login.httpWarning": "This connection uses plain HTTP and is not encrypted. Sign in only on a trusted network.",
1246
+ "login.noscript": "JavaScript is required to sign in."
1184
1247
  };
@@ -1,6 +1,52 @@
1
1
  // Simplified Chinese UI copy. Typed against the English catalog, so a key that
2
2
  // does not exist in en.ts is a compile error; a missing key falls back to en.
3
3
  export const ZH_CN = {
4
+ "settings.section.permissions": "命令权限",
5
+ "permissions.intro": "只读的命令发现模板,不是具体调用的授权结论。不会修改规则或 Grant。",
6
+ "permissions.notice": "按角色比较 CLI 发现范围。“可见”仅表示默认帮助与补全包含该命令,不代表可以执行。选择角色不会切换你的身份。",
7
+ "permissions.commands": "命令 × 角色矩阵",
8
+ "permissions.capabilities": "内置能力契约",
9
+ "permissions.view": "视图",
10
+ "permissions.search": "查找命令或能力",
11
+ "permissions.role": "角色模板",
12
+ "permissions.category": "命令分类",
13
+ "permissions.allRoles": "比较全部角色模板",
14
+ "permissions.allCategories": "全部分类",
15
+ "permissions.configured": "已配置 GlobalRole",
16
+ "permissions.role.public": "终端 / 非管控会话",
17
+ "permissions.role.operator": "Operator",
18
+ "permissions.role.global": "GlobalRole",
19
+ "permissions.role.leader": "Task Leader",
20
+ "permissions.role.worker": "Task Worker",
21
+ "permissions.role.reviewer": "Task Reviewer",
22
+ "permissions.role.unbound": "未绑定身份",
23
+ "permissions.command": "命令",
24
+ "permissions.visible": "发现可见",
25
+ "permissions.hidden": "隐藏",
26
+ "permissions.tableHint": "仅展示发现范围 · 选择命令查看各角色用法与边界;可横向滚动比较角色。",
27
+ "permissions.reason.visible": "当前目录投影包含该命令。下方选项与 CLI 帮助使用相同的角色过滤规则。",
28
+ "permissions.reason.hidden": "该角色的目录中不展示此可执行命令。这不表示仅缺少一个 Grant。",
29
+ "permissions.unevaluated": "尚未进行具体授权评估;未检查目标、实时 Session 或 Grant。",
30
+ "permissions.boundaries": "执行仍按实际操作检查 Session、Task/生命周期、planning 或 delivery 权限、Assignment/Review、Project/工作区及资源/Grant 边界。目录不能枚举全部领域检查;不可根据可见性、命令名称或 query 效果推断已授权。",
31
+ "permissions.boundary.public": "普通终端与非 Yui 管控会话共用公共入口;这不赋予管控身份,也不授权未被请求的操作。",
32
+ "permissions.boundary.operator": "需要当前 Operator 绑定及领域操作检查。离线模板不能证明该绑定有效。",
33
+ "permissions.boundary.global": "已配置 GlobalRole 共用此发现范围,但不继承 Operator 权限;角色配置和具体领域范围仍是权威来源。",
34
+ "permissions.boundary.leader": "限自身 Task 及已捕获的 Project 范围。Task 激活不会将 planning Session 自动提升为 delivery。",
35
+ "permissions.boundary.assignment": "Worker 与 Reviewer 共用发现模板,但各自 Assignment/Review、冻结引用和可写工作区可能不同。",
36
+ "permissions.boundary.unbound": "不完整或冲突的管控身份不能回退为公共身份。历史或已撤销 Session 即使能看到离线帮助,执行时仍会被重新检查。",
37
+ "permissions.capabilityNotice": "直接展示现有 registry 的内置描述,不维护另一份 CLI 与能力映射。权限类别和必填输入是契约,不是角色授权;不展示 Task 私有插件描述或实时评估。",
38
+ "permissions.effect": "声明效果",
39
+ "permissions.requiredPermissions": "所需权限类别",
40
+ "permissions.requiredInputs": "必填目标输入",
41
+ "permissions.inputs": "全部输入字段",
42
+ "permissions.legend": "含义、限制与来源",
43
+ "permissions.states": "具体能力发现可能返回 hidden(隐藏)、requestable(可经限定渠道申请)或 authorized(已授权,执行时仍复核)。缺少具体输入时不提供 access 结论;此页不会将角色与命令组合标为已授权或可申请。",
44
+ "permissions.sources": "来源",
45
+ "permissions.previous": "上一页",
46
+ "permissions.next": "下一页",
47
+ "permissions.loading": "正在读取模板…",
48
+ "permissions.empty": "没有匹配的模板。",
49
+ "permissions.retry": "重新读取",
4
50
  "tabs.jobs": "Job",
5
51
  "settings.section.jobs": "Job 与时间计划",
6
52
  "jobs.builtinSync": "内置 Project 同步 · 仅安全快进",
@@ -1176,5 +1222,22 @@ export const ZH_CN = {
1176
1222
  "discussion.older": "显示更早的 {count} 条消息",
1177
1223
  "discussion.olderLoading": "正在读取更早的消息…",
1178
1224
  "record.raw": "原始记录",
1179
- "record.reading": "正在读取完整记录…"
1225
+ "record.reading": "正在读取完整记录…",
1226
+ "login.title": "登录 · Yui",
1227
+ "login.heading": "登录 Yui",
1228
+ "login.subtitle": "输入此 Web 监听所用私有凭证文件中的访问密码。",
1229
+ "login.password": "密码",
1230
+ "login.show": "显示密码",
1231
+ "login.hide": "隐藏密码",
1232
+ "login.submit": "登录",
1233
+ "login.submitting": "正在登录…",
1234
+ "login.error.empty": "请输入密码。",
1235
+ "login.error.invalid": "密码不正确。请核对凭证文件后重试。",
1236
+ "login.error.throttled": "失败次数过多,请在 {seconds} 秒后重试。",
1237
+ "login.error.origin": "Web 监听不接受当前地址。请打开 yui web 输出的访问地址。",
1238
+ "login.error.network": "无法连接 Yui Web。请确认监听仍在运行后重试。",
1239
+ "login.notice.expired": "会话已过期,请重新登录以继续。",
1240
+ "login.notice.signedOut": "你已退出登录。",
1241
+ "login.httpWarning": "当前连接为未加密的 HTTP。请仅在可信网络中登录。",
1242
+ "login.noscript": "需要启用 JavaScript 才能登录。"
1180
1243
  };
@@ -17,6 +17,7 @@ import { FORMAT_SCRIPT } from "./lib/format.js";
17
17
  import { I18N_SCRIPT } from "./lib/i18n.js";
18
18
  import { MARKDOWN_SCRIPT } from "./lib/markdown.js";
19
19
  import { PREFS_SCRIPT } from "./lib/prefs.js";
20
+ import { SESSION_SCRIPT } from "./lib/session.js";
20
21
  import { THEME_SCRIPT } from "./lib/theme.js";
21
22
  import { CONTAINERS_SCRIPT } from "./ui/containers.js";
22
23
  import { CONTROLS_SCRIPT } from "./ui/controls.js";
@@ -61,14 +62,17 @@ import { TASK_VIEW_SCRIPT } from "./app/taskView.js";
61
62
  import { SETTINGS_SCRIPT } from "./app/settings.js";
62
63
  import { SKILLS_SCRIPT } from "./views/skills.js";
63
64
  import { JOBS_SCRIPT } from "./views/jobs.js";
65
+ import { PERMISSIONS_SCRIPT } from "./views/permissions.js";
64
66
  import { MODELS_VIEW_SCRIPT } from "./views/models.js";
65
67
  import { MODELS_SCRIPT } from "./app/models.js";
66
68
  import { MANAGE_SHELL_SCRIPT } from "./layout/manageShell.js";
67
69
  import { MANAGE_SCRIPT } from "./app/manage.js";
70
+ import { LOGIN_SCRIPT } from "./app/login.js";
68
71
  export { APP_SCRIPT } from "./app/main.js";
69
72
  export const CLIENT_MODULES = Object.freeze({
70
73
  "views/skills": SKILLS_SCRIPT,
71
74
  "views/jobs": JOBS_SCRIPT,
75
+ "views/permissions": PERMISSIONS_SCRIPT,
72
76
  "views/models": MODELS_VIEW_SCRIPT,
73
77
  "lib/dom": DOM_SCRIPT,
74
78
  "lib/format": FORMAT_SCRIPT,
@@ -76,6 +80,7 @@ export const CLIENT_MODULES = Object.freeze({
76
80
  "lib/i18n": I18N_SCRIPT,
77
81
  "lib/theme": THEME_SCRIPT,
78
82
  "lib/api": API_SCRIPT,
83
+ "lib/session": SESSION_SCRIPT,
79
84
  "lib/markdown": MARKDOWN_SCRIPT,
80
85
  "ui/primitives": PRIMITIVES_SCRIPT,
81
86
  "ui/containers": CONTAINERS_SCRIPT,
@@ -119,6 +124,7 @@ export const CLIENT_MODULES = Object.freeze({
119
124
  "app/taskView": TASK_VIEW_SCRIPT,
120
125
  "app/settings": SETTINGS_SCRIPT,
121
126
  "app/manage": MANAGE_SCRIPT,
127
+ "app/login": LOGIN_SCRIPT,
122
128
  "app/models": MODELS_SCRIPT,
123
129
  "app/selection": SELECTION_SCRIPT
124
130
  });
@@ -1,6 +1,8 @@
1
1
  export const API_SCRIPT = String.raw `
2
2
  // Loopback API client. Every request carries the page token; a write is keyed
3
3
  // so an unresolved submission can never be silently replayed from this page.
4
+ import { sessionExpired } from "/assets/js/lib/session.js";
5
+
4
6
  const token = document.querySelector('meta[name="yui-web-token"]').content;
5
7
  const unresolved = new Set();
6
8
 
@@ -22,6 +24,9 @@ export async function requestJson(path, options) {
22
24
  const body = await response.json();
23
25
  if (body && body.error) message = body.error;
24
26
  if (body && body.disposition === "not-submitted") disposition = "not-submitted";
27
+ // The server rejected the request before reading it: the browser session
28
+ // is missing or expired. Return to the sign-in page for this view.
29
+ if (response.status === 401 && body && body.code === "authentication-required") sessionExpired();
25
30
  } catch {}
26
31
  throw Object.assign(new Error(message), { disposition, status: response.status });
27
32
  }
@@ -0,0 +1,21 @@
1
+ export const SESSION_SCRIPT = String.raw `
2
+ // Browser session helper for password-protected listeners. The session
3
+ // itself is an HttpOnly cookie owned by the server; this module never reads
4
+ // or stores a credential. It only routes an expired page back to the
5
+ // sign-in page with a same-origin return path.
6
+ let redirecting = false;
7
+
8
+ export function returnTarget() {
9
+ return window.location.pathname + window.location.search;
10
+ }
11
+
12
+ // Called when any API read/write answers 401. Navigates once; the fragment
13
+ // survives on the sign-in URL and is restored after a successful sign-in.
14
+ export function sessionExpired() {
15
+ if (redirecting) return;
16
+ redirecting = true;
17
+ const query = new URLSearchParams({ reason: "expired" });
18
+ if (returnTarget() !== "/") query.set("next", returnTarget());
19
+ window.location.assign("/login?" + query.toString() + window.location.hash);
20
+ }
21
+ `;
@@ -8,6 +8,7 @@ import { h, icon, clear } from "/assets/js/lib/dom.js";
8
8
  import { fill } from "/assets/js/lib/format.js";
9
9
  import { pageToken, requestJson } from "/assets/js/lib/api.js";
10
10
  import { dot } from "/assets/js/ui/primitives.js";
11
+ import { sessionExpired } from "/assets/js/lib/session.js";
11
12
 
12
13
  function cssVar(name, fallback) {
13
14
  const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
@@ -172,9 +173,11 @@ function bindSocket(ctl, socket, terminal, link, generation) {
172
173
  terminal.writeln("\r\n" + message.message);
173
174
  }
174
175
  });
175
- socket.addEventListener("close", function () {
176
+ socket.addEventListener("close", function (event) {
176
177
  if (generation !== ctl.generation) return;
177
178
  link.writable = false;
179
+ // 1008: the server closed this terminal because its browser session ended.
180
+ if (event && event.code === 1008) { sessionExpired(); return; }
178
181
  if (ctl.stateKey !== "terminal.error") setState(ctl, "terminal.closed", "closed");
179
182
  });
180
183
  socket.addEventListener("error", function () {