sideby 0.16.1 → 0.17.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 -3
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +16 -0
- package/dist/src/cli/commands.js +457 -5
- package/dist/src/cli/json-schemas.js +112 -0
- package/dist/src/cli/main.js +93 -3
- package/dist/src/core/ask.d.ts +150 -0
- package/dist/src/core/ask.js +365 -0
- package/dist/src/core/attachments.d.ts +50 -0
- package/dist/src/core/attachments.js +442 -0
- package/dist/src/core/autostart.d.ts +88 -0
- package/dist/src/core/autostart.js +256 -0
- package/dist/src/core/config.d.ts +97 -0
- package/dist/src/core/config.js +278 -10
- package/dist/src/core/launch.d.ts +15 -4
- package/dist/src/core/launch.js +27 -23
- package/dist/src/core/local-build.d.ts +5 -0
- package/dist/src/core/local-build.js +23 -3
- package/dist/src/core/probe.d.ts +78 -0
- package/dist/src/core/probe.js +127 -0
- package/dist/src/core/same-login.d.ts +9 -4
- package/dist/src/core/same-login.js +9 -4
- package/dist/src/core/secret-file.d.ts +26 -0
- package/dist/src/core/secret-file.js +126 -7
- package/dist/src/core/terminal.d.ts +93 -0
- package/dist/src/core/terminal.js +371 -0
- package/dist/src/core/update.d.ts +11 -0
- package/dist/src/core/update.js +38 -0
- package/dist/src/families/claude/index.js +3 -0
- package/dist/src/families/claude/quota-cache.d.ts +24 -7
- package/dist/src/families/claude/quota-cache.js +72 -21
- package/dist/src/families/codex/index.js +7 -0
- package/dist/src/families/codex/login-key.d.ts +8 -0
- package/dist/src/families/codex/login-key.js +33 -0
- package/dist/src/families/grok/index.js +3 -0
- package/dist/src/families/login-keys.d.ts +6 -0
- package/dist/src/families/login-keys.js +6 -0
- package/dist/src/families/pi/index.js +4 -0
- package/dist/src/panel/background.js +3 -2
- package/dist/src/panel/handler.d.ts +29 -2
- package/dist/src/panel/handler.js +393 -9
- package/dist/src/panel/i18n.d.ts +172 -2
- package/dist/src/panel/i18n.js +375 -35
- package/dist/src/panel/page-logic.d.ts +196 -0
- package/dist/src/panel/page-logic.js +377 -0
- package/dist/src/panel/page-script.js +947 -126
- package/dist/src/panel/page-styles.js +69 -19
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +2 -0
- package/dist/src/panel/server.d.ts +16 -2
- package/dist/src/panel/server.js +41 -13
- package/dist/src/plugins/check.d.ts +47 -0
- package/dist/src/plugins/check.js +544 -0
- package/dist/src/plugins/scaffold.d.ts +21 -0
- package/dist/src/plugins/scaffold.js +123 -0
- package/dist/src/quota/statusline-tap.js +1 -1
- package/dist/src/runtime.d.ts +10 -0
- package/dist/src/runtime.js +47 -16
- package/dist/src/types.d.ts +19 -0
- package/package.json +2 -2
- package/schemas/ask.json +180 -0
- package/schemas/autostart.json +165 -0
- package/schemas/config-get.json +132 -0
- package/schemas/config-set.json +107 -0
- package/schemas/config.json +73 -6
- package/schemas/key.json +93 -0
- package/schemas/plugins-check.json +113 -0
- package/schemas/plugins-new.json +87 -0
- package/schemas/terminal.json +152 -0
- package/schemas/try.json +109 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +12 -6
- package/skills/sideby/references/accounts.md +1 -1
- package/skills/sideby/references/ai-tasks.md +116 -0
- package/skills/sideby/references/panel.md +14 -0
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { aliasesByAccount } from '../core/accounts.js';
|
|
4
4
|
import { listAppProfiles, openAppProfile, removeAppProfile, } from '../core/app-profiles.js';
|
|
5
|
+
import { openLoginInTerminal, pickTaskAccount, previewAttachments, promptForTask, setCustomTerminal, setPreferredTerminal, startAiTask, terminals, tryAccount, } from '../core/ask.js';
|
|
6
|
+
import { MAX_ATTACHMENTS, MAX_UPLOAD_BYTES, removeStaged, stageUpload } from '../core/attachments.js';
|
|
7
|
+
import { applyAutostart, autostartStatus, planAutostart, } from '../core/autostart.js';
|
|
5
8
|
import { FAVICON_SVG, ICON_SVG } from '../core/brand-mark.js';
|
|
6
9
|
import { browserReport, openInBrowser, setBrowserSignIns, } from '../core/browser-profiles.js';
|
|
7
|
-
import { aliasArgs, aliasRefs, editConfigUpdate, } from '../core/config.js';
|
|
10
|
+
import { aliasArgs, aliasRefs, editConfigPanel, editConfigTerminal, editConfigUpdate, } from '../core/config.js';
|
|
8
11
|
import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
|
|
9
12
|
import { UserError } from '../core/errors.js';
|
|
10
13
|
import { planHandoff } from '../core/handoff.js';
|
|
@@ -13,12 +16,15 @@ import { handoffOverview } from '../core/handoff-overview.js';
|
|
|
13
16
|
import { configureHandoff, handoffStatus } from '../core/handoff-status.js';
|
|
14
17
|
import { accountModel, setAccountModel } from '../core/model-choice.js';
|
|
15
18
|
import { tildify } from '../core/paths.js';
|
|
19
|
+
import { SECRET_VAR_NAME, secretVarFor, setSecret } from '../core/secret-file.js';
|
|
16
20
|
import { SKILL_INSTALL } from '../core/skill-check.js';
|
|
21
|
+
import { launchArgs, TERMINALS } from '../core/terminal.js';
|
|
17
22
|
import { MIN_CALLS } from '../core/tuning.js';
|
|
18
23
|
import * as update from '../core/update.js';
|
|
19
24
|
import { buildId, localBuild, packageVersion } from '../core/version.js';
|
|
20
25
|
import { HOOK_FAMILIES } from '../handoff/hook.js';
|
|
21
26
|
import { renderPage } from './page.js';
|
|
27
|
+
import { AI_TASK_KINDS } from './page-logic.js';
|
|
22
28
|
import { resolveTheme } from './theme.js';
|
|
23
29
|
export const PANEL_VERSION = packageVersion();
|
|
24
30
|
export const MAX_BODY_BYTES = 64 * 1024;
|
|
@@ -44,6 +50,59 @@ export function healthByAccount(history) {
|
|
|
44
50
|
});
|
|
45
51
|
return out;
|
|
46
52
|
}
|
|
53
|
+
function keyFacts(def) {
|
|
54
|
+
let keyVar = null;
|
|
55
|
+
try {
|
|
56
|
+
keyVar = secretVarFor(def);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
return {
|
|
60
|
+
keyVar,
|
|
61
|
+
...(def.secretFileMeansApi === false ? { keyAlways: true } : {}),
|
|
62
|
+
...(def.probe ? { tryCommand: [def.bin, ...def.probe.args] } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
let autostartOverride = {};
|
|
66
|
+
/**
|
|
67
|
+
* Internal, for tests: replaces parts of the machine the Panel's start-at-login switch uses (platform, launcher,
|
|
68
|
+
* `exec`, login home). Not part of the plugin or Portal contract.
|
|
69
|
+
*/
|
|
70
|
+
export function bindPanelAutostart(ctx) {
|
|
71
|
+
const before = autostartOverride;
|
|
72
|
+
autostartOverride = { ...autostartOverride, ...ctx };
|
|
73
|
+
return () => {
|
|
74
|
+
autostartOverride = before;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const autostartCtx = (rt) => ({ home: rt.paths.home, ...autostartOverride });
|
|
78
|
+
/**
|
|
79
|
+
* The start-at-login state as the page shows it (spec §3.29): the agent's path and what it starts, with the home
|
|
80
|
+
* folder as `~`, and nothing else of the machine.
|
|
81
|
+
*/
|
|
82
|
+
export function autostartView(s, home) {
|
|
83
|
+
const t = (p) => tildify(p, home);
|
|
84
|
+
return {
|
|
85
|
+
supported: s.supported,
|
|
86
|
+
...(s.reason ? { reason: s.reason } : {}),
|
|
87
|
+
state: s.state,
|
|
88
|
+
path: t(s.path),
|
|
89
|
+
current: s.current,
|
|
90
|
+
...(s.programArguments ? { programArguments: s.programArguments.map(t) } : {}),
|
|
91
|
+
...(s.missing ? { missing: s.missing.map(t) } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function autostartPlanView(p, home) {
|
|
95
|
+
return {
|
|
96
|
+
action: p.action,
|
|
97
|
+
changed: p.changed,
|
|
98
|
+
path: tildify(p.path, home),
|
|
99
|
+
...(p.programArguments ? { programArguments: p.programArguments.map((a) => tildify(a, home)) } : {}),
|
|
100
|
+
...(p.blocked ? { blocked: p.blocked } : {}),
|
|
101
|
+
status: autostartView(p.status, home),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const PANEL_THEMES = ['system', 'light', 'dark'];
|
|
105
|
+
const PANEL_LANGUAGES = ['auto', 'en', 'zh'];
|
|
47
106
|
// One Update Check at a time per process, started by a state read when one is due (the cache also keeps a failed
|
|
48
107
|
// one from being tried again within the hour, though the page asks every 20 s).
|
|
49
108
|
let checking = null;
|
|
@@ -312,9 +371,10 @@ async function buildState(rt, opts) {
|
|
|
312
371
|
readOnly: opts.readOnly,
|
|
313
372
|
generatedAt: ctx.now.toISOString(),
|
|
314
373
|
families: families.map((f) => {
|
|
315
|
-
const
|
|
374
|
+
const def = rt.families.get(f.id);
|
|
375
|
+
const logo = def?.logo;
|
|
316
376
|
const g = f.brokenBin ? { ...f, brokenBin: tildify(f.brokenBin, ctx.home) } : f;
|
|
317
|
-
return
|
|
377
|
+
return { ...g, ...(logo ? { logo } : {}), ...(def ? keyFacts(def) : {}) };
|
|
318
378
|
}),
|
|
319
379
|
accounts: list,
|
|
320
380
|
pluginErrors: rt.pluginErrors.map((e) => ({ where: e.where, message: e.message })),
|
|
@@ -338,6 +398,7 @@ async function buildState(rt, opts) {
|
|
|
338
398
|
...(showBrowser ? { browser } : {}),
|
|
339
399
|
display: rt.displayOrder(),
|
|
340
400
|
...(rt.quotaChecks().length ? { quotaChecks: rt.quotaChecks() } : {}),
|
|
401
|
+
...(rt.config.panel && Object.keys(rt.config.panel).length ? { panel: rt.config.panel } : {}),
|
|
341
402
|
};
|
|
342
403
|
}
|
|
343
404
|
/** The `display` of a /api/display-order body: null, or string lists only; anything else is refused. */
|
|
@@ -405,6 +466,102 @@ function handoffChange(body) {
|
|
|
405
466
|
}
|
|
406
467
|
return out;
|
|
407
468
|
}
|
|
469
|
+
/** The terminals for Settings and the confirm area: installed paths stay on the server. */
|
|
470
|
+
async function terminalsView(rt) {
|
|
471
|
+
const r = await terminals(rt);
|
|
472
|
+
return {
|
|
473
|
+
installed: r.installed.map((t) => t.id),
|
|
474
|
+
preferred: r.preferred,
|
|
475
|
+
guess: r.guess,
|
|
476
|
+
custom: rt.config.terminal?.custom ?? null,
|
|
477
|
+
terminals: r.terminals.map((t) => ({ id: t.id, name: t.name, installed: t.installed })),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const AI_TASKS = AI_TASK_KINDS;
|
|
481
|
+
/** The `context` of an AI Task body (spec §3.28): only the strings and findings a Task Prompt shows. */
|
|
482
|
+
function askContext(body) {
|
|
483
|
+
const c = body.context;
|
|
484
|
+
if (c === undefined || c === null)
|
|
485
|
+
return undefined;
|
|
486
|
+
if (typeof c !== 'object' || Array.isArray(c))
|
|
487
|
+
throw new HttpError(400, '"context" must be an object');
|
|
488
|
+
const out = {};
|
|
489
|
+
const str = (v, what) => {
|
|
490
|
+
if (typeof v !== 'string')
|
|
491
|
+
throw new HttpError(400, `${what} must be a string`);
|
|
492
|
+
return v;
|
|
493
|
+
};
|
|
494
|
+
for (const [k, v] of Object.entries(c)) {
|
|
495
|
+
if (k === 'account' || k === 'family' || k === 'quotaReason' || k === 'version' || k === 'terminalCustom')
|
|
496
|
+
out[k] = str(v, `"context.${k}"`);
|
|
497
|
+
else if (k === 'terminals') {
|
|
498
|
+
if (!Array.isArray(v) || v.length > 50)
|
|
499
|
+
throw new HttpError(400, '"context.terminals" must be a list');
|
|
500
|
+
out.terminals = v.map((x) => str(x, 'each of "context.terminals"'));
|
|
501
|
+
}
|
|
502
|
+
else if (k === 'handoff') {
|
|
503
|
+
if (!Array.isArray(v) || v.length > 100)
|
|
504
|
+
throw new HttpError(400, '"context.handoff" must be a list');
|
|
505
|
+
out.handoff = v.map((x) => str(x, 'each of "context.handoff"'));
|
|
506
|
+
}
|
|
507
|
+
else if (k === 'findings') {
|
|
508
|
+
if (!Array.isArray(v) || v.length > 100)
|
|
509
|
+
throw new HttpError(400, '"context.findings" must be a list');
|
|
510
|
+
out.findings = v.map((f) => {
|
|
511
|
+
if (!f || typeof f !== 'object' || Array.isArray(f) || typeof f.message !== 'string')
|
|
512
|
+
throw new HttpError(400, 'each finding needs a "message"');
|
|
513
|
+
const one = {
|
|
514
|
+
message: f.message,
|
|
515
|
+
};
|
|
516
|
+
for (const key of ['code', 'account', 'item', 'hint'])
|
|
517
|
+
if (f[key] !== undefined && f[key] !== null)
|
|
518
|
+
one[key] = str(f[key], `"finding.${key}"`);
|
|
519
|
+
return one;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
else
|
|
523
|
+
throw new HttpError(400, `unknown context "${k}"`);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
/** Task, text, context, language and account of an /api/ask body; unknown tasks are refused. */
|
|
528
|
+
function askInput(body) {
|
|
529
|
+
const task = optString(body, 'task') ?? 'free';
|
|
530
|
+
if (!AI_TASKS.includes(task))
|
|
531
|
+
throw new HttpError(400, `"task" must be one of ${AI_TASKS.join(', ')}`);
|
|
532
|
+
const text = optString(body, 'text');
|
|
533
|
+
if (text && text.length > 8000)
|
|
534
|
+
throw new HttpError(400, '"text" is too long; keep it under 8000 characters');
|
|
535
|
+
const lang = optString(body, 'lang');
|
|
536
|
+
if (lang && lang !== 'en' && lang !== 'zh')
|
|
537
|
+
throw new HttpError(400, '"lang" must be "en" or "zh"');
|
|
538
|
+
const account = optString(body, 'account');
|
|
539
|
+
const terminal = optString(body, 'terminal');
|
|
540
|
+
if (terminal && !TERMINALS.some((t) => t.id === terminal))
|
|
541
|
+
throw new HttpError(400, 'unknown terminal; pick one from the list');
|
|
542
|
+
const context = askContext(body);
|
|
543
|
+
const uploads = body.attachments;
|
|
544
|
+
if (uploads !== undefined &&
|
|
545
|
+
!(Array.isArray(uploads) &&
|
|
546
|
+
uploads.length <= MAX_ATTACHMENTS &&
|
|
547
|
+
uploads.every((u) => typeof u === 'string' && /^[a-f0-9]{16}$/.test(u))))
|
|
548
|
+
throw new HttpError(400, `"attachments" must list at most ${MAX_ATTACHMENTS} upload ids`);
|
|
549
|
+
return {
|
|
550
|
+
...(uploads?.length ? { uploads: uploads } : {}),
|
|
551
|
+
task,
|
|
552
|
+
...(text ? { text } : {}),
|
|
553
|
+
...(context ? { context } : {}),
|
|
554
|
+
...(lang ? { lang } : {}),
|
|
555
|
+
...(account ? { account } : {}),
|
|
556
|
+
...(terminal ? { terminal } : {}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/** A terminal open result as the page needs it: macOS refusing Automation gets its own flag (spec §3.28). */
|
|
560
|
+
function opened(r) {
|
|
561
|
+
return { ...r, automation: !r.opened && r.reason === 'automation-denied' };
|
|
562
|
+
}
|
|
563
|
+
/** The AI Task upload route; its body is a file, not a JSON object, so it sits outside the route table. */
|
|
564
|
+
const ATTACH_ROUTE = '/api/ask/attach';
|
|
408
565
|
/** Runs write requests one at a time; a few may wait, the rest are told to retry. */
|
|
409
566
|
function writeQueue() {
|
|
410
567
|
let tail = Promise.resolve();
|
|
@@ -434,6 +591,120 @@ export function createPanelHandler(opts) {
|
|
|
434
591
|
const serial = writeQueue();
|
|
435
592
|
const getRuntime = typeof opts.runtime === 'function' ? opts.runtime : async () => opts.runtime;
|
|
436
593
|
const routes = {
|
|
594
|
+
// AI Tasks (spec §3.28, ADR-0027). The prompt and the account it would use, read only: for the confirm area,
|
|
595
|
+
// the copy fallback and the read-only Panel.
|
|
596
|
+
// Frees a staged upload the person removed from the dialog, so it stops counting toward the staging limit.
|
|
597
|
+
'/api/ask/attach/remove': {
|
|
598
|
+
async run(body) {
|
|
599
|
+
const ids = body.ids;
|
|
600
|
+
if (!Array.isArray(ids) || ids.length > MAX_ATTACHMENTS || ids.some((x) => typeof x !== 'string'))
|
|
601
|
+
throw new HttpError(400, `"ids" must be a list of at most ${MAX_ATTACHMENTS} upload ids`);
|
|
602
|
+
const rt = await getRuntime();
|
|
603
|
+
return { removed: await removeStaged(rt.paths, ids) };
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
'/api/ask/prompt': {
|
|
607
|
+
writes: () => false,
|
|
608
|
+
queued: false,
|
|
609
|
+
async run(body) {
|
|
610
|
+
const input = askInput(body);
|
|
611
|
+
const rt = await getRuntime();
|
|
612
|
+
const { prompt, removedSecrets } = promptForTask(rt, input, await previewAttachments(rt, input));
|
|
613
|
+
const pick = await pickTaskAccount(rt, input);
|
|
614
|
+
return {
|
|
615
|
+
prompt,
|
|
616
|
+
removedSecrets,
|
|
617
|
+
account: 'account' in pick ? pick.account.ref : null,
|
|
618
|
+
...('reason' in pick ? { reason: pick.reason } : {}),
|
|
619
|
+
};
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
// Writes the task file and opens the terminal. An account the person picked, API ones too, is kept as `ask.account`.
|
|
623
|
+
'/api/ask': {
|
|
624
|
+
async run(body) {
|
|
625
|
+
const input = askInput(body);
|
|
626
|
+
const rt = await getRuntime();
|
|
627
|
+
return opened(await startAiTask(rt, { ...input, remember: Boolean(input.account || input.terminal) }));
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
// Runs `sideby login <ref>` in the person's terminal; no AI is involved (spec §3.28 "登录").
|
|
631
|
+
'/api/login': {
|
|
632
|
+
async run(body) {
|
|
633
|
+
const account = optString(body, 'account');
|
|
634
|
+
if (!account)
|
|
635
|
+
throw new HttpError(400, '"account" is required');
|
|
636
|
+
return opened(await openLoginInTerminal(await getRuntime(), account));
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
// The key box: writes one variable of an API Account's proxy.env. The value is never returned or logged.
|
|
640
|
+
'/api/key': {
|
|
641
|
+
async run(body) {
|
|
642
|
+
const account = optString(body, 'account');
|
|
643
|
+
if (!account)
|
|
644
|
+
throw new HttpError(400, '"account" is required');
|
|
645
|
+
const value = body.value;
|
|
646
|
+
if (typeof value !== 'string' || !value.trim())
|
|
647
|
+
throw new HttpError(400, 'enter the key');
|
|
648
|
+
const name = optString(body, 'var');
|
|
649
|
+
if (name !== undefined && !SECRET_VAR_NAME.test(name))
|
|
650
|
+
throw new HttpError(400, 'use an uppercase variable name such as API_KEY');
|
|
651
|
+
const rt = await getRuntime();
|
|
652
|
+
const found = await rt.resolve(account);
|
|
653
|
+
const family = rt.families.get(found.family);
|
|
654
|
+
if (!family)
|
|
655
|
+
throw new HttpError(400, 'unknown family');
|
|
656
|
+
if (found.kind !== 'api' && family.secretFileMeansApi !== false)
|
|
657
|
+
throw new UserError('This is a subscription account; sign in instead, or create an API account in New account.');
|
|
658
|
+
const v = name ?? secretVarFor(family);
|
|
659
|
+
const { join } = await import('node:path');
|
|
660
|
+
const r = await setSecret(found.secretFile ?? join(found.dir, 'proxy.env'), v, value.trim());
|
|
661
|
+
return { account: found.ref, var: v, written: true, path: tildify(r.path, rt.paths.home) };
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
// `sideby try`: the page has said it spends a few tokens. It starts a program, so it is a write; it runs beside
|
|
665
|
+
// the queue because the Host may take up to a minute.
|
|
666
|
+
'/api/try': {
|
|
667
|
+
queued: false,
|
|
668
|
+
async run(body) {
|
|
669
|
+
const account = optString(body, 'account');
|
|
670
|
+
if (!account)
|
|
671
|
+
throw new HttpError(400, '"account" is required');
|
|
672
|
+
return tryAccount(await getRuntime(), account);
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
// Preferred Terminal and Custom terminal command (spec §3.28): `id` null or left out goes back to the guess;
|
|
676
|
+
// `custom` an empty string removes the template.
|
|
677
|
+
'/api/terminal': {
|
|
678
|
+
async run(body) {
|
|
679
|
+
const id = body.id === undefined ? undefined : optString(body, 'id');
|
|
680
|
+
if (id && !TERMINALS.some((t) => t.id === id))
|
|
681
|
+
throw new HttpError(400, 'unknown terminal; pick one from the list');
|
|
682
|
+
const rt = await getRuntime();
|
|
683
|
+
if (body.custom !== undefined) {
|
|
684
|
+
// Same rules as `sideby terminal --custom`: a saved command is also the one chosen; clearing it drops that choice.
|
|
685
|
+
const custom = optString(body, 'custom')?.trim();
|
|
686
|
+
if (custom) {
|
|
687
|
+
try {
|
|
688
|
+
await setCustomTerminal(rt, custom);
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
throw new HttpError(400, err.message);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
const wasChosen = rt.config.terminal?.preferred === 'custom';
|
|
696
|
+
const r = await editConfigTerminal(rt.paths.configFile, {
|
|
697
|
+
custom: undefined,
|
|
698
|
+
...(wasChosen ? { preferred: undefined } : {}),
|
|
699
|
+
});
|
|
700
|
+
rt.config.terminal = r.after;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (body.id !== undefined || body.custom === undefined)
|
|
704
|
+
await setPreferredTerminal(rt, id);
|
|
705
|
+
return terminalsView(rt);
|
|
706
|
+
},
|
|
707
|
+
},
|
|
437
708
|
'/api/doctor': {
|
|
438
709
|
// Only the record of the check is written, and a read-only Panel skips that (persist: false).
|
|
439
710
|
writes: () => false,
|
|
@@ -810,6 +1081,60 @@ export function createPanelHandler(opts) {
|
|
|
810
1081
|
return { family, ...(await rt.setQuotaCheck(family, body.on)) };
|
|
811
1082
|
},
|
|
812
1083
|
},
|
|
1084
|
+
// The Panel's theme and language (spec §3.29): config `panel.theme` and `panel.language`; null removes one.
|
|
1085
|
+
'/api/panel/settings': {
|
|
1086
|
+
async run(body) {
|
|
1087
|
+
const patch = {};
|
|
1088
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1089
|
+
if (k === 'theme') {
|
|
1090
|
+
if (v !== null && !PANEL_THEMES.some((x) => x === v))
|
|
1091
|
+
throw new HttpError(400, '"theme" must be "system", "light", "dark" or null');
|
|
1092
|
+
patch.theme = v;
|
|
1093
|
+
}
|
|
1094
|
+
else if (k === 'language') {
|
|
1095
|
+
if (v !== null && !PANEL_LANGUAGES.some((x) => x === v))
|
|
1096
|
+
throw new HttpError(400, '"language" must be "auto", "en", "zh" or null');
|
|
1097
|
+
patch.language = v;
|
|
1098
|
+
}
|
|
1099
|
+
else
|
|
1100
|
+
throw new HttpError(400, `unknown setting "${k}"`);
|
|
1101
|
+
}
|
|
1102
|
+
if (!Object.keys(patch).length)
|
|
1103
|
+
throw new HttpError(400, 'send "theme" or "language"');
|
|
1104
|
+
const rt = await getRuntime();
|
|
1105
|
+
const r = await editConfigPanel(rt.paths.configFile, patch);
|
|
1106
|
+
return { changed: r.changed, panel: r.after ?? {} };
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
// Start at login (spec §3.29, ADR-0029). Without `confirm` the plan: the file and what it starts, nothing is
|
|
1110
|
+
// written. With it, a fresh plan runs only while it starts what the person saw (`programArguments`).
|
|
1111
|
+
'/api/autostart': {
|
|
1112
|
+
writes: (body) => body.confirm === true,
|
|
1113
|
+
async run(body) {
|
|
1114
|
+
if (typeof body.on !== 'boolean')
|
|
1115
|
+
throw new HttpError(400, '"on" must be true or false');
|
|
1116
|
+
const rt = await getRuntime();
|
|
1117
|
+
const ctx = autostartCtx(rt);
|
|
1118
|
+
const home = rt.paths.home;
|
|
1119
|
+
const plan = await planAutostart(ctx, body.on ? 'on' : 'off');
|
|
1120
|
+
const shown = autostartPlanView(plan, home);
|
|
1121
|
+
if (!optBool(body, 'confirm') || plan.blocked)
|
|
1122
|
+
return { plan: shown, applied: false };
|
|
1123
|
+
const saw = body.programArguments;
|
|
1124
|
+
const want = shown.programArguments ?? [];
|
|
1125
|
+
if (plan.action === 'on' &&
|
|
1126
|
+
(!Array.isArray(saw) || saw.length !== want.length || saw.some((x, i) => x !== want[i])))
|
|
1127
|
+
return { plan: shown, applied: false, changedSince: true };
|
|
1128
|
+
const r = await applyAutostart(ctx, plan);
|
|
1129
|
+
return {
|
|
1130
|
+
applied: true,
|
|
1131
|
+
changed: r.changed,
|
|
1132
|
+
loaded: r.loaded,
|
|
1133
|
+
...(r.note ? { note: r.note.split(home).join('~') } : {}),
|
|
1134
|
+
status: autostartView(await autostartStatus(ctx), home),
|
|
1135
|
+
};
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
813
1138
|
// Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
|
|
814
1139
|
'/api/quota/teardown': {
|
|
815
1140
|
async run(body) {
|
|
@@ -821,6 +1146,29 @@ export function createPanelHandler(opts) {
|
|
|
821
1146
|
},
|
|
822
1147
|
},
|
|
823
1148
|
};
|
|
1149
|
+
// An AI Task attachment (spec §3.28 "附件"): the body is the file itself, streamed into the staging folder rather than
|
|
1150
|
+
// read into memory, with its own size limit. The type comes from its first bytes; `Content-Type` is not trusted.
|
|
1151
|
+
const receiveAttachment = async (req) => {
|
|
1152
|
+
const declared = Number(req.headers['content-length']);
|
|
1153
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES)
|
|
1154
|
+
throw new HttpError(413, 'This file is over 200 MB; attach a smaller one.');
|
|
1155
|
+
try {
|
|
1156
|
+
// Stopping early (a wrong type, too big) must not destroy the socket the answer goes out on.
|
|
1157
|
+
const { id, kind, bytes } = await stageUpload((await getRuntime()).paths, req.iterator({ destroyOnReturn: false }));
|
|
1158
|
+
return { id, kind, bytes };
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
const code = err.code;
|
|
1162
|
+
if (code === 'attachment-too-large')
|
|
1163
|
+
throw new HttpError(413, err.message);
|
|
1164
|
+
if (code === 'attachment-type')
|
|
1165
|
+
throw new HttpError(415, err.message);
|
|
1166
|
+
// The staging folder is full: the server's own limit, whatever the page allows.
|
|
1167
|
+
if (code === 'attachment-quota')
|
|
1168
|
+
throw new HttpError(507, err.message, code);
|
|
1169
|
+
throw err;
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
824
1172
|
const handle = async (req, res) => {
|
|
825
1173
|
let pathname;
|
|
826
1174
|
try {
|
|
@@ -840,6 +1188,11 @@ export function createPanelHandler(opts) {
|
|
|
840
1188
|
if (method === 'GET' || method === 'HEAD') {
|
|
841
1189
|
if (sub === '/' || sub === '/index.html') {
|
|
842
1190
|
const nonce = randomBytes(16).toString('base64');
|
|
1191
|
+
// The theme and language in config (spec §3.29), so the first paint has them; a config that cannot be
|
|
1192
|
+
// read leaves the defaults, and /api/state reports the problem.
|
|
1193
|
+
const panel = method === 'HEAD'
|
|
1194
|
+
? undefined
|
|
1195
|
+
: await getRuntime().then((rt) => rt.config.panel, () => undefined);
|
|
843
1196
|
res.writeHead(200, {
|
|
844
1197
|
'content-type': 'text/html; charset=utf-8',
|
|
845
1198
|
'cache-control': 'no-store',
|
|
@@ -858,7 +1211,19 @@ export function createPanelHandler(opts) {
|
|
|
858
1211
|
});
|
|
859
1212
|
res.end(method === 'HEAD'
|
|
860
1213
|
? undefined
|
|
861
|
-
: renderPage({
|
|
1214
|
+
: renderPage({
|
|
1215
|
+
basePath,
|
|
1216
|
+
token,
|
|
1217
|
+
nonce,
|
|
1218
|
+
version,
|
|
1219
|
+
build,
|
|
1220
|
+
local,
|
|
1221
|
+
readOnly,
|
|
1222
|
+
theme,
|
|
1223
|
+
lang,
|
|
1224
|
+
updates,
|
|
1225
|
+
...(panel ? { panel } : {}),
|
|
1226
|
+
}));
|
|
862
1227
|
return true;
|
|
863
1228
|
}
|
|
864
1229
|
// The mark, for a Portal's tab and for this page's favicon (ADR-0006 amendment).
|
|
@@ -883,18 +1248,28 @@ export function createPanelHandler(opts) {
|
|
|
883
1248
|
send(res, 200, { token });
|
|
884
1249
|
return true;
|
|
885
1250
|
}
|
|
1251
|
+
if (sub === '/api/terminals') {
|
|
1252
|
+
send(res, 200, await terminalsView(await getRuntime()));
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
// Start at login (spec §3.29): its state only; turning it on or off is the POST of the same path.
|
|
1256
|
+
if (sub === '/api/autostart') {
|
|
1257
|
+
const rt = await getRuntime();
|
|
1258
|
+
send(res, 200, autostartView(await autostartStatus(autostartCtx(rt)), rt.paths.home));
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
886
1261
|
if (sub === '/api/state') {
|
|
887
1262
|
send(res, 200, await buildState(await getRuntime(), { version, build, readOnly, updates }));
|
|
888
1263
|
return true;
|
|
889
1264
|
}
|
|
890
|
-
if (Object.hasOwn(routes, sub))
|
|
1265
|
+
if (Object.hasOwn(routes, sub) || sub === ATTACH_ROUTE)
|
|
891
1266
|
throw new HttpError(405, 'use POST');
|
|
892
1267
|
throw new HttpError(404, 'not found');
|
|
893
1268
|
}
|
|
894
1269
|
if (method !== 'POST')
|
|
895
1270
|
throw new HttpError(405, 'method not allowed');
|
|
896
1271
|
const route = Object.hasOwn(routes, sub) ? routes[sub] : undefined;
|
|
897
|
-
if (!route)
|
|
1272
|
+
if (!route && sub !== ATTACH_ROUTE)
|
|
898
1273
|
throw new HttpError(404, 'not found');
|
|
899
1274
|
const origin = req.headers.origin;
|
|
900
1275
|
if (!origin || origin.toLowerCase() !== `http://${host}`)
|
|
@@ -902,11 +1277,18 @@ export function createPanelHandler(opts) {
|
|
|
902
1277
|
const given = req.headers['x-sideby-token'];
|
|
903
1278
|
if (typeof given !== 'string' || !sameSecret(given, token))
|
|
904
1279
|
throw new HttpError(403, 'missing or wrong x-sideby-token; reload the page', 'bad-token');
|
|
1280
|
+
if (!route) {
|
|
1281
|
+
// An upload writes a file: a read-only Panel refuses it before reading a byte.
|
|
1282
|
+
if (readOnly)
|
|
1283
|
+
throw new HttpError(403, 'this panel is read-only');
|
|
1284
|
+
send(res, 200, await receiveAttachment(req));
|
|
1285
|
+
return true;
|
|
1286
|
+
}
|
|
905
1287
|
const body = (await readBody(req));
|
|
906
1288
|
if (readOnly && (route.writes?.(body) ?? true))
|
|
907
1289
|
throw new HttpError(403, 'this panel is read-only');
|
|
908
1290
|
// Reads that record history (doctor) also run in this queue, so overlapping checks merge in order.
|
|
909
|
-
send(res, 200, await serial(() => route.run(body)));
|
|
1291
|
+
send(res, 200, await (route.queued === false ? route.run(body) : serial(() => route.run(body))));
|
|
910
1292
|
return true;
|
|
911
1293
|
}
|
|
912
1294
|
catch (err) {
|
|
@@ -916,8 +1298,10 @@ export function createPanelHandler(opts) {
|
|
|
916
1298
|
}
|
|
917
1299
|
if (err instanceof HttpError) {
|
|
918
1300
|
const body = err.code ? { error: err.message, code: err.code } : { error: err.message };
|
|
919
|
-
|
|
920
|
-
|
|
1301
|
+
// A refused body (too big, or an upload stopped early) is left unread: close the connection after answering.
|
|
1302
|
+
const unread = err.status === 413 || (sub === ATTACH_ROUTE && !req.readableEnded);
|
|
1303
|
+
send(res, err.status, body, unread ? { connection: 'close' } : {});
|
|
1304
|
+
if (unread)
|
|
921
1305
|
req.resume();
|
|
922
1306
|
}
|
|
923
1307
|
else if (err instanceof UserError) {
|