Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/tests/fake-api.ts
#	packages/client/runtime/src/client/workspaces/service.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/client/ui-workspace/src/client/WorkspacePicker.tsx
#	packages/client/ui-workspace/tests/workspace-picker.spec.tsx
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/host.schema.ts
#	packages/host/apiproxy/src/api/host.ts
#	packages/host/apiproxy/src/api/rpc-map.ts
#	packages/host/apiproxy/src/fetch/client.ts
#	packages/host/apiproxy/src/fetch/handler.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/host/apiproxy/tests/client-handler.spec.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
creatixchu
2026-07-28 21:21:21 +08:00
750 changed files with 15381 additions and 5577 deletions

View File

@@ -19,6 +19,13 @@
- id: session
name: '@deepseek-ai/dsh-session'
# Projection registry: drives every registered domain unit over committed
# session events and serves finished values (history-tail projections block +
# session/projection frames). Without this row every domain's optional unit
# injection stays silent — no block, no frames, no titles/todos on the web.
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
- id: session-title
name: '@deepseek-ai/dsh-session-title'
config:

View File

@@ -57,6 +57,7 @@
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",

View File

@@ -34,28 +34,31 @@ export const ALL_INTERFACES_HOST = '0.0.0.0'
* authorities an all-interfaces bind is reachable by on the LAN.
* @returns the addresses in interface order (possibly empty).
*/
export function lanIPv4Addresses(): string[] {
function lanIPv4Addresses(): string[] {
return Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
}
/**
* Authorities the /api browser-trust fence must accept for one invocation:
* the machine's LAN IP literals when the effective bind is all-interfaces
* (advertised by the printed LAN URL, so they must not answer 403), followed
* by the explicit extras. Derived entries are port-less IP literals — DNS
* rebinding needs an attacker-controlled name, so an IP-literal Host is safe
* on any port, and the bound port may be OS-assigned, unknowable pre-boot.
* One LAN-trust resolution for one invocation, sampled exactly once: the
* machine's LAN IP literals when the effective bind is all-interfaces, and
* the `trustedHosts` value built from them plus the explicit extras. The
* single sample is deliberate — display must advertise only addresses the
* fence was configured with, so both read this snapshot. Derived entries are
* port-less IP literals: DNS rebinding needs an attacker-controlled name, so
* an IP-literal Host is safe on any port, and the bound port may be
* OS-assigned, unknowable pre-boot.
* @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
* @param extra - `--trusted-host` values, in argv order.
* @returns the connection row's `trustedHosts` value (possibly empty).
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
*/
export function resolveTrustedHosts(bindHost: string | undefined, extra: readonly string[]): string[] {
return [
...bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [],
...extra,
]
export function resolveLanTrust(
bindHost: string | undefined,
extra: readonly string[],
): { lanAddresses: string[]; trustedHosts: string[] } {
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/** One profile-json key mapped onto a yml row's config field. */
@@ -126,6 +129,14 @@ export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
/**
* LAN IPv4 addresses sampled once at patch composition — the exact snapshot
* the /api trust fence was configured with. Display reads this instead of
* re-sampling, so the advertised LAN URL can never name an address the
* fence rejects. Empty unless the effective bind is all-interfaces.
*/
lanAddresses: readonly string[] = []
private patches: PatchOptions[] = []
constructor(private readonly options: AppCLIEntryOptions) {}
@@ -188,9 +199,10 @@ export class AppCLIEntry {
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 2b: authorities for the /api browser-trust fence (rationale on
// resolveTrustedHosts).
// resolveLanTrust).
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const trustedHosts = resolveTrustedHosts(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
this.lanAddresses = lanAddresses
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
// Source 3: the frontend dist — an assembly fact of this app, never yml

View File

@@ -53,7 +53,7 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
continue
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
const joined = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {

View File

@@ -7,7 +7,7 @@
*/
import { fileURLToPath } from 'node:url'
import { ALL_INTERFACES_HOST, AppCLIEntry, lanIPv4Addresses } from './app-cli-entry.ts'
import { AppCLIEntry } from './app-cli-entry.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
@@ -48,7 +48,9 @@ export async function runWeb(
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lanCandidate = host === ALL_INTERFACES_HOST ? lanIPv4Addresses()[0] : undefined
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)

View File

@@ -1,7 +1,7 @@
/** LAN-authority derivation for the /api browser-trust fence (`resolveTrustedHosts`). */
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { lanIPv4Addresses, resolveTrustedHosts } from '../src/app-cli-entry.ts'
import { describe, expect, it, vi } from 'vitest'
import { resolveLanTrust } from '../src/app-cli-entry.ts'
vi.mock('node:os', () => ({
networkInterfaces: () => ({
@@ -19,22 +19,15 @@ vi.mock('node:os', () => ({
}),
}))
afterEach(() => { vi.restoreAllMocks() })
describe('resolveLanTrust', () => {
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
})
describe('lanIPv4Addresses', () => {
it('returns only non-internal IPv4 addresses, in interface order', () => {
expect(lanIPv4Addresses()).toEqual(['192.168.1.5', '10.0.0.7'])
})
})
describe('resolveTrustedHosts', () => {
it('derives port-less LAN IP literals for an all-interfaces bind, ahead of the extras', () => {
expect(resolveTrustedHosts('0.0.0.0', ['harness.internal:3080']))
.toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
})
it('derives nothing for a loopback or unresolved bind — extras alone stand', () => {
expect(resolveTrustedHosts('127.0.0.1', [])).toEqual([])
expect(resolveTrustedHosts(undefined, ['lab.internal'])).toEqual(['lab.internal'])
it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
})
})