From fcd2071786b64ed72660f4e269e4d3f3a2886448 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Thu, 3 Sep 2026 18:09:07 +0800 Subject: [PATCH] fix(client): resolve remote.session through the declared inject (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- package.json | 1 + src/client/historyPage.ts | 100 ++++++++++++++++++++---- src/client/index.ts | 8 ++ src/client/services.ts | 21 ++--- tests/client/compat/clientFaces.spec.ts | 30 ++++++- tests/client/helpers/harness.ts | 11 ++- tests/client/historyPage.spec.ts | 84 +++++++++++++++++++- 7 files changed, 227 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 169bc99..dd3593e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/historyPage.ts b/src/client/historyPage.ts index fe47034..ddd4db6 100644 --- a/src/client/historyPage.ts +++ b/src/client/historyPage.ts @@ -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)[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) | 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) { diff --git a/src/client/index.ts b/src/client/index.ts index 55ccd5f..55d3cde 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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) diff --git a/src/client/services.ts b/src/client/services.ts index 8aea463..0cc6edd 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -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: { diff --git a/tests/client/compat/clientFaces.spec.ts b/tests/client/compat/clientFaces.spec.ts index b973714..30a6470 100644 --- a/tests/client/compat/clientFaces.spec.ts +++ b/tests/client/compat/clientFaces.spec.ts @@ -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() + }) }) } diff --git a/tests/client/helpers/harness.ts b/tests/client/helpers/harness.ts index 74289be..3696b74 100644 --- a/tests/client/helpers/harness.ts +++ b/tests/client/helpers/harness.ts @@ -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, 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 }) } diff --git a/tests/client/historyPage.spec.ts b/tests/client/historyPage.spec.ts index b07d61d..57e0afd 100644 --- a/tests/client/historyPage.spec.ts +++ b/tests/client/historyPage.spec.ts @@ -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 { + 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.