sideby 0.16.0 → 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 +460 -5
- package/dist/src/cli/json-schemas.js +115 -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 +17 -0
- package/dist/src/core/local-build.js +39 -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 +17 -0
- package/dist/src/core/update.js +63 -1
- 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 +396 -9
- package/dist/src/panel/i18n.d.ts +175 -2
- package/dist/src/panel/i18n.js +381 -35
- package/dist/src/panel/page-logic.d.ts +214 -0
- package/dist/src/panel/page-logic.js +394 -0
- package/dist/src/panel/page-script.js +984 -134
- 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/local.json +4 -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
- package/skills/sideby/references/update.md +1 -1
|
@@ -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,
|
|
@@ -745,8 +1016,10 @@ export function createPanelHandler(opts) {
|
|
|
745
1016
|
if (!optBool(body, 'confirm'))
|
|
746
1017
|
return { ...(await lb.localStatus(d)), to, switched: false };
|
|
747
1018
|
const { backgroundPanelPort } = await import('./background.js');
|
|
1019
|
+
// The skill fetch can outlast what a Portal waits for: it runs on its own, and the answer comes at once.
|
|
748
1020
|
const r = await lb.switchLocal(rt.paths, d, to, rt.env, {
|
|
749
1021
|
panelPort: () => backgroundPanelPort(rt.paths),
|
|
1022
|
+
skillSync: 'background',
|
|
750
1023
|
});
|
|
751
1024
|
if (!r.ok)
|
|
752
1025
|
throw new UserError(r.message);
|
|
@@ -756,6 +1029,7 @@ export function createPanelHandler(opts) {
|
|
|
756
1029
|
switched: !r.unchanged,
|
|
757
1030
|
message: r.message,
|
|
758
1031
|
restartedPanel: r.restartedPanel,
|
|
1032
|
+
...(r.skillSyncStarted === undefined ? {} : { skillSyncStarted: r.skillSyncStarted }),
|
|
759
1033
|
};
|
|
760
1034
|
},
|
|
761
1035
|
},
|
|
@@ -807,6 +1081,60 @@ export function createPanelHandler(opts) {
|
|
|
807
1081
|
return { family, ...(await rt.setQuotaCheck(family, body.on)) };
|
|
808
1082
|
},
|
|
809
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
|
+
},
|
|
810
1138
|
// Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
|
|
811
1139
|
'/api/quota/teardown': {
|
|
812
1140
|
async run(body) {
|
|
@@ -818,6 +1146,29 @@ export function createPanelHandler(opts) {
|
|
|
818
1146
|
},
|
|
819
1147
|
},
|
|
820
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
|
+
};
|
|
821
1172
|
const handle = async (req, res) => {
|
|
822
1173
|
let pathname;
|
|
823
1174
|
try {
|
|
@@ -837,6 +1188,11 @@ export function createPanelHandler(opts) {
|
|
|
837
1188
|
if (method === 'GET' || method === 'HEAD') {
|
|
838
1189
|
if (sub === '/' || sub === '/index.html') {
|
|
839
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);
|
|
840
1196
|
res.writeHead(200, {
|
|
841
1197
|
'content-type': 'text/html; charset=utf-8',
|
|
842
1198
|
'cache-control': 'no-store',
|
|
@@ -855,7 +1211,19 @@ export function createPanelHandler(opts) {
|
|
|
855
1211
|
});
|
|
856
1212
|
res.end(method === 'HEAD'
|
|
857
1213
|
? undefined
|
|
858
|
-
: 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
|
+
}));
|
|
859
1227
|
return true;
|
|
860
1228
|
}
|
|
861
1229
|
// The mark, for a Portal's tab and for this page's favicon (ADR-0006 amendment).
|
|
@@ -880,18 +1248,28 @@ export function createPanelHandler(opts) {
|
|
|
880
1248
|
send(res, 200, { token });
|
|
881
1249
|
return true;
|
|
882
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
|
+
}
|
|
883
1261
|
if (sub === '/api/state') {
|
|
884
1262
|
send(res, 200, await buildState(await getRuntime(), { version, build, readOnly, updates }));
|
|
885
1263
|
return true;
|
|
886
1264
|
}
|
|
887
|
-
if (Object.hasOwn(routes, sub))
|
|
1265
|
+
if (Object.hasOwn(routes, sub) || sub === ATTACH_ROUTE)
|
|
888
1266
|
throw new HttpError(405, 'use POST');
|
|
889
1267
|
throw new HttpError(404, 'not found');
|
|
890
1268
|
}
|
|
891
1269
|
if (method !== 'POST')
|
|
892
1270
|
throw new HttpError(405, 'method not allowed');
|
|
893
1271
|
const route = Object.hasOwn(routes, sub) ? routes[sub] : undefined;
|
|
894
|
-
if (!route)
|
|
1272
|
+
if (!route && sub !== ATTACH_ROUTE)
|
|
895
1273
|
throw new HttpError(404, 'not found');
|
|
896
1274
|
const origin = req.headers.origin;
|
|
897
1275
|
if (!origin || origin.toLowerCase() !== `http://${host}`)
|
|
@@ -899,11 +1277,18 @@ export function createPanelHandler(opts) {
|
|
|
899
1277
|
const given = req.headers['x-sideby-token'];
|
|
900
1278
|
if (typeof given !== 'string' || !sameSecret(given, token))
|
|
901
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
|
+
}
|
|
902
1287
|
const body = (await readBody(req));
|
|
903
1288
|
if (readOnly && (route.writes?.(body) ?? true))
|
|
904
1289
|
throw new HttpError(403, 'this panel is read-only');
|
|
905
1290
|
// Reads that record history (doctor) also run in this queue, so overlapping checks merge in order.
|
|
906
|
-
send(res, 200, await serial(() => route.run(body)));
|
|
1291
|
+
send(res, 200, await (route.queued === false ? route.run(body) : serial(() => route.run(body))));
|
|
907
1292
|
return true;
|
|
908
1293
|
}
|
|
909
1294
|
catch (err) {
|
|
@@ -913,8 +1298,10 @@ export function createPanelHandler(opts) {
|
|
|
913
1298
|
}
|
|
914
1299
|
if (err instanceof HttpError) {
|
|
915
1300
|
const body = err.code ? { error: err.message, code: err.code } : { error: err.message };
|
|
916
|
-
|
|
917
|
-
|
|
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)
|
|
918
1305
|
req.resume();
|
|
919
1306
|
}
|
|
920
1307
|
else if (err instanceof UserError) {
|