dsh-custom-mode 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/client.js +3 -1
  2. package/index.mjs +93 -159
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -45,7 +45,9 @@ try {
45
45
 
46
46
  const react = require("react")
47
47
 
48
- const ROUTE = "/custom-mode"
48
+ // 路由注册在平台的共享 `/api` 频道上(由载体在分发前施加信任与鉴权),
49
+ // 所以这里的路径必须带 `/api` 前缀。
50
+ const ROUTE = "/api/custom-mode"
49
51
 
50
52
  const NS = "settings.customMode"
51
53
 
package/index.mjs CHANGED
@@ -1,38 +1,36 @@
1
1
  /**
2
- * Host half: the 「自定义模式」 settings page — now an ASSISTANT MANAGER.
2
+ * Host half: the 「自定义模式」 settings page — an ASSISTANT MANAGER.
3
3
  *
4
- * It serves one private HTTP route and its sub-paths; the browser half calls them
4
+ * It registers five exact routes on the platform's shared `/api` channel; the browser half calls them
5
5
  * with `fetch`:
6
6
  *
7
- * GET /custom-mode — every assistant this feature manages.
8
- * GET /custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
9
- * POST /custom-mode/state — { id, mode, overrides, prompt, name, description }:
10
- * validate, render a fresh `agent.cordis.yml`, write both files.
11
- * POST /custom-mode/create — { name, description }: seed a new assistant from the
12
- * packaged template and give it the standard base mode.
13
- * POST /custom-mode/delete — { id }: remove a locally authored assistant.
14
- * POST /custom-mode/reorder — { id, direction }: move one assistant up/down in the
15
- * picker order by writing `order` into each `preset.yml`.
7
+ * GET /api/custom-mode — every assistant this feature manages.
8
+ * GET /api/custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
9
+ * POST /api/custom-mode/state — { id, mode, overrides, prompt, name, description }:
10
+ * validate, render a fresh `agent.cordis.yml`, write both files.
11
+ * POST /api/custom-mode/create — { name, description }: seed a new assistant from the
12
+ * packaged template and give it the standard base mode.
13
+ * POST /api/custom-mode/delete — { id }: remove a locally authored assistant.
14
+ * POST /api/custom-mode/reorder — { id, direction }: move one assistant up/down in the
15
+ * picker order by writing `order` into each `preset.yml`.
16
16
  *
17
- * Why one PREFIX route instead of five exact ones: the registered route table keys
18
- * on (kind, path), so a prefix claims `/custom-mode` and everything under it while
19
- * staying one registration to dispose. `dsh-host-webserver` matches a prefix route
20
- * on the exact path too, so the list endpoint lives at the route path itself.
17
+ * **Why `/api` and not the raw `webServer` table**: the carrier that owns the `/api` channel applies the
18
+ * platform's trust and authentication policy loopback/`trustedHosts` Host check, `Sec-Fetch-Site`,
19
+ * `Origin`, and the signed browser-session cookie — *before* dispatching to a route. Registering there
20
+ * makes the fence part of the structure: a route cannot exist without it. Registering on the raw table
21
+ * instead puts the route outside that policy and leaves "check the request first" as a rule a human has
22
+ * to remember — and an earlier version of this plugin, doing exactly that, let an unauthenticated GET
23
+ * read the whole system prompt and an unauthenticated cross-site POST rewrite `prompt.md` (every official
24
+ * route answered 401; a plain form post needs no preflight, so CORS would not have helped either). The
25
+ * registered paths are absolute *including* `/api`, which is what the platform's own packages pass.
21
26
  *
22
- * Why a private route instead of a Remote namespace or `dsh-settings`: this
23
- * plugin then owns no Cordis service name and cannot collide with anything, and
24
- * it stays independent of the settings API whose helper names differ between dsh
25
- * releases (see docs/ARCHITECTURE.md).
27
+ * **Why exact routes rather than one prefix**: the Fetch registry matches exact paths. Five registrations
28
+ * cost nothing and each declares the methods it owns, so the method table doubles as the guarantee that a
29
+ * prefetched `GET …/create` cannot create anything that method is simply not registered for that path.
26
30
  *
27
- * A route registered on the raw `webServer` table is however OUTSIDE the
28
- * platform's browser-trust fence, which only guards the channels the Connection
29
- * service mounts (`/`, `/api`, …). Measured on 0.1.6-alpha.1: an unauthenticated
30
- * POST with `content-type: text/plain` rewrote `prompt.md`, while every official
31
- * route answered 401 — and a cross-site form post needs no preflight, so any page
32
- * the user visited could have rewritten their agent's system prompt. The route
33
- * therefore runs the platform's own check first, via
34
- * `ctx.connection.requestRejection(req)` (Host/Origin fence + browser auth),
35
- * which is the same verdict `/api` gets.
31
+ * Why a private route instead of a Remote namespace or `dsh-settings`: this plugin then owns no Cordis
32
+ * service name and cannot collide with anything, and it stays independent of the settings API whose helper
33
+ * names differ between dsh releases (see docs/ARCHITECTURE.md §5).
36
34
  *
37
35
  * Storage is deliberately file-only and stateless: the composition file IS the
38
36
  * saved state, so no second document can drift from it. The page derives which
@@ -64,7 +62,7 @@ import {
64
62
  userPresetRoot,
65
63
  } from './assistants.mjs'
66
64
 
67
- export { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR, PRESET_META_PATH }
65
+ export { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, API_PREFIX, PRESET_DIR, PRESET_META_PATH }
68
66
 
69
67
  /** The list endpoint is the route path itself; the rest hang off it. */
70
68
  const STATE_PATH = ROUTE_PATH + '/state'
@@ -424,92 +422,35 @@ export async function deleteAssistant(rows, input, agentPresets) {
424
422
  return { ok: true, id, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
425
423
  }
426
424
 
427
- /** Send JSON with no-store caching, so a save is never read back stale. */
428
- function sendJson(res, status, value) {
429
- const body = JSON.stringify(value)
430
- res.writeHead(status, {
431
- 'content-type': 'application/json; charset=utf-8',
432
- 'cache-control': 'no-store',
433
- 'content-length': String(Buffer.byteLength(body, 'utf8')),
434
- })
435
- res.end(body)
436
- }
437
-
438
- /** Read a request body with a hard cap. */
439
- async function readBody(req) {
440
- const chunks = []
441
- let size = 0
442
- for await (const chunk of req) {
443
- size += chunk.length
444
- if (size > 4_000_000) throw new Error('请求体过大(上限 4MB)')
445
- chunks.push(chunk)
446
- }
447
- return Buffer.concat(chunks).toString('utf8')
448
- }
449
-
450
- /**
451
- * The request's path and query.
452
- *
453
- * A missing or unparsable `url` resolves to the route path itself, which is the
454
- * list endpoint — the lenient branch exists so a malformed request reads as an
455
- * ordinary list call instead of throwing inside the fence.
456
- */
457
- function requestUrl(req) {
458
- const raw =
459
- req !== null && typeof req === 'object' && typeof req.url === 'string' && req.url !== '' ? req.url : ROUTE_PATH
460
- try {
461
- return new URL(raw, 'http://localhost')
462
- } catch {
463
- return new URL(ROUTE_PATH, 'http://localhost')
464
- }
465
- }
466
-
467
425
  /**
468
- * Rejection status for one request, or undefined when it may proceed.
426
+ * The plugin's HTTP surface, registered on the platform's **shared `/api` channel**.
469
427
  *
470
- * The platform's fence is verified through the Connection service, which is where
471
- * the Host/Origin check and the browser-session check live:
428
+ * `ctx.connection.fetch.register({ path, methods, requestBody, fetch })` mounts one exact route under
429
+ * `/api`, and the carrier applies its trust and authentication policy **before** dispatch. That is a
430
+ * structural guarantee, not a convention: a route cannot exist without the fence, so "did you remember
431
+ * to check?" is not a question anyone has to answer. An earlier version of this plugin registered on the
432
+ * raw `ctx.webServer` table and called `ctx.connection.requestRejection` by hand — measured then, an
433
+ * unauthenticated GET returned the whole system prompt and an unauthenticated cross-site POST rewrote
434
+ * `prompt.md`, while every official route answered 401.
472
435
  *
473
- * - Host must be loopback (or a declared trusted authority) defeats DNS
474
- * rebinding, where the socket reaches this server but the Host names the
475
- * attacker's domain;
476
- * - `Sec-Fetch-Site: cross-site` and a mismatching `Origin` are refused → defeats
477
- * a malicious page posting to this local port (a simple form post needs no
478
- * preflight, so CORS alone would not have stopped it);
479
- * - the signed `dsh-auth-*` cookie must be present → without the browser session
480
- * that the launch URL establishes, the route is closed.
436
+ * Measured on 0.1.6-alpha.2 with a throwaway probe plugin (see `docs/MEASUREMENTS.md` §16):
481
437
  *
482
- * The service is resolved lazily, per request, and NOT through `inject`: measured
483
- * on 0.1.6-alpha.1, `connection` is provided after this bundle row's `apply` runs,
484
- * so an `inject` here would park the plugin in `pending` for no reason — while by
485
- * request time the service is always there.
438
+ * GET /api/<route> no session cookie 401 unauthorized
439
+ * GET /api/<route> Origin: https://evil.example cross-site → 403
440
+ * GET /api/<route> Host: evil.example DNS-rebinding shape 403
441
+ * GET /api/<route> session cookie → 200
442
+ * POST /api/<route> on a route that owns GET only → 404 (never dispatched)
486
443
  *
487
- * When the service is absent the route FAILS CLOSED. A dead settings page is a
488
- * visible, honest failure; an unauthenticated write path that rewrites the agent's
489
- * system prompt is a silent one.
444
+ * The paths are absolute *including* `/api` that is what the platform's own packages pass
445
+ * (`/api/present.host`, `/api/changes.summary`), despite the type saying "below /api".
490
446
  */
491
- let warnedMissingConnection = false
492
- function connectionRejection(ctx, req) {
493
- const connection = ctx.get('connection')
494
- if (connection !== undefined && typeof connection.requestRejection === 'function') {
495
- return connection.requestRejection(req)
496
- }
497
- if (!warnedMissingConnection) {
498
- warnedMissingConnection = true
499
- console.error(
500
- 'custom-mode: connection 服务不可用(DSH 版本不匹配?),已拒绝该设置页的所有请求以保守处理。' +
501
- 'prompt.md 与 custom_prompt 工具不受影响。',
502
- )
503
- }
504
- return 503
505
- }
447
+ const API_PREFIX = '/api'
506
448
 
507
449
  /**
508
450
  * Where the settings page can exist at all.
509
451
  *
510
- * The page is a WEB page: without `webServer` there is nothing to serve the route on,
511
- * and without `agentPresets` the assistant list cannot be built. The tui profile has
512
- * neither.
452
+ * The page is a WEB page: without `connection` there is no `/api` channel to register on, and without
453
+ * `agentPresets` the assistant list cannot be built. The tui profile has neither.
513
454
  *
514
455
  * These must NOT go into the row's own `inject`. Measured on 0.1.6-alpha.1:
515
456
  * `./install.sh --profile tui` — a usage both `install.sh --help` and the READMEs
@@ -520,11 +461,11 @@ function connectionRejection(ctx, req) {
520
461
  * custom-mode (dsh-custom-mode): pending (waiting for services: webServer, agentPresets)
521
462
  *
522
463
  * That is the SAME line a broken installation prints, so it teaches users to ignore the
523
- * one warning that matters. Instead the row always activates, and the route is
464
+ * one warning that matters. Instead the row always activates, and the routes are
524
465
  * registered from a scoped fiber that waits for those two services (`ctx.inject`),
525
466
  * which is the dynamic form of the same declaration.
526
467
  */
527
- const WEB_SERVICES = ['webServer', 'agentPresets']
468
+ const WEB_SERVICES = ['connection', 'agentPresets']
528
469
 
529
470
  export function apply(ctx) {
530
471
  // Before anything else: make sure the preset tree on disk is complete.
@@ -552,7 +493,7 @@ export function apply(ctx) {
552
493
  const missing = []
553
494
  if (typeof scope.agentPresets?.list !== 'function') missing.push('agentPresets.list()')
554
495
  if (typeof scope.agentPresets?.remove !== 'function') missing.push('agentPresets.remove()')
555
- if (typeof scope.webServer?.register !== 'function') missing.push('webServer.register()')
496
+ if (typeof scope.connection?.fetch?.register !== 'function') missing.push('connection.fetch.register()')
556
497
  if (missing.length > 0) {
557
498
  console.error(
558
499
  'custom-mode: 当前 DSH 版本缺少所需 API:' +
@@ -589,83 +530,76 @@ export function apply(ctx) {
589
530
  return Array.isArray(rows) ? rows : []
590
531
  }
591
532
 
592
- const handler = async (req, res) => {
593
- try {
594
- // The fence comes first, before any method dispatch: the GET leaks the whole
595
- // system prompt and the POST rewrites it, so neither may run unauthenticated.
596
- const rejection = connectionRejection(scope, req)
597
- if (rejection !== undefined) {
598
- // 401/403 与平台对 /api 的措辞一致;503 是"我们自己保守关闭"(connection 服务
599
- // 取不到),它既不是未授权也不是被禁止,别把响应体写成 forbidden 误导排查的人。
600
- const reason = rejection === 401 ? 'unauthorized' : rejection === 403 ? 'forbidden' : 'unavailable'
601
- res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
602
- res.end(reason)
603
- return
604
- }
533
+ /**
534
+ * One Fetch-shaped handler for the whole surface.
535
+ *
536
+ * `pathname` is the logical path (without the `/api` prefix) so the method table above stays the
537
+ * single place where the surface is described.
538
+ */
539
+ /**
540
+ * JSON response with the page's caching policy attached.
541
+ *
542
+ * `no-store` because every one of these answers is state the page then displays: a cached
543
+ * `GET /api/custom-mode/state` would show the user rows they already changed. (The old
544
+ * hand-rolled response helper set the same header; dropping it here would have been a silent
545
+ * regression.)
546
+ */
547
+ const json = (value, status = 200) =>
548
+ Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
605
549
 
606
- const url = requestUrl(req)
607
- const pathname = url.pathname
608
- const methods = METHODS[pathname]
609
- if (methods === undefined) {
610
- sendJson(res, 404, { ok: false, error: '未知的子路径:' + pathname })
611
- return
612
- }
613
- // Per-path method table, not a global GET/POST gate: `create` and `delete`
614
- // are POST-only, and letting a GET fall through to them would create an
615
- // assistant off a link a browser prefetched.
616
- if (!methods.includes(req.method)) {
617
- sendJson(res, 405, { ok: false, error: '只支持 ' + methods.join(' 与 ') })
618
- return
619
- }
620
- if (req.method === 'GET' && pathname === ROUTE_PATH) {
550
+ const handle = async (request, pathname) => {
551
+ try {
552
+ const url = new URL(request.url)
553
+ if (request.method === 'GET' && pathname === ROUTE_PATH) {
621
554
  await ensureShipped()
622
- sendJson(res, 200, readList(await roster()))
623
- return
555
+ return json(readList(await roster()))
624
556
  }
625
- if (req.method === 'GET' && pathname === STATE_PATH) {
557
+ if (request.method === 'GET' && pathname === STATE_PATH) {
626
558
  await ensureShipped()
627
- sendJson(res, 200, readState(await roster(), url.searchParams.get('id') ?? ''))
628
- return
559
+ return json(readState(await roster(), url.searchParams.get('id') ?? ''))
629
560
  }
630
561
 
631
562
  // Everything below writes, so the body is read and parsed exactly once.
632
- const raw = await readBody(req)
633
563
  let parsed
634
564
  try {
635
- parsed = JSON.parse(raw)
565
+ parsed = await request.json()
636
566
  } catch {
637
- sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
638
- return
567
+ return json({ ok: false, error: '请求体不是合法 JSON' }, 400)
639
568
  }
640
569
  await ensureShipped()
641
570
  if (pathname === STATE_PATH) {
642
571
  const result = saveState(await roster(), parsed)
643
- sendJson(res, result.ok === true ? 200 : 400, result)
644
- return
572
+ return json(result, result.ok === true ? 200 : 400)
645
573
  }
646
574
  if (pathname === CREATE_PATH) {
647
575
  const result = createAssistant(await roster(), parsed)
648
- sendJson(res, result.ok === true ? 200 : 400, result)
649
- return
576
+ return json(result, result.ok === true ? 200 : 400)
650
577
  }
651
578
  if (pathname === REORDER_PATH) {
652
579
  const result = reorderAssistant(await roster(), parsed)
653
- sendJson(res, result.ok === true ? 200 : 400, result)
654
- return
580
+ return json(result, result.ok === true ? 200 : 400)
655
581
  }
656
582
  const result = await deleteAssistant(await roster(), parsed, scope.agentPresets)
657
- sendJson(res, result.ok === true ? 200 : 400, result)
583
+ return json(result, result.ok === true ? 200 : 400)
658
584
  } catch (error) {
659
- sendJson(res, 500, { ok: false, error: describe(error) })
585
+ return json({ ok: false, error: describe(error) }, 500)
660
586
  }
661
587
  }
662
588
 
663
- // A PREFIX route claims `/custom-mode` and every sub-path under it, which keeps
664
- // the endpoints to one registration and one disposer.
665
- scope.effect(
666
- () => scope.webServer.register({ kind: 'prefix', path: ROUTE_PATH, handler }),
667
- 'custom-mode.route',
668
- )
589
+ // One registration per exact path the platform's Fetch registry matches exact paths, not prefixes —
590
+ // each declaring the methods it owns. The method table doubles as the guarantee that a prefetched
591
+ // `GET /api/custom-mode/create` cannot create anything: that method is not registered for that path.
592
+ for (const [pathname, methods] of Object.entries(METHODS)) {
593
+ scope.effect(
594
+ () => scope.connection.fetch.register({
595
+ path: API_PREFIX + pathname,
596
+ methods: [...methods],
597
+ requestBody: 'buffered',
598
+ fetch: (request) => handle(request, pathname),
599
+ }),
600
+ 'custom-mode.route' + pathname,
601
+ )
602
+ }
669
603
  })
670
604
  }
671
605
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
5
5
  "license": "MIT",
6
6
  "type": "module",