dsh-sidecard-ask 2.0.1 → 2.1.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/CHANGELOG.md +167 -0
- package/README.md +62 -16
- package/client.js +505 -103
- package/index.js +457 -68
- package/package.json +69 -66
package/index.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 1. Serve the plugin's own JSON + SSE API under `/sidecard-ask/api`.
|
|
6
6
|
* 2. Own the plugin's configuration: built-in defaults, the bundle patch
|
|
7
7
|
* layer, and the user layer persisted to
|
|
8
|
-
* `<DSH_HOME>/sidecard-ask/config
|
|
8
|
+
* `<DSH_HOME>/sidecard-ask/config.<DSH_PROFILE>.json` (the plain
|
|
9
|
+
* `config.json` when no profile is set).
|
|
9
10
|
* 3. Run one independent "side answer" per request: a child Agent started
|
|
10
11
|
* through `ctx.subagents` whose live deltas are bridged from the
|
|
11
12
|
* process-local `agent/assistant-stream` event onto the SSE response.
|
|
@@ -44,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
|
|
|
44
45
|
export const inject = ['webServer']
|
|
45
46
|
|
|
46
47
|
/** Version of this plugin (kept in step with package.json by test/verify.mjs). */
|
|
47
|
-
export const PLUGIN_VERSION = '2.0
|
|
48
|
+
export const PLUGIN_VERSION = '2.1.0'
|
|
48
49
|
|
|
49
50
|
/** Route prefix of the plugin's own API. */
|
|
50
51
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -58,6 +59,22 @@ const SSE_HEARTBEAT_MS = 15_000
|
|
|
58
59
|
/** Hard cap on the prompt text handed to the child (defense in depth). */
|
|
59
60
|
const MAX_PROMPT_CHARS = 60_000
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Hard cap on the follow-up question inside the prompt (defense in depth).
|
|
64
|
+
*
|
|
65
|
+
* The question is the child's instruction, so it is capped far above anything
|
|
66
|
+
* a person types and far below the prompt budget: an unbounded question used
|
|
67
|
+
* to be able to squeeze the selection down to a single character.
|
|
68
|
+
*/
|
|
69
|
+
const MAX_QUESTION_CHARS = 20_000
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Code points the selection keeps before earlier turns are dropped from the
|
|
73
|
+
* prompt. Without a floor, a long question plus a full history could starve
|
|
74
|
+
* the selection to one character while the prompt still overflowed.
|
|
75
|
+
*/
|
|
76
|
+
const SELECTION_FLOOR_CHARS = 2_000
|
|
77
|
+
|
|
61
78
|
/**
|
|
62
79
|
* Built-in defaults. `cordis.patch.yml` overrides these; the user layer
|
|
63
80
|
* persisted by the Client settings page overrides the patch.
|
|
@@ -172,7 +189,7 @@ export const DEFAULT_CONFIG = {
|
|
|
172
189
|
* SMTP transport, editable in the settings page so a normal user never has to
|
|
173
190
|
* touch environment variables.
|
|
174
191
|
*
|
|
175
|
-
* These live in the USER layer only (`<DSH_HOME>/sidecard-ask/config
|
|
192
|
+
* These live in the USER layer only (`<DSH_HOME>/sidecard-ask/config.<profile>.json`,
|
|
176
193
|
* outside the repository, never in a release artifact). The environment still
|
|
177
194
|
* works and still wins nothing: an empty host means "read the environment".
|
|
178
195
|
* The password is write-only over the wire: `/state` never returns it, an
|
|
@@ -203,6 +220,14 @@ const QUICK_ACTION_LIMITS = {
|
|
|
203
220
|
*/
|
|
204
221
|
export const HISTORY_TURNS = 6
|
|
205
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Per-turn character caps inside the built prompt: a single very long earlier
|
|
225
|
+
* answer must not consume the whole 60k character budget and push the current
|
|
226
|
+
* question out (see {@link buildSidePrompt}'s budget allocation).
|
|
227
|
+
*/
|
|
228
|
+
const HISTORY_TURN_Q_CHARS = 500
|
|
229
|
+
const HISTORY_TURN_A_CHARS = 4000
|
|
230
|
+
|
|
206
231
|
/** Allowed values per enum key; anything else falls back to the default. */
|
|
207
232
|
const ENUMS = {
|
|
208
233
|
trigger: ['selection', 'shortcut', 'both'],
|
|
@@ -522,6 +547,10 @@ export function normalizeConfig(patchConfig, persisted) {
|
|
|
522
547
|
* a Plane-2 CJK glyph spans two code units, and slicing the pair's midpoint
|
|
523
548
|
* would push a broken character into the child's prompt. The client mirrors
|
|
524
549
|
* this helper exactly (asserted by the contract test).
|
|
550
|
+
*
|
|
551
|
+
* The omission note is charged to the budget, not added on top of it: an
|
|
552
|
+
* "inclusive cap" that the note itself overflows by ~19 characters is not a
|
|
553
|
+
* cap, and the caller sizes this budget from what a fixed prompt leaves over.
|
|
525
554
|
* @param {string} text - the selected text.
|
|
526
555
|
* @param {number} maxChars - inclusive cap, in code points.
|
|
527
556
|
* @returns {{text: string, truncated: boolean, droppedChars: number}}
|
|
@@ -529,13 +558,21 @@ export function normalizeConfig(patchConfig, persisted) {
|
|
|
529
558
|
export function truncateSelection(text, maxChars) {
|
|
530
559
|
const source = typeof text === 'string' ? text : ''
|
|
531
560
|
const points = Array.from(source)
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
561
|
+
const cap = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : points.length
|
|
562
|
+
if (points.length <= cap) return { text: source, truncated: false, droppedChars: 0 }
|
|
563
|
+
// Reserve the note's UPPER bound: the dropped count never exceeds the total,
|
|
564
|
+
// so the widest possible note is what the budget has to survive.
|
|
565
|
+
const noteLength = Array.from(`\n…(已省略中间 ${points.length} 个字符)…\n`).length
|
|
566
|
+
const room = cap - noteLength
|
|
567
|
+
if (room < 2) {
|
|
568
|
+
// Too little budget for a note: a plain head cut stays inside the cap.
|
|
569
|
+
return { text: points.slice(0, cap).join(''), truncated: true, droppedChars: points.length - cap }
|
|
570
|
+
}
|
|
571
|
+
const head = Math.max(1, Math.ceil(room * 0.7))
|
|
572
|
+
const tail = Math.max(0, room - head)
|
|
535
573
|
const dropped = points.length - head - tail
|
|
536
|
-
const marker = `\n…(已省略中间 ${dropped} 个字符)…\n`
|
|
537
574
|
return {
|
|
538
|
-
text: `${points.slice(0, head).join('')}${
|
|
575
|
+
text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
|
|
539
576
|
truncated: true,
|
|
540
577
|
droppedChars: dropped,
|
|
541
578
|
}
|
|
@@ -556,24 +593,88 @@ export function truncateSelection(text, maxChars) {
|
|
|
556
593
|
* @returns {string} the prompt text.
|
|
557
594
|
*/
|
|
558
595
|
export function buildSidePrompt(input) {
|
|
596
|
+
return composeSidePrompt(input).text
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Build the child's prompt and report what the budget cost the user.
|
|
601
|
+
*
|
|
602
|
+
* The caller used to learn nothing about prompt-side truncation: a selection
|
|
603
|
+
* lost to the budget was silent because only the client-side cut was reported.
|
|
604
|
+
* @param {{selection: string, question: string, zone: string, truncated: boolean,
|
|
605
|
+
* history: Array<{question: string, answer: string}>, historyTurns: number}} input
|
|
606
|
+
* @returns {{text: string, truncated: boolean, droppedChars: number,
|
|
607
|
+
* historyTurns: number, historyDropped: number}} the prompt and its budget cost.
|
|
608
|
+
*/
|
|
609
|
+
function composeSidePrompt(input) {
|
|
559
610
|
const zoneLabel = { chat: 'chat area', task: 'task area', other: 'elsewhere' }[input.zone] ?? 'elsewhere'
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
]
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
611
|
+
const capTurn = (value, limit) => {
|
|
612
|
+
const text = typeof value === 'string' ? value : ''
|
|
613
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}…`
|
|
614
|
+
}
|
|
615
|
+
// Last N turns; each turn's fields are individually bounded so one long
|
|
616
|
+
// earlier answer cannot consume the whole budget. Entries that are not
|
|
617
|
+
// objects are dropped instead of thrown on: `history: [null]` used to raise a
|
|
618
|
+
// TypeError out of `ask()` before its try block, which reached the Client as
|
|
619
|
+
// a raw English message and with no `start` frame at all.
|
|
620
|
+
const limit = Number.isFinite(input.historyTurns)
|
|
621
|
+
? Math.max(0, Math.floor(input.historyTurns))
|
|
622
|
+
: HISTORY_TURNS
|
|
623
|
+
const validTurns = (Array.isArray(input.history) ? input.history : [])
|
|
624
|
+
.filter(turn => turn !== null && typeof turn === 'object' && !Array.isArray(turn))
|
|
625
|
+
// `slice(-0)` is `slice(0)` — the whole list. A caller asking for zero turns
|
|
626
|
+
// must get zero turns, not every turn.
|
|
627
|
+
const turns = limit === 0 ? [] : validTurns.slice(-limit)
|
|
628
|
+
const questionBlock = `Question: ${capTurn(input.question, MAX_QUESTION_CHARS)}`
|
|
629
|
+
const sourceLine = `Source: ${zoneLabel}${input.truncated ? ' (selection too long; truncated)' : ''}`
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Assemble the prompt for one selection and one set of turns. Used both for
|
|
633
|
+
* the real prompt and for the empty-selection skeleton, so the fixed length
|
|
634
|
+
* is MEASURED instead of re-derived: the old hand-counted sum missed the
|
|
635
|
+
* separator between the selection and its closing fence.
|
|
636
|
+
*/
|
|
637
|
+
const assemble = (selection, historyTurns) => {
|
|
638
|
+
const parts = [sourceLine, 'Selection:', '```text', selection, '```']
|
|
639
|
+
if (historyTurns.length > 0) {
|
|
640
|
+
const historyLines = ['Earlier turns on this card:']
|
|
641
|
+
for (const turn of historyTurns) {
|
|
642
|
+
historyLines.push(`Question: ${capTurn(turn.question, HISTORY_TURN_Q_CHARS)}`)
|
|
643
|
+
const answer = capTurn(turn.answer, HISTORY_TURN_A_CHARS)
|
|
644
|
+
if (answer !== '') historyLines.push(`Answer: ${answer}`)
|
|
645
|
+
}
|
|
646
|
+
parts.push(historyLines.join('\n'))
|
|
647
|
+
}
|
|
648
|
+
parts.push(questionBlock)
|
|
649
|
+
return parts.join('\n')
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const rawSelection = typeof input.selection === 'string' ? input.selection : ''
|
|
653
|
+
const selectionLength = Array.from(rawSelection).length
|
|
654
|
+
// The question is the instruction and must survive the cap; the history
|
|
655
|
+
// block is fixed-length; only the SELECTION absorbs what remains. Slicing the
|
|
656
|
+
// whole joined string used to drop "Question:" when the selection filled 60k
|
|
657
|
+
// characters, leaving the child with no instruction.
|
|
658
|
+
//
|
|
659
|
+
// The history is the only slack: if the fixed blocks would starve the
|
|
660
|
+
// selection below its floor, the OLDEST turns go first (the newest context is
|
|
661
|
+
// the most relevant), and if even a bare skeleton cannot hold the floor the
|
|
662
|
+
// question cap above is what keeps the budget finite.
|
|
663
|
+
const floor = Math.min(SELECTION_FLOOR_CHARS, selectionLength)
|
|
664
|
+
let kept = turns
|
|
665
|
+
let skeleton = assemble('', kept).length
|
|
666
|
+
while (kept.length > 0 && MAX_PROMPT_CHARS - skeleton < floor) {
|
|
667
|
+
kept = kept.slice(1)
|
|
668
|
+
skeleton = assemble('', kept).length
|
|
669
|
+
}
|
|
670
|
+
const cut = truncateSelection(rawSelection, Math.max(0, MAX_PROMPT_CHARS - skeleton))
|
|
671
|
+
return {
|
|
672
|
+
text: assemble(cut.text, kept),
|
|
673
|
+
truncated: cut.truncated || kept.length !== turns.length,
|
|
674
|
+
droppedChars: cut.droppedChars,
|
|
675
|
+
historyTurns: kept.length,
|
|
676
|
+
historyDropped: turns.length - kept.length,
|
|
574
677
|
}
|
|
575
|
-
parts.push('Question:', input.question)
|
|
576
|
-
return parts.join('\n').slice(0, MAX_PROMPT_CHARS)
|
|
577
678
|
}
|
|
578
679
|
|
|
579
680
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -725,8 +826,20 @@ export function parseSmtpUrl(raw) {
|
|
|
725
826
|
if (host === '') return { ok: false, reason: 'SMTP 地址缺少主机名' }
|
|
726
827
|
const port = url.port === '' ? (secure ? 465 : 587) : Number(url.port)
|
|
727
828
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) return { ok: false, reason: 'SMTP 端口不在 1–65535 之间' }
|
|
728
|
-
|
|
729
|
-
|
|
829
|
+
// A password pasted into an environment variable is not URL-escaped by its
|
|
830
|
+
// author: `pass%word` reaches here as `%wo`, and `decodeURIComponent` rejects
|
|
831
|
+
// that with a URIError. The throw used to escape `parseSmtpUrl` →
|
|
832
|
+
// `readSmtpFromEnv` → `describeFeedback` → `/state`, where it surfaced as
|
|
833
|
+
// "host unreachable" and took the whole settings page with it — one stray `%`
|
|
834
|
+
// in a credential must be a config error, never a broken page.
|
|
835
|
+
let user
|
|
836
|
+
let pass
|
|
837
|
+
try {
|
|
838
|
+
user = decodeURIComponent(url.username ?? '')
|
|
839
|
+
pass = decodeURIComponent(url.password ?? '')
|
|
840
|
+
} catch {
|
|
841
|
+
return { ok: false, reason: 'SMTP 账号或密码含未转义的 %(请把 % 写成 %25)' }
|
|
842
|
+
}
|
|
730
843
|
if (user === '' || pass === '') return { ok: false, reason: 'SMTP 地址需要 user:pass' }
|
|
731
844
|
const from = (url.searchParams.get('from') ?? '').trim() || user
|
|
732
845
|
if (!looksLikeEmail(from)) return { ok: false, reason: '发件邮箱格式不合法(由 ?from= 指定)' }
|
|
@@ -818,18 +931,28 @@ export function resolveSmtp(config = {}, env = process.env) {
|
|
|
818
931
|
|
|
819
932
|
/**
|
|
820
933
|
* The SMTP settings as the page may see them: everything except the password,
|
|
821
|
-
* which is reduced to
|
|
934
|
+
* which is reduced to boolean facts.
|
|
822
935
|
*/
|
|
823
936
|
export function describeSmtpSettings(config = {}, env = process.env) {
|
|
824
937
|
const resolved = resolveSmtp(config, env)
|
|
938
|
+
const stored = typeof config.feedbackSmtpPass === 'string' && config.feedbackSmtpPass !== ''
|
|
939
|
+
const source = resolved.ok === true ? resolved.source : ''
|
|
825
940
|
return {
|
|
826
941
|
host: typeof config.feedbackSmtpHost === 'string' ? config.feedbackSmtpHost.trim() : '',
|
|
827
942
|
port: clampNumber(config.feedbackSmtpPort, 1, 65_535) ?? 465,
|
|
828
943
|
secure: config.feedbackSmtpSecure !== false,
|
|
829
944
|
user: typeof config.feedbackSmtpUser === 'string' ? config.feedbackSmtpUser.trim() : '',
|
|
830
945
|
from: typeof config.feedbackSmtpFrom === 'string' ? config.feedbackSmtpFrom.trim() : '',
|
|
831
|
-
|
|
832
|
-
|
|
946
|
+
// True when a password is actually available to send with, from EITHER
|
|
947
|
+
// layer. This used to read the config field only, so an operator who
|
|
948
|
+
// exported the credentials got `configured: true` next to
|
|
949
|
+
// `hasPassword: false` — a descriptor that contradicted itself and made a
|
|
950
|
+
// working setup look broken in diagnostics.
|
|
951
|
+
hasPassword: source === 'env' ? true : stored,
|
|
952
|
+
// Which layer the active credentials come from, and whether the settings
|
|
953
|
+
// form itself holds a password (what "leave blank to keep it" refers to).
|
|
954
|
+
source,
|
|
955
|
+
storedPassword: stored,
|
|
833
956
|
}
|
|
834
957
|
}
|
|
835
958
|
|
|
@@ -1133,6 +1256,28 @@ export function configDir() {
|
|
|
1133
1256
|
return join(base, 'sidecard-ask')
|
|
1134
1257
|
}
|
|
1135
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* The persisted-config file for THIS harness profile.
|
|
1261
|
+
*
|
|
1262
|
+
* `$DSH_HOME` is one directory shared by every profile of an installation —
|
|
1263
|
+
* the web shell and the desktop shell are two profiles over the same home — so
|
|
1264
|
+
* a single `config.json` let one end's save silently rewrite the other end's
|
|
1265
|
+
* settings (a web-only `sideTools: 'inherit'` then applied to desktop runs).
|
|
1266
|
+
* `$DSH_PROFILE` is the harness' own profile identity, so it becomes part of
|
|
1267
|
+
* the file name.
|
|
1268
|
+
*
|
|
1269
|
+
* The profile segment is restricted to a conservative token: this value
|
|
1270
|
+
* reaches a filesystem path, so anything else falls back to the shared file
|
|
1271
|
+
* rather than escaping the directory. A shell that sets no profile (older
|
|
1272
|
+
* harness, bare `node` runs) keeps the historical path exactly.
|
|
1273
|
+
* @returns {string} absolute file path.
|
|
1274
|
+
*/
|
|
1275
|
+
export function configFilePath() {
|
|
1276
|
+
const profile = typeof process.env.DSH_PROFILE === 'string' ? process.env.DSH_PROFILE.trim() : ''
|
|
1277
|
+
const slug = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile) ? profile : ''
|
|
1278
|
+
return join(configDir(), slug === '' ? 'config.json' : `config.${slug}.json`)
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1136
1281
|
/**
|
|
1137
1282
|
* Build the read-only tool restriction for a child run.
|
|
1138
1283
|
*
|
|
@@ -1246,7 +1391,10 @@ function writeOk(res, value) {
|
|
|
1246
1391
|
* relies on `retry-after` surviving).
|
|
1247
1392
|
*/
|
|
1248
1393
|
function writeError(res, status, code, message, extraHeaders = {}) {
|
|
1249
|
-
|
|
1394
|
+
if (res === null || res === undefined || res.destroyed === true || res.headersSent === true) return
|
|
1395
|
+
try {
|
|
1396
|
+
writeJson(res, status, { ok: false, error: { code, message } }, extraHeaders)
|
|
1397
|
+
} catch { /* the socket is already gone — nothing to write */ }
|
|
1250
1398
|
}
|
|
1251
1399
|
|
|
1252
1400
|
/**
|
|
@@ -1257,27 +1405,54 @@ function readJsonBody(req) {
|
|
|
1257
1405
|
return new Promise((resolve, reject) => {
|
|
1258
1406
|
const chunks = []
|
|
1259
1407
|
let size = 0
|
|
1260
|
-
|
|
1408
|
+
let settled = false
|
|
1409
|
+
const cleanup = () => {
|
|
1410
|
+
// Guard: synthetic request stubs (and very old streams) may only implement
|
|
1411
|
+
// on(); removeListener is a no-op there rather than a crash.
|
|
1412
|
+
if (typeof req.removeListener === 'function') {
|
|
1413
|
+
req.removeListener('data', onData)
|
|
1414
|
+
req.removeListener('end', onEnd)
|
|
1415
|
+
}
|
|
1416
|
+
// The 'error' listener stays attached for the socket lifetime: removing
|
|
1417
|
+
// it would turn a later ECONNRESET after destroy into an unhandled crash;
|
|
1418
|
+
// onError no-ops once settled is true.
|
|
1419
|
+
}
|
|
1420
|
+
const fail = (error) => {
|
|
1421
|
+
if (settled) return
|
|
1422
|
+
settled = true
|
|
1423
|
+
cleanup()
|
|
1424
|
+
reject(error)
|
|
1425
|
+
}
|
|
1426
|
+
const onData = (chunk) => {
|
|
1261
1427
|
size += chunk.length
|
|
1262
1428
|
if (size > MAX_BODY_BYTES) {
|
|
1263
|
-
|
|
1429
|
+
fail(Object.assign(new Error('请求体过大'), { code: 'too-large' }))
|
|
1264
1430
|
req.destroy()
|
|
1265
1431
|
return
|
|
1266
1432
|
}
|
|
1267
1433
|
chunks.push(chunk)
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1434
|
+
}
|
|
1435
|
+
const onEnd = () => {
|
|
1436
|
+
if (settled) return
|
|
1270
1437
|
if (size === 0) {
|
|
1438
|
+
settled = true
|
|
1439
|
+
cleanup()
|
|
1271
1440
|
resolve(null)
|
|
1272
1441
|
return
|
|
1273
1442
|
}
|
|
1274
1443
|
try {
|
|
1275
|
-
|
|
1444
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
1445
|
+
settled = true
|
|
1446
|
+
cleanup()
|
|
1447
|
+
resolve(value)
|
|
1276
1448
|
} catch {
|
|
1277
|
-
|
|
1449
|
+
fail(Object.assign(new Error('请求体不是合法 JSON'), { code: 'bad-request' }))
|
|
1278
1450
|
}
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1451
|
+
}
|
|
1452
|
+
const onError = (error) => { fail(error) }
|
|
1453
|
+
req.on('data', onData)
|
|
1454
|
+
req.on('end', onEnd)
|
|
1455
|
+
req.on('error', onError)
|
|
1281
1456
|
})
|
|
1282
1457
|
}
|
|
1283
1458
|
|
|
@@ -1290,12 +1465,24 @@ function openSse(res) {
|
|
|
1290
1465
|
'x-accel-buffering': 'no',
|
|
1291
1466
|
})
|
|
1292
1467
|
let closed = false
|
|
1293
|
-
|
|
1468
|
+
let heartbeat = null
|
|
1469
|
+
/**
|
|
1470
|
+
* Mark the stream dead and stop the heartbeat exactly once.
|
|
1471
|
+
*
|
|
1472
|
+
* `end()` alone was not enough: a failed `write` set `closed = true`, and
|
|
1473
|
+
* `end()` then early-returned on that flag WITHOUT clearing the interval — an
|
|
1474
|
+
* orphaned timer ticking every 15s for the rest of the process lifetime.
|
|
1475
|
+
*/
|
|
1476
|
+
const shutdown = () => {
|
|
1477
|
+
closed = true
|
|
1478
|
+
if (heartbeat !== null) clearInterval(heartbeat)
|
|
1479
|
+
}
|
|
1480
|
+
heartbeat = setInterval(() => {
|
|
1294
1481
|
if (closed) return
|
|
1295
1482
|
try {
|
|
1296
1483
|
res.write(': keep-alive\n\n')
|
|
1297
1484
|
} catch {
|
|
1298
|
-
|
|
1485
|
+
shutdown()
|
|
1299
1486
|
}
|
|
1300
1487
|
}, SSE_HEARTBEAT_MS)
|
|
1301
1488
|
heartbeat.unref?.()
|
|
@@ -1306,14 +1493,13 @@ function openSse(res) {
|
|
|
1306
1493
|
try {
|
|
1307
1494
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
1308
1495
|
} catch {
|
|
1309
|
-
|
|
1496
|
+
shutdown()
|
|
1310
1497
|
}
|
|
1311
1498
|
},
|
|
1312
1499
|
/** End the stream exactly once. */
|
|
1313
1500
|
end() {
|
|
1314
1501
|
if (closed) return
|
|
1315
|
-
|
|
1316
|
-
clearInterval(heartbeat)
|
|
1502
|
+
shutdown()
|
|
1317
1503
|
try {
|
|
1318
1504
|
res.end()
|
|
1319
1505
|
} catch {
|
|
@@ -1564,7 +1750,8 @@ function createSideEngine(ctx, configOf) {
|
|
|
1564
1750
|
}
|
|
1565
1751
|
|
|
1566
1752
|
/** Report what the side card can do right now. */
|
|
1567
|
-
function capabilities() {
|
|
1753
|
+
function capabilities() {
|
|
1754
|
+
const providers = providerNames()
|
|
1568
1755
|
const service = subagentsService()
|
|
1569
1756
|
const parent = resolveParent(undefined)
|
|
1570
1757
|
return {
|
|
@@ -1663,7 +1850,9 @@ function createSideEngine(ctx, configOf) {
|
|
|
1663
1850
|
}
|
|
1664
1851
|
|
|
1665
1852
|
const cut = truncateSelection(rawSelection, config.maxChars)
|
|
1666
|
-
|
|
1853
|
+
// `composeSidePrompt` reports what the total prompt budget cost so the
|
|
1854
|
+
// start frame can surface it; `buildSidePrompt` is the string-only wrapper.
|
|
1855
|
+
const composed = composeSidePrompt({
|
|
1667
1856
|
selection: cut.text,
|
|
1668
1857
|
question,
|
|
1669
1858
|
zone: typeof request.zone === 'string' ? request.zone : 'other',
|
|
@@ -1671,6 +1860,7 @@ function createSideEngine(ctx, configOf) {
|
|
|
1671
1860
|
history: Array.isArray(request.history) ? request.history : [],
|
|
1672
1861
|
historyTurns: HISTORY_TURNS,
|
|
1673
1862
|
})
|
|
1863
|
+
const prompt = composed.text
|
|
1674
1864
|
|
|
1675
1865
|
const controller = new AbortController()
|
|
1676
1866
|
const abortOnClientGone = () => { controller.abort() }
|
|
@@ -1734,6 +1924,12 @@ function createSideEngine(ctx, configOf) {
|
|
|
1734
1924
|
maxChars: config.maxChars,
|
|
1735
1925
|
truncated: cut.truncated,
|
|
1736
1926
|
droppedChars: cut.droppedChars,
|
|
1927
|
+
// Prompt-side budget cost, which the Client could not see before: the
|
|
1928
|
+
// selection can lose MORE characters to the total prompt budget than
|
|
1929
|
+
// the client-side cut dropped, and older turns can be shed entirely.
|
|
1930
|
+
promptTruncated: composed.truncated,
|
|
1931
|
+
promptDroppedChars: composed.droppedChars,
|
|
1932
|
+
promptHistoryDropped: composed.historyDropped,
|
|
1737
1933
|
// Reported so the card can say the run inherits the session's tools
|
|
1738
1934
|
// instead of silently pretending the read-only guard is in force.
|
|
1739
1935
|
toolFilter: config.sideTools !== 'readonly'
|
|
@@ -1812,6 +2008,15 @@ function createSideEngine(ctx, configOf) {
|
|
|
1812
2008
|
.trim()
|
|
1813
2009
|
text = joined
|
|
1814
2010
|
}
|
|
2011
|
+
// Non-streaming builds lose reasoning when only the text fallback ran:
|
|
2012
|
+
// also recover reasoning/thinking blocks from the durable result.output.
|
|
2013
|
+
if (reasoning.trim() === '') {
|
|
2014
|
+
reasoning = (Array.isArray(result?.output) ? result.output : [])
|
|
2015
|
+
.filter(block => (block?.type === 'reasoning' || block?.type === 'thinking') && typeof block.text === 'string')
|
|
2016
|
+
.map(block => block.text)
|
|
2017
|
+
.join('\n')
|
|
2018
|
+
.trim()
|
|
2019
|
+
}
|
|
1815
2020
|
const stopReason = typeof result?.stopReason === 'string' ? result.stopReason : 'completed'
|
|
1816
2021
|
if (text.trim() === '' && stopReason !== 'completed') {
|
|
1817
2022
|
// `refusal` is the subagent seam's name for a turn the loop ended as
|
|
@@ -1841,6 +2046,9 @@ function createSideEngine(ctx, configOf) {
|
|
|
1841
2046
|
text,
|
|
1842
2047
|
reasoning,
|
|
1843
2048
|
streaming: framesSeen || chunksSeen,
|
|
2049
|
+
// True when the child returned no prose at all (reasoning-only or a
|
|
2050
|
+
// silent completion); the Client already shows its empty notice.
|
|
2051
|
+
empty: text.trim() === '',
|
|
1844
2052
|
// Which source carried the deltas — the Client uses it only for the
|
|
1845
2053
|
// "this build publishes no stream frames" note.
|
|
1846
2054
|
streamSource: framesSeen ? 'frames' : (chunksSeen ? 'chunks' : 'result'),
|
|
@@ -1904,14 +2112,13 @@ function createSideEngine(ctx, configOf) {
|
|
|
1904
2112
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1905
2113
|
|
|
1906
2114
|
/**
|
|
1907
|
-
* Read
|
|
1908
|
-
*
|
|
2115
|
+
* Read ONE config file. A missing or unreadable file is not an error — it only
|
|
2116
|
+
* means "no user overrides here".
|
|
2117
|
+
* @param {string} path - absolute file path.
|
|
1909
2118
|
* @param {object} [logger] - optional ctx.logger.
|
|
1910
2119
|
* @returns {{data: Record<string, unknown>, path: string, present: boolean, error?: string}}
|
|
1911
2120
|
*/
|
|
1912
|
-
function
|
|
1913
|
-
const dir = configDir()
|
|
1914
|
-
const path = join(dir, 'config.json')
|
|
2121
|
+
function readConfigFile(path, logger) {
|
|
1915
2122
|
try {
|
|
1916
2123
|
const raw = readFileSync(path, 'utf8')
|
|
1917
2124
|
const parsed = JSON.parse(raw)
|
|
@@ -1925,19 +2132,47 @@ function readPersistedConfig(logger) {
|
|
|
1925
2132
|
}
|
|
1926
2133
|
}
|
|
1927
2134
|
|
|
2135
|
+
/**
|
|
2136
|
+
* Read the persisted user layer for this profile.
|
|
2137
|
+
*
|
|
2138
|
+
* A profile-scoped file that has never been written inherits the old shared
|
|
2139
|
+
* `config.json` once, so an existing installation keeps its settings when the
|
|
2140
|
+
* file name becomes profile-aware. The shared file is never moved or deleted:
|
|
2141
|
+
* an older plugin build still reads it.
|
|
2142
|
+
* @param {object} [logger] - optional ctx.logger.
|
|
2143
|
+
* @returns {{data: Record<string, unknown>, path: string, present: boolean,
|
|
2144
|
+
* inheritedFrom?: string, error?: string}}
|
|
2145
|
+
*/
|
|
2146
|
+
function readPersistedConfig(logger) {
|
|
2147
|
+
const path = configFilePath()
|
|
2148
|
+
const own = readConfigFile(path, logger)
|
|
2149
|
+
if (own.present === true) return own
|
|
2150
|
+
const legacy = join(configDir(), 'config.json')
|
|
2151
|
+
if (path === legacy) return own
|
|
2152
|
+
const inherited = readConfigFile(legacy, logger)
|
|
2153
|
+
if (inherited.present !== true) return own
|
|
2154
|
+
return { ...inherited, path, inheritedFrom: legacy }
|
|
2155
|
+
}
|
|
2156
|
+
|
|
1928
2157
|
/**
|
|
1929
2158
|
* Persist the user layer atomically (write a sibling temp file, then rename).
|
|
1930
|
-
*
|
|
2159
|
+
* Always writes THIS profile's file, never the shared one.
|
|
2160
|
+
* @param {Record<string, unknown>} data - the user layer.
|
|
2161
|
+
* @param {object} [logger] - optional ctx.logger.
|
|
2162
|
+
* @returns {{ok: boolean, path: string, error?: string}} the outcome.
|
|
1931
2163
|
*/
|
|
1932
2164
|
function writePersistedConfig(data, logger) {
|
|
1933
2165
|
const dir = configDir()
|
|
1934
|
-
const path =
|
|
2166
|
+
const path = configFilePath()
|
|
1935
2167
|
const temp = `${path}.tmp`
|
|
1936
2168
|
try {
|
|
1937
|
-
|
|
1938
|
-
|
|
2169
|
+
// POSIX hardening: the file holds the SMTP password, so it must not be
|
|
2170
|
+
// world-readable; the mode bits are no-ops on Windows but keep the
|
|
2171
|
+
// behavior correct on Linux/macOS.
|
|
2172
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
2173
|
+
writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
1939
2174
|
renameSync(temp, path)
|
|
1940
|
-
return { ok: true }
|
|
2175
|
+
return { ok: true, path }
|
|
1941
2176
|
} catch (error) {
|
|
1942
2177
|
try {
|
|
1943
2178
|
rmSync(temp, { force: true })
|
|
@@ -1945,7 +2180,7 @@ function writePersistedConfig(data, logger) {
|
|
|
1945
2180
|
/* best effort */
|
|
1946
2181
|
}
|
|
1947
2182
|
logger?.warn?.('[sidecard-ask] 保存配置失败:', error?.message ?? error)
|
|
1948
|
-
return { ok: false, error: String(error?.message ?? error) }
|
|
2183
|
+
return { ok: false, path, error: String(error?.message ?? error) }
|
|
1949
2184
|
}
|
|
1950
2185
|
}
|
|
1951
2186
|
|
|
@@ -2063,6 +2298,59 @@ export function apply(ctx, patchConfig) {
|
|
|
2063
2298
|
return out
|
|
2064
2299
|
}
|
|
2065
2300
|
|
|
2301
|
+
/**
|
|
2302
|
+
* Whether a sanitized report carries any observation at all.
|
|
2303
|
+
*
|
|
2304
|
+
* `sanitizeClientReport` always produces the `at/slots/native/better` shell,
|
|
2305
|
+
* so an empty POST used to look like a valid report and erased the previous
|
|
2306
|
+
* one. Only fields the Client actually observed count.
|
|
2307
|
+
* @param {Record<string, any>} report - sanitized report.
|
|
2308
|
+
* @returns {boolean} true when at least one observation is present.
|
|
2309
|
+
*/
|
|
2310
|
+
function reportHasContent(report) {
|
|
2311
|
+
if (report === null || typeof report !== 'object') return false
|
|
2312
|
+
for (const key of ['version', 'surface', 'zoneAnchors', 'sessionKnown', 'family']) {
|
|
2313
|
+
if (report[key] !== undefined) return true
|
|
2314
|
+
}
|
|
2315
|
+
if (report.slots !== undefined && Object.keys(report.slots).length > 0) return true
|
|
2316
|
+
for (const key of ['native', 'better']) {
|
|
2317
|
+
const block = report[key]
|
|
2318
|
+
if (block !== undefined && Object.keys(block).length > 0) return true
|
|
2319
|
+
}
|
|
2320
|
+
return false
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/**
|
|
2324
|
+
* Fold one report into the accumulated view instead of replacing it.
|
|
2325
|
+
*
|
|
2326
|
+
* The channel is a single last-write-wins slot fed by every page of every
|
|
2327
|
+
* tab, so a replacement loses whatever the previous reporter knew (a page
|
|
2328
|
+
* that only re-registered one slot would blank the other three). Undefined
|
|
2329
|
+
* fields mean "not observed this time" and keep the stored value; only
|
|
2330
|
+
* fields the sender actually reported overwrite.
|
|
2331
|
+
* @param {Record<string, any>|null} previous - accumulated report.
|
|
2332
|
+
* @param {Record<string, any>} next - freshly sanitized report.
|
|
2333
|
+
* @returns {Record<string, any>} merged report.
|
|
2334
|
+
*/
|
|
2335
|
+
function mergeClientReport(previous, next) {
|
|
2336
|
+
if (previous === null || typeof previous !== 'object') return next
|
|
2337
|
+
const merged = { ...previous, at: next.at }
|
|
2338
|
+
for (const key of ['version', 'surface', 'zoneAnchors', 'sessionKnown', 'family']) {
|
|
2339
|
+
if (next[key] !== undefined) merged[key] = next[key]
|
|
2340
|
+
}
|
|
2341
|
+
for (const key of ['slots', 'native', 'better']) {
|
|
2342
|
+
const incoming = next[key]
|
|
2343
|
+
if (incoming === undefined) continue
|
|
2344
|
+
const stored = previous[key]
|
|
2345
|
+
if (stored === undefined || typeof stored !== 'object') {
|
|
2346
|
+
merged[key] = incoming
|
|
2347
|
+
continue
|
|
2348
|
+
}
|
|
2349
|
+
merged[key] = { ...stored, ...incoming }
|
|
2350
|
+
}
|
|
2351
|
+
return merged
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2066
2354
|
/** Whether the request may reach the plugin routes. */
|
|
2067
2355
|
const trustedHostsOf = () => {
|
|
2068
2356
|
const runtime = ctx.get('webRuntime')
|
|
@@ -2089,6 +2377,10 @@ export function apply(ctx, patchConfig) {
|
|
|
2089
2377
|
persistedAtBoot: persisted.present && Object.keys(sanitizeLayer(persisted.data)).length > 0,
|
|
2090
2378
|
persistedPath: persisted.path,
|
|
2091
2379
|
persistedError: persisted.error,
|
|
2380
|
+
// Set only on the first boot after a profile-scoped file was
|
|
2381
|
+
// introduced: the settings were inherited from the old shared file and
|
|
2382
|
+
// will be re-saved into the scoped one on the next save.
|
|
2383
|
+
persistedInheritedFrom: persisted.inheritedFrom,
|
|
2092
2384
|
patchKeys: Object.keys(sanitizeLayer(patchConfig)),
|
|
2093
2385
|
},
|
|
2094
2386
|
capabilities: {
|
|
@@ -2110,6 +2402,11 @@ export function apply(ctx, patchConfig) {
|
|
|
2110
2402
|
* A browser-side plugin cannot be inspected from the Host, so this is the
|
|
2111
2403
|
* only channel that turns "the card silently fell back" or "the native tab
|
|
2112
2404
|
* never registered" into something readable from outside the page.
|
|
2405
|
+
*
|
|
2406
|
+
* Reports are MERGED, not replaced, and a report with no observations is
|
|
2407
|
+
* refused: the channel is one last-write-wins slot shared by every page, so
|
|
2408
|
+
* a replacement (or an empty POST) used to erase what the real page knew and
|
|
2409
|
+
* made the one outside view of the Client lie.
|
|
2113
2410
|
*/
|
|
2114
2411
|
const handleDiagnose = async (req, res) => {
|
|
2115
2412
|
let body
|
|
@@ -2124,7 +2421,11 @@ export function apply(ctx, patchConfig) {
|
|
|
2124
2421
|
writeError(res, 400, 'bad-request', '诊断上报必须是对象')
|
|
2125
2422
|
return
|
|
2126
2423
|
}
|
|
2127
|
-
|
|
2424
|
+
if (!reportHasContent(report)) {
|
|
2425
|
+
writeError(res, 400, 'bad-request', '诊断上报没有任何可识别字段')
|
|
2426
|
+
return
|
|
2427
|
+
}
|
|
2428
|
+
clientReport = mergeClientReport(clientReport, report)
|
|
2128
2429
|
writeOk(res, { accepted: true, at: report.at })
|
|
2129
2430
|
}
|
|
2130
2431
|
|
|
@@ -2248,11 +2549,22 @@ export function apply(ctx, patchConfig) {
|
|
|
2248
2549
|
return
|
|
2249
2550
|
}
|
|
2250
2551
|
const problems = []
|
|
2251
|
-
// The SMTP password is write-only: `/state` never hands it back, so
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2552
|
+
// The SMTP password is write-only: `/state` never hands it back, so a
|
|
2553
|
+
// client that submits an empty string for an untouched password means
|
|
2554
|
+
// "leave it alone" instead of erasing it. That rule used to leave NO way to
|
|
2555
|
+
// remove a stored credential at all (empty = keep, anything else =
|
|
2556
|
+
// overwrite), so `feedbackSmtpPassClear: true` is the explicit erase: it
|
|
2557
|
+
// drops the stored password (typically to fall back to the environment) and
|
|
2558
|
+
// is never persisted as a config key itself.
|
|
2254
2559
|
const patch = { ...body }
|
|
2255
|
-
if (patch.
|
|
2560
|
+
if (patch.feedbackSmtpPassClear === true) {
|
|
2561
|
+
delete patch.feedbackSmtpPassClear
|
|
2562
|
+
delete patch.feedbackSmtpPass
|
|
2563
|
+
delete userLayer.feedbackSmtpPass
|
|
2564
|
+
} else {
|
|
2565
|
+
delete patch.feedbackSmtpPassClear
|
|
2566
|
+
if (patch.feedbackSmtpPass === '') delete patch.feedbackSmtpPass
|
|
2567
|
+
}
|
|
2256
2568
|
const accepted = sanitizeLayer(patch, problems)
|
|
2257
2569
|
if (problems.length > 0) {
|
|
2258
2570
|
writeError(res, 400, 'invalid-config', problems.join(';'))
|
|
@@ -2279,8 +2591,56 @@ export function apply(ctx, patchConfig) {
|
|
|
2279
2591
|
/**
|
|
2280
2592
|
* The HTTP methods the plugin serves; kept in one place because the route
|
|
2281
2593
|
* registration has two shapes (see below).
|
|
2594
|
+
*
|
|
2595
|
+
* `ping` is the one method that answers WITHOUT the browser session: it is
|
|
2596
|
+
* the only way a script, a CI job or a monitor can tell "the plugin is up"
|
|
2597
|
+
* apart from "the Host is up" once the session gate is in front of
|
|
2598
|
+
* everything else. It exposes nothing but the version and the Host clock.
|
|
2282
2599
|
*/
|
|
2283
|
-
const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
|
|
2600
|
+
const API_METHODS = ['ping', 'state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* The Host's own browser-session gate, borrowed for the plugin's routes.
|
|
2604
|
+
*
|
|
2605
|
+
* `webServer` routes are NOT covered by the Host's index-only
|
|
2606
|
+
* `authorizeIndex`, so without this check any local process that can reach
|
|
2607
|
+
* the loopback port may read `/state`, rewrite settings, and spawn a child
|
|
2608
|
+
* agent with the plugin's own privileges. `connection.requestRejection`
|
|
2609
|
+
* verifies the authority-bound `dsh-auth-*` HMAC cookie the Host mints for
|
|
2610
|
+
* the browser (a plain `dsh-auth-` cookie is NOT enough: the signature is
|
|
2611
|
+
* checked against a secret only the Host holds).
|
|
2612
|
+
*
|
|
2613
|
+
* Returns undefined when the composition has no `connection` service: such a
|
|
2614
|
+
* shell serves no browser UI, so requests can only come from the machine
|
|
2615
|
+
* itself and the loopback/Origin fence is the whole model.
|
|
2616
|
+
*/
|
|
2617
|
+
const connectionService = () => {
|
|
2618
|
+
try {
|
|
2619
|
+
const connection = ctx.get('connection')
|
|
2620
|
+
return connection !== undefined && typeof connection.requestRejection === 'function' ? connection : undefined
|
|
2621
|
+
} catch {
|
|
2622
|
+
return undefined
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
let authGateNoted = false
|
|
2626
|
+
const authRejection = req => {
|
|
2627
|
+
const connection = connectionService()
|
|
2628
|
+
if (connection === undefined) {
|
|
2629
|
+
if (!authGateNoted) {
|
|
2630
|
+
authGateNoted = true
|
|
2631
|
+
ctx.logger?.info?.('[sidecard-ask] 未挂载 connection 服务:API 仅保留 loopback/Origin 围栏')
|
|
2632
|
+
}
|
|
2633
|
+
return undefined
|
|
2634
|
+
}
|
|
2635
|
+
try {
|
|
2636
|
+
const rejection = connection.requestRejection(req)
|
|
2637
|
+
return rejection === 401 || rejection === 403 ? rejection : undefined
|
|
2638
|
+
} catch (error) {
|
|
2639
|
+
// Fail closed: an unreadable auth service must not widen the surface.
|
|
2640
|
+
ctx.logger?.warn?.('[sidecard-ask] 鉴权校验异常,按未认证处理:', error?.message ?? error)
|
|
2641
|
+
return 401
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2284
2644
|
|
|
2285
2645
|
/** One request → one response; the dispatcher owns path parsing and the fence. */
|
|
2286
2646
|
const routeHandler = async (req, res) => {
|
|
@@ -2288,27 +2648,56 @@ export function apply(ctx, patchConfig) {
|
|
|
2288
2648
|
writeError(res, 403, 'forbidden', 'forbidden')
|
|
2289
2649
|
return
|
|
2290
2650
|
}
|
|
2651
|
+
// Path parsing runs BEFORE the session gate now, because `ping` must be
|
|
2652
|
+
// answerable without a browser session. The order is deliberate: the fence
|
|
2653
|
+
// still rejects a foreign origin first, the method name is validated
|
|
2654
|
+
// before anything is dispatched, and `ping` is the ONLY verb that skips the
|
|
2655
|
+
// gate — everything else falls through to it immediately below.
|
|
2291
2656
|
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
2292
2657
|
const method = pathname.startsWith(`${ROUTE_PREFIX}/`)
|
|
2293
2658
|
? pathname.slice(ROUTE_PREFIX.length + 1)
|
|
2294
2659
|
: ''
|
|
2295
|
-
|
|
2660
|
+
// The method name decides "unknown" before the HTTP verb does, so a typo
|
|
2661
|
+
// answers the same 404 for every verb (a GET used to fall through to 405).
|
|
2662
|
+
if (method === '' || method.includes('/') || !API_METHODS.includes(method)) {
|
|
2296
2663
|
writeError(res, 404, 'not-found', `未知接口 "${method}"`)
|
|
2297
2664
|
return
|
|
2298
2665
|
}
|
|
2299
|
-
if (method === '
|
|
2300
|
-
if (req.method !== 'GET' && req.method !== '
|
|
2666
|
+
if (method === 'ping') {
|
|
2667
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2301
2668
|
writeError(res, 405, 'method-error', 'method not allowed')
|
|
2302
2669
|
return
|
|
2303
2670
|
}
|
|
2304
|
-
|
|
2671
|
+
writeOk(res, { version: PLUGIN_VERSION, at: Date.now() })
|
|
2305
2672
|
return
|
|
2306
2673
|
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2674
|
+
const unauthorized = authRejection(req)
|
|
2675
|
+
if (unauthorized !== undefined) {
|
|
2676
|
+
writeError(
|
|
2677
|
+
res,
|
|
2678
|
+
unauthorized,
|
|
2679
|
+
unauthorized === 401 ? 'unauthorized' : 'forbidden',
|
|
2680
|
+
unauthorized === 401 ? '需要先通过 DSH 登录(宿主会话未认证)' : '请求来源不受信任',
|
|
2681
|
+
)
|
|
2309
2682
|
return
|
|
2310
2683
|
}
|
|
2684
|
+
// Everything that can throw runs inside ONE try: `/state` assembles the
|
|
2685
|
+
// whole diagnostic surface (capabilities, SMTP resolution, provenance), and
|
|
2686
|
+
// it used to sit outside this block, so a single bad config value answered
|
|
2687
|
+
// the browser with an unhandled rejection instead of a readable 500.
|
|
2311
2688
|
try {
|
|
2689
|
+
if (method === 'state') {
|
|
2690
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
2691
|
+
writeError(res, 405, 'method-error', 'method not allowed')
|
|
2692
|
+
return
|
|
2693
|
+
}
|
|
2694
|
+
handleState(res)
|
|
2695
|
+
return
|
|
2696
|
+
}
|
|
2697
|
+
if (req.method !== 'POST') {
|
|
2698
|
+
writeError(res, 405, 'method-error', 'method not allowed')
|
|
2699
|
+
return
|
|
2700
|
+
}
|
|
2312
2701
|
if (method === 'ask') await handleAsk(req, res)
|
|
2313
2702
|
else if (method === 'cancel') await handleCancel(req, res)
|
|
2314
2703
|
else if (method === 'config') await handleConfig(req, res)
|