fix(client): resolve remote.session through the declared inject (#42)

The target-content fetch read the 0.1.2+ gateway history face with
ctx.get('remote.session') and then dereferenced .page on the returned
value. A cordis traced-service proxy read off a fiber that never
declared the service throws on property access ("cannot get property
\"remote.session\" without inject") — the guard around ctx.get alone
cannot catch it, and the exception escapes the Context tab render and
takes the whole web client down.

The remote face is now resolved through the DECLARED inject instead:
apply() registers a ctx.inject(['remote', 'remote.session']) callback
(the dsh idiom — both names together, because the traced remote proxy
resolves .session through the context), binding the page verb into a
slot the fetchers prefer. Both names together stay absent on the
pre-0.1.2 line, where the callback never fires and the legacy
connection face carries the reads. Every service property chain is
additionally re-proven (readKeysOf: remote.session.page and
connection.api.sessions.history degrade at the first throwing getter
instead of ever throwing), matching the no-white-screen contract.

package.json now declares @deepseek-ai/dsh-api-remotes in
dsh.client.inject, so the graph composes the namespace-mounting bundle
before this plugin (skip-safe on 0.1.1, where the row does not exist).
This commit is contained in:
bowenliang123 committed 2026-09-03 18:09:07 +08:00
1 parent 3179715f57
commit fcd2071786
7 files changed
+227 -28

No files matched your search

+1
View File
@@ -49,6 +49,7 @@
},
"client": {
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
+86 -14
View File
@@ -11,11 +11,21 @@
* map into the same conversation-node shapes the window join delivers (a
* thin display subset of dsh's own fold). Fetched nodes cache per session —
* history is immutable, so a seq never needs fetching twice.
*
* The remote face is resolved through the DECLARED inject
* (`watchHistoryFaces`): this plugin's module inject lists only slots/
* locale, and a top-level `remote.session` would stall the whole plugin on
* pre-0.1.2 hosts that never mount the namespace — while NONDECLARED reads
* of the traced service proxy throw "cannot get property … without inject"
* and can take a view down. The injection callback runs under a fiber that
* declares both `remote` and `remote.session` (the dsh idiom), so the
* property path resolves; the guarded reflect reads beneath it degrade to
* the legacy face or to no face instead of ever throwing.
*/
import type {
ClientCtx, ConnectionFace, ContentFetcher, ConversationNodeLike, HeaderFetcher,
HistoryEntryLike, SessionPageFace,
ClientCtx, ContentFetcher, ConversationNodeLike, HeaderFetcher,
HistoryEntryLike, SessionPageFace, SessionsHistoryFace,
} from './services'
import type { HeaderEpochContent } from '../shared/types'
@@ -163,6 +173,69 @@ function serviceOf(ctx: ClientCtx, name: string): unknown {
}
}
/**
* Walk a (possibly traced/proxied) service value down a plain-property path,
* degrading at the FIRST throw. cordis's undeclared-service proxies are one
* hostile object class — any accessor backed by host state can throw too —
* and the no-white-screen contract needs the whole chain guarded, not just
* the `ctx.get` call.
*/
function readKeysOf(value: unknown, ...keys: string[]): unknown {
let current = value
for (const key of keys) {
if (current === null || typeof current !== 'object') return undefined
try {
current = (current as Record<string, unknown>)[key]
} catch {
return undefined
}
}
return current
}
/** The `page` verb of a history face, re-proved and bound to its owner. */
function readPageOf(face: unknown): SessionPageFace['page'] | undefined {
const fn = readKeysOf(face, 'page')
return typeof fn === 'function' ? (fn as SessionPageFace['page']).bind(face) : undefined
}
/** The `history` verb of the legacy api client face, re-proved and bound. */
function readHistoryOf(face: unknown): SessionsHistoryFace['history'] | undefined {
const sessions = readKeysOf(face, 'api', 'sessions')
const fn = readKeysOf(sessions, 'history')
return typeof fn === 'function' ? (fn as SessionsHistoryFace['history']).bind(sessions) : undefined
}
/**
* The 0.1.2+ gateway history page verb, resolved through the DECLARED inject
* (see {@link watchHistoryFaces}) and bound up front: a method extracted
* unbound loses `this`, and the traced `remote` proxy that hands it out
* requires the inject to resolve at all.
*/
let declaredPage: SessionPageFace['page'] | undefined
/**
* Register the plugin's 0.1.2+ history faces with the harness through the
* DECLARED inject — both `remote` AND `remote.session` (the ui-chat idiom)
* must be in one fiber's requirement list, because the traced `remote`
* proxy resolves `.session` through the context and each name needs the
* other's declaration. Pre-0.1.2 hosts never provide either name, so the
* callback simply never fires and the legacy `connection` face carries the
* reads. The callback re-runs on every unload/remount, so it owns the
* slot's lifetime. The face itself is re-proven (a never-fired or hostile
* invocation leaves the slot unset — nothing here can throw).
*/
export function watchHistoryFaces(ctx: ClientCtx): void {
ctx.inject(['remote', 'remote.session'], (c) => {
const session = (c as ClientCtx & { remote?: { session?: SessionPageFace } }).remote?.session
const page = session !== undefined ? readPageOf(session) : undefined
declaredPage = page
return () => {
declaredPage = undefined
}
})
}
/** The rows array of a history/page response, under every served envelope. */
function rowsOf(response: unknown): readonly unknown[] {
// Envelope unwrapping, both served shapes: the 0.1.2 remote resolves to
@@ -195,20 +268,19 @@ function rowsOf(response: unknown): readonly unknown[] {
* pre-0.1.2 api client verb (`connection.api.sessions.history`) beneath it.
* Both cut message-aligned pages, so the returned read covers `seq` whenever
* the durable log still holds it: the newer face pins the inclusive cut to
* the seq itself, the legacy reader uses the exclusive bound one past it.
* Undefined when no face exists (older hosts) — callers keep their static
* degradation.
* the seq itself, the legacy reader uses the exclusive bound one past it —
* and because a non-declared read of the traced remote proxy throws
* ("cannot get property … without inject"), the page face prefers the
* injection-resolved slot and only then tries the reflect read. Every
* service property access degrades at its own guard instead of taking the
* view down. Undefined when no face exists (older hosts) — callers keep
* their static degradation.
*/
function pageReadersOf(ctx: ClientCtx, sessionId: string): ((seq: number) => Promise<unknown>) | undefined {
// The 0.1.2+ session namespace is a traced cordis service literally named
// `remote.session` — read through `ctx.get`, whose reflect read needs no
// inject declaration and yields undefined on harnesses that never mount
// the namespace (0.1.1). The faces are bound up front: a method extracted
// unbound loses `this`, and a transport relying on it would throw.
const pageFace = serviceOf(ctx, 'remote.session') as SessionPageFace | undefined
const page = pageFace !== undefined && typeof pageFace.page === 'function' ? pageFace.page.bind(pageFace) : undefined
const historyFace = (serviceOf(ctx, 'connection') as ConnectionFace | undefined)?.api?.sessions
const history = historyFace !== undefined && typeof historyFace.history === 'function' ? historyFace.history.bind(historyFace) : undefined
// The declared-inject face wins; the reflect read beneath it is the
// bounded second chance on hosts where the inject callback never fired.
const page = declaredPage ?? readPageOf(serviceOf(ctx, 'remote.session'))
const history = readHistoryOf(serviceOf(ctx, 'connection'))
if (page === undefined && history === undefined) return undefined
return (seq) => {
if (page !== undefined) {
+8
View File
@@ -30,6 +30,7 @@ import type { ClientCtx } from './services'
import { createContextSettings, type SettingsField, type SettingsScopeBinderFace } from './settings'
import { makeContextView } from './components/contextView'
import { makeContextJumpButton } from './components/contextJump'
import { watchHistoryFaces } from './historyPage'
import { makeViewKit } from './viewkit'
// Theme-native styles: the bundle's global-CSS channel injects each sheet as
@@ -67,6 +68,13 @@ function apply(ctx: ClientCtx): void {
const t = ctx.locale.bind(NS)
const kit = makeViewKit(t)
// History faces of the 0.1.2+ gateway remotes, resolved through the
// DECLARED inject — a non-declared read of the traced `remote.session`
// proxy throws ("cannot get property … without inject") and would take
// the browser down with the view. Injection waits for the service and
// never fires on hosts that lack the namespace (the pre-0.1.2 legacy
// `connection` face carries those reads instead).
watchHistoryFaces(ctx)
const settings = createContextSettings()
const ContextView = makeContextView(ctx, kit, settings)
+11 -10
View File
@@ -540,16 +540,17 @@ export interface SessionsHistoryFace {
/**
* The seq-anchored history page verb of the dsh 0.1.2+ gateway remotes: the
* session namespace mounts as a traced cordis service literally named
* `remote.session`, so `ctx.get('remote.session')` reaches it without an
* inject declaration (undefined on harnesses that never mount it — the
* 0.1.1 remotes carry no session namespace). `throughSeq` is the inclusive
* log cut (a seq that must exist in the log), `beforeSeq` the exclusive
* upper bound, and the response wraps the rows in a `ClientResult`-style
* envelope. Rows are `SessionHistoryRecord`s — `{type:'event', event}`
* entries plus packed `{type:'chunks', …}` runs the mapper skips (every
* event the fold needs — user/assistant messages, tool calls/results,
* compaction summaries — is always served verbatim; only streaming deltas
* pack).
* `remote.session`. The plugin resolves it through the DECLARED inject
* (`watchHistoryFaces` in historyPage.ts — a non-declared read of the
* traced proxy throws), so reads are undefined on harnesses that never
* mount it (the 0.1.1 remotes carry no session namespace) rather than
* crashing. `throughSeq` is the inclusive log cut (a seq that must exist in
* the log), `beforeSeq` the exclusive upper bound, and the response wraps
* the rows in a `ClientResult`-style envelope. Rows are
* `SessionHistoryRecord`s — `{type:'event', event}` entries plus packed
* `{type:'chunks', …}` runs the mapper skips (every event the fold needs —
* user/assistant messages, tool calls/results, compaction summaries — is
* always served verbatim; only streaming deltas pack).
*/
export interface SessionPageFace {
page(request: {
+29 -1
View File
@@ -22,7 +22,7 @@ import { createContextSettings } from '../../../src/client/settings'
import { DICT_EN } from '../../../src/client/i18n'
import type { ContextTimeline } from '../../../src/shared/types'
import { h } from '../../../src/client/react'
import { asClientCtx } from '../helpers/harness'
import { asClientCtx, TestClientCtx } from '../helpers/harness'
import { click, makeKit, mount, queryAll, text } from '../helpers/kit'
import { baselineCtx, chatSeat, convNodes, sessionSeat } from './baselineFaces'
@@ -137,6 +137,34 @@ for (const baseline of BASELINES) {
assert.ok(text(m.container).includes('window node hello'), 'the conversation-window join surfaced the seat node')
await m.unmount()
})
test('a throwing history-service proxy degrades the tab, never crashes it', async () => {
// Issue #42: an undeclared-service proxy can throw on the property READ
// itself ("cannot get property "remote.session" without inject") — the
// no-white-screen guarantee must degrade it to the static hint, not let
// it escape the view and take the whole web client down.
const ctx = new TestClientCtx()
if (baseline.client.historyFace === 'remote.session.page') {
ctx.setService('remote.session', {
get page() { throw new Error('cannot get property "remote.session" without inject') },
})
} else {
ctx.setService('connection', { get api() { throw new Error('hostile connection') } })
}
const settings = createContextSettings()
const View = makeContextView(asClientCtx(ctx), kit, settings)
const nodes = convNodes()
const props: SessionStandardProps = {
sessionId: 's-face',
useProjection: (key: string) => (key === 'contextTimeline' ? timeline() : undefined),
...(baseline.client.chatNodesSeat === 'useChat'
? { useChat: chatSeat(nodes) }
: { useSession: sessionSeat(nodes) }),
}
const m = await mount(h(View, props))
assert.ok(text(m.container).includes(DICT_EN['overview.title']), 'the tab still rendered around a hostile face')
await m.unmount()
})
})
}
+9 -2
View File
@@ -96,14 +96,21 @@ export class TestClientCtx {
const p = this.pending[i]
if (p.deps.every(d => this.services.has(d))) {
this.pending.splice(i, 1)
p.cb(this)
this.runInjected(p.cb)
}
}
}
private runInjected(cb: (ctx: TestClientCtx) => void): void {
// Cordis runs the inject callback as a disposable plugin: a returned
// disposer belongs to the fiber and runs on unload.
const dispose = cb(this)
if (typeof dispose === 'function') this.disposers.push(dispose)
}
inject(deps: string[] | Record<string, unknown>, cb: (ctx: TestClientCtx) => void): void {
const list = Array.isArray(deps) ? deps : Object.keys(deps)
if (list.every(d => this.services.has(d))) cb(this)
if (list.every(d => this.services.has(d))) this.runInjected(cb)
else this.pending.push({ deps: list, cb })
}
+83 -1
View File
@@ -4,7 +4,8 @@
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeContentFetcher, makeHeaderFetcher, pageNodesOf } from '../../src/client/historyPage'
import { makeContentFetcher, makeHeaderFetcher, pageNodesOf, watchHistoryFaces } from '../../src/client/historyPage'
import { asClientCtx, TestClientCtx } from './helpers/harness'
/** One well-formed history row. */
function row(type: string, seq: number, data: unknown): unknown {
@@ -224,6 +225,87 @@ describe('makeContentFetcher — the per-session targeted read', () => {
assert.equal(makeContentFetcher(hostile('connection'), 's'), undefined)
})
// The issue-42 failure: the injected-service PROXY throws on the `.page`
// read itself ("cannot get property "remote.session" without inject") —
// the guard around `ctx.get` alone cannot catch it. A throwing getter must
// degrade to the legacy face (or to no face), never out of the fetcher.
test('a throwing service-property read degrades to the legacy face', async () => {
const calls: { beforeSeq: number }[] = []
const hostilePage = {
get page() {
throw new Error('cannot get property "remote.session" without inject')
},
}
const fetch = makeContentFetcher(ctxWith({
'remote.session': hostilePage,
connection: { api: apiFace([row('user/message', 7, { content: [{ type: 'text', text: 'LEGACY BODY' }] })], calls) },
}), 's')!
const node = await fetch(7)
assert.deepEqual(calls, [{ beforeSeq: 8 }], 'the throwing page face falls back to the legacy verb')
assert.equal((node?.content as { text: string }[])[0]?.text, 'LEGACY BODY')
// No legacy face beneath it: the fetcher itself degrades, no throw.
assert.equal(makeContentFetcher(ctxWith({ 'remote.session': hostilePage }), 's'), undefined)
})
test('a throwing connection-property chain degrades to no fetcher', () => {
const hostileApi = {
get api() {
throw new Error('cannot get property "api" without inject')
},
}
assert.equal(makeContentFetcher(ctxWith({ connection: hostileApi }), 's'), undefined)
assert.equal(makeContentFetcher(ctxWith({ connection: { api: { get sessions() { throw new Error('hostile sessions') } } } }), 's'), undefined)
})
test('the declared remote.session inject carries the page read and unloads cleanly', async () => {
// The harness mirrors the traced `remote` service: factory→`.session`
// is the facade property path the DECLARED fiber resolves. The direct
// `remote.session` service value is hostile on purpose — the injection
// path must be the one that carries the read.
const calls: unknown[] = []
const declared = {
session: {
page(request: unknown, _signal: AbortSignal): Promise<unknown> {
calls.push(request)
return Promise.resolve({ ok: true, value: { records: [] } })
},
},
}
const ctx = new TestClientCtx()
ctx.setService('remote', declared)
ctx.setService('remote.session', { get page() { throw new Error('cannot get property "remote.session" without inject') } })
watchHistoryFaces(asClientCtx(ctx))
const fetch = makeContentFetcher(asClientCtx(ctx), 'sess-1')!
const node = await fetch(7)
assert.deepEqual(calls, [{ address: { kind: 'session', sessionId: 'sess-1' }, throughSeq: 7, beforeSeq: 8 }])
assert.equal(node, null, 'the declared page face mapped the (empty) page')
// Unload clears the slot: the hostile reflect read (and the absent
// legacy face) then degrade to no fetcher — nothing stales across
// plugin reloads.
ctx.dispose()
assert.equal(makeContentFetcher(asClientCtx(ctx), 's'), undefined, 'the unloaded slot leaves no face behind')
})
test('the declared inject never fires without both remote services', () => {
const ctx = new TestClientCtx()
// Only `remote`: the injected fiber requires BOTH names, so nothing
// fires and the legacy verb carries the reads (the pre-0.1.2 line).
ctx.setService('remote', { session: {} })
watchHistoryFaces(asClientCtx(ctx))
ctx.dispose()
})
test('the declared inject tolerates a shapeless remote service', () => {
const ctx = new TestClientCtx()
// Both names exist but the facade bears no session namespace: the
// callback fires and leaves the slot unset, never throwing.
ctx.setService('remote', {})
ctx.setService('remote.session', {})
watchHistoryFaces(asClientCtx(ctx))
assert.equal(makeContentFetcher(asClientCtx(ctx), 's'), undefined)
ctx.dispose()
})
test('the 0.1.2 page face wins over the legacy history verb and maps its records', async () => {
const calls: unknown[] = []
// The real 0.1.2 shape: the remote resolves to the ClientResult itself.