refactor(shared): consolidate the pairing trust fence into shared/host/pair-access.ts

Git-graph, pet, and skill-explorer carried byte-identical fence logic.
The decision now lives in one shared source distributed as generated
sync-shared copies; each package keeps a self-describing thin wrapper.
Canonical tests added under shared/tests; wrapper specs stay as wiring
tests.
This commit is contained in:
zhu1090093659
2026-08-27 01:20:12 +08:00
parent e1504c1df7
commit e7c74daefa
13 changed files with 323 additions and 77 deletions
@@ -0,0 +1,5 @@
# Bilingual-pair consistency record (docs/i18n.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with git hash-object.
2026-08-26-shared-pair-access-fence.md: 14892fe3fbd3c64088a02c4cdd2ba71f678a3ac6
2026-08-26-shared-pair-access-fence.zh.md: 5f24edefefabb96a531eae5be4bcea73f8e9b1be
@@ -0,0 +1,23 @@
# Agent Note: Shared pairing trust fence (pair-access) via sync-shared
Status: implemented
## Problem
Three plugins — git-graph (`src/host/access.ts`), pet (`src/access.ts`), and skill-explorer (`src/access.ts`) — carried byte-identical trust-fence decision logic (loopback short-circuit, `remoteWebUiPairing` structural lookup with ctx.get/property fallback) differing only in the exported function name and header comment. A duplicated security check must drift in lockstep across copies or silently diverge; the audit flagged all three locations plus their triple-cloned tests.
## Decision
The decision logic now lives once in `shared/host/pair-access.ts` (`isPairedOrLoopbackAllowed`), distributed to the three packages as generated copies through the existing `scripts/sync-shared.mjs` table — the same mechanism that already syncs `loopback.ts`/`http.ts`/`dsh-home.ts`. Each package keeps a hand-written `access.ts` wrapper exporting its self-describing name (`isGitAllowed`, `isPetAllowed`, `isSkillExplorerAllowed`) that delegates to the synced copy, so no call site changes. A canonical test for the core logic lives in `shared/tests/pair-access.spec.ts`; the per-package wrapper specs stay as wiring tests.
## Alternatives considered
A new shared runtime package imported as a dependency was rejected: every existing shared helper in this monorepo ships via sync-shared copies (packages must stay independently publishable without a workspace-internal dependency chain), and pair-access must not be the first exception. Replacing the wrappers entirely (renaming call sites to a single function) was rejected as needless churn — the wrapper keeps each package's public vocabulary and documents which routes the fence guards. Syncing the tests was rejected: sync-shared copies source files only, and the per-package specs double as wiring verification. The deprecated dsh-aionui-panel has no fence code (client-only), so nothing was consolidated there.
## Consequences
Fence fixes now propagate by editing one shared source and running `node scripts/sync-shared.mjs`; the drift gate (`test:scripts`) fails CI if any copy diverges. The sync-shared test's copy-count buckets grew (97 to 100 entries, 45 to 48 host copies) and its fake tree gained a pair-access source. No behavior change: identical logic, verified by the unchanged per-package specs.
## Testing
`pnpm test:scripts` (copy-count and drift suites), `pnpm docs:check`, and per-package `pnpm typecheck` + `pnpm test` for dsh-git-graph (141), dsh-pet (445), dsh-skill-explorer (72) — all pass. Shared spec: 15 tests in shared/tests (pair-access + loopback).
@@ -0,0 +1,23 @@
# Agent Note: 经 sync-shared 共享配对信任闸门(pair-access)
Status: implemented
## Problem
三个插件——git-graph(`src/host/access.ts`)、pet(`src/access.ts`)与 skill-explorer(`src/access.ts`)——各自携带逐字节相同的信任闸门判定逻辑(loopback 短路、`remoteWebUiPairing` 结构化查找及 ctx.get/属性回退),差别仅在导出函数名与头注释。安全检查的重复副本必须同步漂移,否则会静默分叉;审计点名了全部三处位置及其三份克隆测试。
## Decision
判定逻辑现在只存在一份:`shared/host/pair-access.ts`(`isPairedOrLoopbackAllowed`),通过既有 `scripts/sync-shared.mjs` 副本表以生成副本分发到三个包——与 `loopback.ts`/`http.ts`/`dsh-home.ts` 的同步机制相同。每个包保留手写的 `access.ts` 薄包装,导出自描述的名字(`isGitAllowed`、`isPetAllowed`、`isSkillExplorerAllowed`)并委托给同步副本,调用方零改动。核心逻辑的权威测试落在 `shared/tests/pair-access.spec.ts`;各包的包装规格保留为接线测试。
## Alternatives considered
新建共享运行时包以依赖方式引入的方案被否决:本仓所有既有共享助手都经 sync-shared 副本分发(各包必须可独立发布,不能引入 workspace 内部依赖链),pair-access 不应成为第一个例外。彻底去掉包装(调用方统一改用单一函数名)被否决,属于无谓 churn——包装保留了各包的公开词汇并注明闸门守护的路由。同步测试文件的方案被否决:sync-shared 只复制源文件,且各包规格兼作接线验证。已弃用的 dsh-aionui-panel 没有闸门代码(纯 client),无可合并内容。
## Consequences
闸门修复现在只需改一份共享源码并运行 `node scripts/sync-shared.mjs`;任何副本分叉都会被漂移门禁(`test:scripts`)在 CI 拦下。sync-shared 测试的副本计数桶随之更新(总条目 97→100,host 副本 45→48),其临时目录假树新增 pair-access 源文件。无行为变化:逻辑相同,由各包未改动的规格验证。
## Testing
`pnpm test:scripts`(副本计数与漂移套件)、`pnpm docs:check`,以及 dsh-git-graph(141)、dsh-pet(445)、dsh-skill-explorer(72)三包各自的 `pnpm typecheck` + `pnpm test`——全部通过。共享规格:shared/tests 共 15 项(pair-access + loopback)。
+4 -24
View File
@@ -2,22 +2,12 @@
* Git-graph trust fence: loopback (the desktop) always passes; a live
* paired-device cookie is an additional allow path when remote-web-ui is
* loaded. The plugin never depends on that plugin — without the service the
* fence stays loopback-only (same pattern as skill-explorer / aionui-panel).
* fence stays loopback-only. The decision logic lives in the generated
* pair-access.ts copy (shared by git-graph / pet / skill-explorer).
*/
import type { IncomingMessage } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import { isLoopbackRequest } from './loopback.ts'
/** Structural pairing lookup (no package dependency on remote-web-ui). */
interface PairingAccess {
isPairedDevice(request: IncomingMessage): boolean
}
/** ctx.get is optional on the test harness; production Context always has it. */
type LookupCtx = Context & {
get?(name: string, strict?: boolean): unknown
remoteWebUiPairing?: PairingAccess
}
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
/**
* Whether this request may enter any /git route (JSON operations or SSE).
@@ -26,15 +16,5 @@ type LookupCtx = Context & {
* @returns true for loopback, or a live paired-device cookie.
*/
export function isGitAllowed(ctx: Context, request: IncomingMessage): boolean {
if (isLoopbackRequest(request)) return true
const bag = ctx as LookupCtx
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
return pairing?.isPairedDevice(request) === true
}
function isPairingAccess(value: unknown): value is PairingAccess {
return value !== undefined
&& value !== null
&& typeof (value as PairingAccess).isPairedDevice === 'function'
return isPairedOrLoopbackAllowed(ctx, request)
}
@@ -0,0 +1,46 @@
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
/**
* Pairing trust fence shared by plugins that expose host routes: loopback
* (the desktop) always passes; a live paired-device cookie is an additional
* allow path when remote-web-ui is loaded. The consuming plugin never
* depends on that plugin — without the service the fence stays
* loopback-only.
*
* Per-package wrappers (access.ts) call this with their own name so each
* plugin keeps a self-describing export; the security decision lives only
* here.
*/
import type { IncomingMessage } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import { isLoopbackRequest } from './loopback.ts'
/** Structural pairing lookup (no package dependency on remote-web-ui). */
interface PairingAccess {
isPairedDevice(request: IncomingMessage): boolean
}
/** ctx.get is optional on the test harness; production Context always has it. */
type LookupCtx = Context & {
get?(name: string, strict?: boolean): unknown
remoteWebUiPairing?: PairingAccess
}
/**
* Whether this request may enter the plugin's host routes.
* @param ctx - host context; may expose remoteWebUiPairing.
* @param request - the incoming HTTP request.
* @returns true for loopback, or a live paired-device cookie.
*/
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
if (isLoopbackRequest(request)) return true
const bag = ctx as LookupCtx
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
return pairing?.isPairedDevice(request) === true
}
function isPairingAccess(value: unknown): value is PairingAccess {
return value !== undefined
&& value !== null
&& typeof (value as PairingAccess).isPairedDevice === 'function'
}
+7 -27
View File
@@ -1,23 +1,13 @@
/**
* Pet trust fence: loopback (the desktop) always passes; a live paired-device
* cookie is an additional allow path when remote-web-ui is loaded. The pet
* never depends on that plugin — without the service the fence stays
* loopback-only (same pattern as skill-explorer / aionui-panel).
* Pet trust fence: loopback (the desktop) always passes; a live
* paired-device cookie is an additional allow path when remote-web-ui is
* loaded. The pet never depends on that plugin — without the service the
* fence stays loopback-only. The decision logic lives in the generated
* pair-access.ts copy (shared by git-graph / pet / skill-explorer).
*/
import type { IncomingMessage } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import { isLoopbackRequest } from './loopback.ts'
/** Structural pairing lookup (no package dependency on remote-web-ui). */
interface PairingAccess {
isPairedDevice(request: IncomingMessage): boolean
}
/** ctx.get is optional on the test harness; production Context always has it. */
type LookupCtx = Context & {
get?(name: string, strict?: boolean): unknown
remoteWebUiPairing?: PairingAccess
}
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
/**
* Whether this request may enter any /api/pet or /pet asset route.
@@ -26,15 +16,5 @@ type LookupCtx = Context & {
* @returns true for loopback, or a live paired-device cookie.
*/
export function isPetAllowed(ctx: Context, request: IncomingMessage): boolean {
if (isLoopbackRequest(request)) return true
const bag = ctx as LookupCtx
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
return pairing?.isPairedDevice(request) === true
}
function isPairingAccess(value: unknown): value is PairingAccess {
return value !== undefined
&& value !== null
&& typeof (value as PairingAccess).isPairedDevice === 'function'
return isPairedOrLoopbackAllowed(ctx, request)
}
+46
View File
@@ -0,0 +1,46 @@
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
/**
* Pairing trust fence shared by plugins that expose host routes: loopback
* (the desktop) always passes; a live paired-device cookie is an additional
* allow path when remote-web-ui is loaded. The consuming plugin never
* depends on that plugin — without the service the fence stays
* loopback-only.
*
* Per-package wrappers (access.ts) call this with their own name so each
* plugin keeps a self-describing export; the security decision lives only
* here.
*/
import type { IncomingMessage } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import { isLoopbackRequest } from './loopback.ts'
/** Structural pairing lookup (no package dependency on remote-web-ui). */
interface PairingAccess {
isPairedDevice(request: IncomingMessage): boolean
}
/** ctx.get is optional on the test harness; production Context always has it. */
type LookupCtx = Context & {
get?(name: string, strict?: boolean): unknown
remoteWebUiPairing?: PairingAccess
}
/**
* Whether this request may enter the plugin's host routes.
* @param ctx - host context; may expose remoteWebUiPairing.
* @param request - the incoming HTTP request.
* @returns true for loopback, or a live paired-device cookie.
*/
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
if (isLoopbackRequest(request)) return true
const bag = ctx as LookupCtx
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
return pairing?.isPairedDevice(request) === true
}
function isPairingAccess(value: unknown): value is PairingAccess {
return value !== undefined
&& value !== null
&& typeof (value as PairingAccess).isPairedDevice === 'function'
}
+4 -24
View File
@@ -2,22 +2,12 @@
* Skill-center trust fence: loopback (the desktop) always passes; a live
* paired-device cookie is an additional allow path when remote-web-ui is
* loaded. The plugin never depends on that plugin — without the service the
* fence stays loopback-only (same pattern as aionui-panel, issue #146).
* fence stays loopback-only. The decision logic lives in the generated
* pair-access.ts copy (shared by git-graph / pet / skill-explorer).
*/
import type { IncomingMessage } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import { isLoopbackRequest } from './loopback.ts'
/** Structural pairing lookup (no package dependency on remote-web-ui). */
interface PairingAccess {
isPairedDevice(request: IncomingMessage): boolean
}
/** ctx.get is optional on the test harness; production Context always has it. */
type LookupCtx = Context & {
get?(name: string, strict?: boolean): unknown
remoteWebUiPairing?: PairingAccess
}
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
/**
* Whether this request may enter any /api/dsh-skill-explorer route.
@@ -26,15 +16,5 @@ type LookupCtx = Context & {
* @returns true for loopback, or a live paired-device cookie.
*/
export function isSkillExplorerAllowed(ctx: Context, request: IncomingMessage): boolean {
if (isLoopbackRequest(request)) return true
const bag = ctx as LookupCtx
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
return pairing?.isPairedDevice(request) === true
}
function isPairingAccess(value: unknown): value is PairingAccess {
return value !== undefined
&& value !== null
&& typeof (value as PairingAccess).isPairedDevice === 'function'
return isPairedOrLoopbackAllowed(ctx, request)
}
@@ -0,0 +1,46 @@
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
/**
* Pairing trust fence shared by plugins that expose host routes: loopback
* (the desktop) always passes; a live paired-device cookie is an additional
* allow path when remote-web-ui is loaded. The consuming plugin never
* depends on that plugin — without the service the fence stays
* loopback-only.
*
* Per-package wrappers (access.ts) call this with their own name so each
* plugin keeps a self-describing export; the security decision lives only
* here.
*/
import type { IncomingMessage } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import { isLoopbackRequest } from './loopback.ts'
/** Structural pairing lookup (no package dependency on remote-web-ui). */
interface PairingAccess {
isPairedDevice(request: IncomingMessage): boolean
}
/** ctx.get is optional on the test harness; production Context always has it. */
type LookupCtx = Context & {
get?(name: string, strict?: boolean): unknown
remoteWebUiPairing?: PairingAccess
}
/**
* Whether this request may enter the plugin's host routes.
* @param ctx - host context; may expose remoteWebUiPairing.
* @param request - the incoming HTTP request.
* @returns true for loopback, or a live paired-device cookie.
*/
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
if (isLoopbackRequest(request)) return true
const bag = ctx as LookupCtx
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
return pairing?.isPairedDevice(request) === true
}
function isPairingAccess(value: unknown): value is PairingAccess {
return value !== undefined
&& value !== null
&& typeof (value as PairingAccess).isPairedDevice === 'function'
}
+5
View File
@@ -130,6 +130,11 @@ const MANIFEST = [
source: 'shared/client/sse-leader.ts',
targets: ['packages/dsh-git-graph/src/client/sse-leader.ts'],
},
{
file: 'pair-access.ts',
source: 'shared/host/pair-access.ts',
targets: ['packages/dsh-git-graph/src/host/pair-access.ts', 'packages/dsh-pet/src/pair-access.ts', 'packages/dsh-skill-explorer/src/pair-access.ts'],
},
{
file: 'loopback.ts',
source: 'shared/host/loopback.ts',
+4 -2
View File
@@ -21,16 +21,17 @@ test('copies cover the settings trio for eight consumers plus host and http help
// Normalize separators: node:path join yields backslashes on Windows, and
// the copy-count buckets below match on forward slashes.
const entries = copyEntries().map(entry => ({ ...entry, target: entry.target.replaceAll('\\', '/') }))
assert.equal(entries.length, 97)
assert.equal(entries.length, 100)
const clientTrio = entries.filter(entry => entry.target.includes('/src/client/'))
assert.equal(clientTrio.length, 43)
const hostCopies = entries.filter(entry => entry.target.includes('/src/host/')
|| entry.target.includes('/src/dsh-home.ts')
|| entry.target.includes('/src/mount-once.ts')
|| entry.target.includes('/src/loopback.ts')
|| entry.target.includes('/src/pair-access.ts')
|| entry.target.includes('/src/agent/')
|| entry.target.endsWith('/packages/dsh-task-board/src/http.ts'))
assert.equal(hostCopies.length, 45)
assert.equal(hostCopies.length, 48)
})
test('checkSync detects drift and applySync repairs it', async () => {
@@ -50,6 +51,7 @@ test('checkSync detects drift and applySync repairs it', async () => {
await writeFile(join(hostDir, 'poll-guard.ts'), 'export const guard = 1' + String.fromCharCode(10))
await writeFile(join(hostDir, 'dsh-home.ts'), 'export const home = 1' + String.fromCharCode(10))
await writeFile(join(hostDir, 'loopback.ts'), 'export const loop = 1' + String.fromCharCode(10))
await writeFile(join(hostDir, 'pair-access.ts'), 'export const fence = 1' + String.fromCharCode(10))
await writeFile(join(hostDir, 'git-runner.ts'), 'export const runner = 1' + String.fromCharCode(10))
await writeFile(join(hostDir, 'mount-once.ts'), 'export const once = 1' + String.fromCharCode(10))
await writeFile(join(hostDir, 'http.ts'), 'export const http = 1' + String.fromCharCode(10))
+45
View File
@@ -0,0 +1,45 @@
/**
* Pairing trust fence shared by plugins that expose host routes: loopback
* (the desktop) always passes; a live paired-device cookie is an additional
* allow path when remote-web-ui is loaded. The consuming plugin never
* depends on that plugin — without the service the fence stays
* loopback-only.
*
* Per-package wrappers (access.ts) call this with their own name so each
* plugin keeps a self-describing export; the security decision lives only
* here.
*/
import type { IncomingMessage } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import { isLoopbackRequest } from './loopback.ts'
/** Structural pairing lookup (no package dependency on remote-web-ui). */
interface PairingAccess {
isPairedDevice(request: IncomingMessage): boolean
}
/** ctx.get is optional on the test harness; production Context always has it. */
type LookupCtx = Context & {
get?(name: string, strict?: boolean): unknown
remoteWebUiPairing?: PairingAccess
}
/**
* Whether this request may enter the plugin's host routes.
* @param ctx - host context; may expose remoteWebUiPairing.
* @param request - the incoming HTTP request.
* @returns true for loopback, or a live paired-device cookie.
*/
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
if (isLoopbackRequest(request)) return true
const bag = ctx as LookupCtx
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
return pairing?.isPairedDevice(request) === true
}
function isPairingAccess(value: unknown): value is PairingAccess {
return value !== undefined
&& value !== null
&& typeof (value as PairingAccess).isPairedDevice === 'function'
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Canonical tests for the shared pairing trust fence (shared/host/pair-access.ts);
* each consuming package keeps a small wrapper spec for its own wiring.
*/
import type { IncomingMessage } from 'node:http'
import { describe, expect, it, vi } from 'vitest'
import { isPairedOrLoopbackAllowed } from '../host/pair-access.ts'
function request(options: {
remoteAddress?: string
host?: string
cookie?: string
} = {}): IncomingMessage {
return {
headers: {
host: options.host ?? '127.0.0.1:3000',
...(options.cookie === undefined ? {} : { cookie: options.cookie }),
},
socket: { remoteAddress: options.remoteAddress ?? '127.0.0.1' },
} as IncomingMessage
}
describe('isPairedOrLoopbackAllowed', () => {
it('allows loopback without a pairing service', () => {
expect(isPairedOrLoopbackAllowed({} as never, request())).toBe(true)
})
it('refuses a LAN client when no pairing service is present', () => {
const ctx = { get: () => undefined }
expect(isPairedOrLoopbackAllowed(ctx as never, request({
remoteAddress: '192.168.1.20',
host: 'dsh.example:443',
}))).toBe(false)
})
it('allows a LAN client with a live paired-device cookie', () => {
const isPairedDevice = vi.fn(() => true)
const ctx = { get: (name: string) => name === 'remoteWebUiPairing' ? { isPairedDevice } : undefined }
const req = request({
remoteAddress: '192.168.1.20',
host: 'dsh.example:443',
cookie: 'dsh_pair=dev-1',
})
expect(isPairedOrLoopbackAllowed(ctx as never, req)).toBe(true)
expect(isPairedDevice).toHaveBeenCalledWith(req)
})
it('refuses a LAN client when pairing returns false (revoked or unknown)', () => {
const ctx = { get: () => ({ isPairedDevice: () => false }) }
expect(isPairedOrLoopbackAllowed(ctx as never, request({
remoteAddress: '192.168.1.20',
host: 'dsh.example:443',
cookie: 'dsh_pair=revoked',
}))).toBe(false)
})
it('falls back to the direct remoteWebUiPairing property when ctx.get is absent', () => {
const ctx = { remoteWebUiPairing: { isPairedDevice: () => true } }
expect(isPairedOrLoopbackAllowed(ctx as never, request({
remoteAddress: '192.168.1.20',
host: 'dsh.example:443',
cookie: 'dsh_pair=dev-1',
}))).toBe(true)
})
})