From 3d437a6b72003c76f1db769a746319ff3f57531a Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 16:12:09 +0800 Subject: [PATCH 01/10] feat(dsh): manage compatible stock presets --- AGENTS.md | 13 + CONTEXT.md | 39 + README.md | 57 +- docs/adr/0004-dsh-research-surface-owner.md | 55 ++ packages/dsh-web/README.md | 51 +- packages/dsh-web/cordis.patch.yml | 30 +- packages/dsh-web/package.json | 4 +- packages/dsh-web/src/client.ts | 45 +- packages/dsh-web/src/contract.ts | 1 - packages/dsh-web/src/index.ts | 15 +- packages/dsh-web/src/provider.ts | 153 ---- packages/dsh-web/src/tools.ts | 268 +++++- packages/dsh-web/test/artifact.test.ts | 61 +- packages/dsh-web/test/client.test.ts | 8 +- packages/dsh-web/test/package.test.ts | 83 +- packages/dsh-web/test/provider.test.ts | 197 ---- packages/dsh-web/test/tools.test.ts | 300 +++++- packages/dsh-web/tsup.config.ts | 1 - packages/web/src/dsh.ts | 964 ++++++++++++++++++++ packages/web/src/program.ts | 36 + packages/web/src/runner.ts | 3 + packages/web/test/dsh.test.ts | 349 +++++++ pnpm-lock.yaml | 15 - 23 files changed, 2213 insertions(+), 535 deletions(-) create mode 100644 docs/adr/0004-dsh-research-surface-owner.md delete mode 100644 packages/dsh-web/src/provider.ts delete mode 100644 packages/dsh-web/test/provider.test.ts create mode 100644 packages/web/src/dsh.ts create mode 100644 packages/web/test/dsh.test.ts diff --git a/AGENTS.md b/AGENTS.md index f5595e2..7dee889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,16 @@ Repository CI and local tests target Linux. Treat Windows behavior as best-effor ## DSH rc.1 Loader validation For profile-local packaged plugins on DSH `0.1.2-rc.1` with Node 24, install the official rc.1 CLI into a test-owned disposable directory and validate its real installed graph through the Node entrypoint with internals enabled: `node --expose-internals /node_modules/@deepseek-ai/dsh/lib/bin.js ...`. Keep `DSH_HOME` test-owned and disposable, do not use a deployed runtime or live credentials, and do not add custom runtime overrides or compatibility layers. + +## Managed DSH presets + +The supported preset workflow is explicit: run `web dsh sync` before installing +or activating `@guionai/dsh-web`, then run `web dsh doctor` and require a zero +exit status. Sync owns only marked copies of `standard`, `ptc`, `cordis`, and +`minimal` under the test-owned DSH user preset root; it must refuse unmarked +same-id conflicts and must not mutate shipped presets, ordinary user presets, +credentials, or sessions. Tests use fixture source trees and disposable +`DSH_HOME` directories. End-to-end validation exercises the packed artifact +through the real Linux rc.1 Loader path and records the disposable paths and +results in the implementation report. Do not validate this integration by +redirecting an agent home to a live deployment or by adding runtime overrides. diff --git a/CONTEXT.md b/CONTEXT.md index 7c22753..63a888d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -71,3 +71,42 @@ The `/api/v1/web` versioned path namespace for the HTTP Service's research operations when exposed through FlickNote Gateway. It identifies Guion Web as the owning service without changing its operation contracts. _Avoid_: Bare `/v1` API, generic web route + +**DSH Research Surface**: +The Guion-owned model-facing DSH tool suite: `web_search`, `web_fetch`, +`web_links`, `web_docs`, and `web_source_search`, plus `web_weather`, +`web_sports`, `web_finance`, and `web_time` while Kepos Bridge is selected. +It is registered globally by `@guionai/dsh-web`; native and PTC presentation +modes consume the same registrations. +_Avoid_: Official Web suite, scoped Web row + +**Compatible DSH Preset**: +A DSH preset that omits its entire scoped `tool-web` row and therefore inherits +the global DSH Research Surface from Guion. The `web dsh sync` command creates +four Guion-managed compatible copies with the same ids as the official shipped +`standard`, `ptc`, `cordis`, and `minimal` presets. The DSH bundle hides the +shipped root while retaining the user root, so these copies become active and +ordinary user presets such as Yuki remain visible. +_Avoid_: Unmanaged copied preset, partially compatible preset + +**Official Shipped DSH Preset**: +A preset installed by `@deepseek-ai/dsh-agent-presets` beside the DSH runtime. +It is deployment-owned, may include the scoped official `tool-web` row, and is +never edited by Guion. The bundle sets `includeShippedRoot: false` because DSH +shipped entries win same-id duplicates. +_Avoid_: Managed preset, user preset + +**Guion-Managed DSH Preset**: +A full snapshot under `${DSH_HOME:-$HOME/.dsh}/.agent-presets` generated by +`web dsh sync` from the installed official rc.1 package. Its +`.guion-managed.json` marker records the source version and identity. Sync may +replace only this marked output; an unmarked same-id directory is user-owned +and causes a conflict. `web dsh doctor` verifies that all four snapshots are +current, complete, and free of scoped `tool-web` rows. +_Avoid_: Hand-edited output, shipped preset + +**Ordinary DSH User Preset**: +A user-created directory in the DSH user preset root without Guion's marker. +It remains visible when the bundle hides the shipped root and is never +overwritten or deleted by `web dsh sync`. +_Avoid_: Guion-managed preset, shipped preset diff --git a/README.md b/README.md index 8be0eed..adbe854 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ HTTP service, Pi extension, and DeepSeek Harness (DSH) integration: HTTP HTML-to-Markdown extraction and explicit browser rendering for client-rendered pages on supported hosts. +> **DSH setup note:** Run `web dsh sync` before installing the DSH bundle and +> `web dsh doctor` afterward. The workflow creates Guion-managed copies of the +> familiar stock preset ids and hides their official shipped duplicates while +> preserving ordinary user presets. + ## Install and configure Node.js 20 or later is required. `@guionai/web` exposes its `web` executable, @@ -202,21 +207,44 @@ the presence of its key alone. ## DSH -Install the DSH bundle in the existing Web profile: +The bundle uses the familiar stock preset ids, but its compatible copies are +owned by Guion in the DSH user preset root. Synchronize those copies before +installing or activating the profile bundle: ```bash +web dsh sync dsh plugin --profile web add @guionai/dsh-web +web dsh doctor ``` -The included profile patch routes stock PTC web search through the selected Exa, -Brave, DeepSeek, or Kepos Bridge provider. Its settings UI stores provider selection and -the complete non-secret Kepos Bridge route (default -`http://codex-bridge.localhost:17480/codex/web-search`) and manages namespaced write-only -credentials, including a write-only DeepSeek API key. DeepSeek uses the same -provider picker/key workflow and exposes no DeepSeek endpoint field. Selecting Kepos Bridge additionally exposes `web_weather`, -`web_sports`, `web_finance`, and `web_time`; these tools are removed when another -provider is selected. Fetch, link discovery, documentation, and Sourcegraph tools -also run in-process. The host DSH packages and React are peers supplied by DSH. +`web dsh sync` reads the installed official +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates marked, +compatible `standard`, `ptc`, `cordis`, and `minimal` copies. It removes only +the top-level official `tool-web` row from the first three; Minimal is copied +unchanged because it already omits that row. The command is idempotent and +refreshes only directories bearing Guion's marker. An existing unmarked +same-id directory is never overwritten: move it or choose another id before +retrying. Run sync again after upgrading the supported DSH runtime. The +read-only doctor command reports missing, incomplete, stale, and conflicting +copies and exits nonzero when the roster is not ready. + +The bundle's preset roster sets `includeShippedRoot: false`, +`includeUserRoot: true`, and `default: standard`. This hides all official +shipped duplicates while preserving Yuki and every other ordinary user +preset. Existing sessions, credentials, and deployed profiles are not +migrated automatically; activate or deploy the bundle separately after a +successful sync and doctor run. + +The profile patch disables the official DSH Web registry, search/fetch providers, +and `tool-web`, then registers Guion's complete DSH Research Surface directly. +Its settings UI stores provider selection and the complete non-secret Kepos +Bridge route (default `http://codex-bridge.localhost:17480/codex/web-search`) +and manages namespaced write-only credentials, including a write-only DeepSeek +API key. DeepSeek uses the same provider picker/key workflow and exposes no +DeepSeek endpoint field. Selecting Kepos Bridge additionally exposes +`web_weather`, `web_sports`, `web_finance`, and `web_time`; these tools are +removed when another provider is selected. The host DSH target is +`0.1.2-rc.1`; its packages and React are peers supplied by DSH. `web_fetch` uses HTTP rendering by default and can explicitly use `render: "browser"` with an integer `waitMs` on a host that supplies the optional executable. @@ -227,6 +255,15 @@ other adapters: input mode is `auto|full|tree` (default `auto`), and omitted or `web_links` uses the same explicit rendering contract and lists HTTP(S) anchors from the original page DOM. +Guion owns `web_search`, `web_fetch`, `web_links`, `web_docs`, and +`web_source_search` in both native and PTC presentation modes. `web_search` +accepts one to four trimmed queries, runs them concurrently, deterministically +merges partial successes, and reports total failure clearly. `web_fetch` +accepts `mode: "auto" | "full" | "tree"`, optional `section_id` with omitted +or `auto` mode, `render: "http" | "browser"`, and browser `waitMs` from 0 +through 30,000. `web_links` has the same rendering and wait contract. The +complete schemas are inherited by every managed stock-equivalent preset. + ## Page-rendering modes `web fetch` has two renderers. `http` (the default) uses Node `fetch`, `linkedom`, diff --git a/docs/adr/0004-dsh-research-surface-owner.md b/docs/adr/0004-dsh-research-surface-owner.md new file mode 100644 index 0000000..3012f47 --- /dev/null +++ b/docs/adr/0004-dsh-research-surface-owner.md @@ -0,0 +1,55 @@ +# Make Guion the single owner of the DSH Research Surface + +## Status + +Accepted + +## Context + +Guion's DSH integration previously selected a Guion provider through the +official DSH Web registry while the official `tool-web` plugin supplied the +model-facing `web_search` and `web_fetch` surface. A scoped official row in a +preset could therefore change the effective tools between native and PTC +modes, and the profile carried duplicate provider and Web-suite ownership. + +## Decision + +This decision targets DeepSeek Harness `0.1.2-rc.1`. + +`@guionai/dsh-web` directly registers the complete DSH Research Surface: +`web_search`, `web_fetch`, `web_links`, `web_docs`, and `web_source_search`, +with the Kepos-only `web_weather`, `web_sports`, `web_finance`, and `web_time` +registrations enabled only while Kepos Bridge is selected. Search reads the +live Guion provider and namespaced credential for every execution. The plugin +does not register with or depend on the official DSH Web provider registry. + +The profile patch disables `dsh-web`, `web-search-deepseek`, `web-fetch-http`, +and `tool-web`, then mounts only the Guion host plugin with its credential, +settings, and tools services. The bundle also patches the official +`agent-presets` row with `includeShippedRoot: false`, `includeUserRoot: true`, +and `default: standard`. DSH prepends shipped presets and lets them win +same-id duplicates, so the shipped root must be hidden for compatible copies +to take effect. + +`web dsh sync` reads the installed official +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` tree and writes full snapshots of +`standard`, `ptc`, `cordis`, and `minimal` under the user preset root. It +removes only the expected top-level official `tool-web` row from the first +three and marks every output with its source version and identity. Sync may +replace only its marked outputs; it refuses unmarked same-id directories and +never edits the shipped package or ordinary user presets. `web dsh doctor` +performs the corresponding read-only checks. + +## Consequences + +Provider selection and credentials remain one live Guion settings surface, and +native and PTC calls receive the same registered tools. Official shipped +presets remain deployment-owned and untouched; their same-id managed copies +are the supported path because the bundle hides the shipped root while +retaining Yuki and other ordinary user presets. Existing sessions, credentials, +and deployed profiles are not migrated automatically. Runtime upgrades require +an explicit sync, and deployment remains a separate operator action. + +The root and package READMEs, `CONTEXT.md`, and `AGENTS.md` are the only project +documents that describe this operator and agent workflow; no other project +documentation exposes a DSH preset contract that needs updating. diff --git a/packages/dsh-web/README.md b/packages/dsh-web/README.md index 114bcb7..eab4ae9 100644 --- a/packages/dsh-web/README.md +++ b/packages/dsh-web/README.md @@ -2,15 +2,50 @@ DeepSeek Harness 0.1.2-rc.1 Web bundle and browser settings client for Guion Web. -Install it into the existing Web profile: +Generate compatible stock-equivalent presets before installing or activating +the bundle in the existing Web profile: ```bash +web dsh sync dsh plugin --profile web add @guionai/dsh-web +web dsh doctor ``` -The package owns the Web profile patch and does not require a custom profile or -PTC preset. It leaves the root `tool-web` row disabled and routes stock PTC's -batched `web_search` through the Guion provider seam. +`web dsh sync` reads the installed official +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates marked copies +with the familiar `standard`, `ptc`, `cordis`, and `minimal` ids in +`${DSH_HOME:-$HOME/.dsh}/.agent-presets`. It removes only the top-level +official `tool-web` row from `standard`, `ptc`, and `cordis`; Minimal is copied +unchanged. Sync is idempotent and refreshes only directories bearing Guion's +marker. It refuses an unmarked same-id directory, so a user's custom preset is +never overwritten or deleted. Run sync again after upgrading DSH. `doctor` is +read-only, reports missing/incomplete/conflicting/stale managed copies, and +exits nonzero until all four are current. + +The bundle patch sets `includeShippedRoot: false`, `includeUserRoot: true`, +and `default: standard`. This hides the official shipped duplicates while +retaining Yuki and all other ordinary user presets. Existing sessions, +credentials, and deployed profiles are not migrated automatically; activate or +deploy the bundle only after sync and doctor succeed. + +The package owns the global DSH Research Surface and does not require a custom +profile. It directly registers `web_search`, `web_fetch`, `web_links`, +`web_docs`, and `web_source_search`; while Kepos Bridge is selected it also +registers `web_weather`, `web_sports`, `web_finance`, and `web_time`. Managed +stock-equivalent presets omit the scoped `tool-web` row so both native and PTC +modes inherit these same global registrations. + +The profile patch disables the official DSH Web registry, official search and +fetch providers, and official `tool-web`; it does not load or depend on the +official `@deepseek-ai/dsh-web` package. + +The Guion schemas are complete and shared by every managed preset: +`web_search` takes one to four trimmed queries and preserves concurrent, +deterministic partial results; `web_fetch` takes `mode: "auto" | "full" | +"tree"`, optional `section_id` with omitted/`auto` mode, and explicit +`render: "http" | "browser"` with browser `waitMs` from 0 through 30,000; +`web_links` has the same renderer contract. `web_docs` and +`web_source_search` retain their existing Context7 and Sourcegraph contracts. Provider selection is explicit and persists in the `guionai-web` settings namespace. Exa, Brave, and DeepSeek API keys use namespaced write-only DSH credentials; @@ -26,6 +61,14 @@ field in this card is for Kepos Bridge only. The route is a complete absolute `http:` or `https:` URL; credentials, query strings, and fragments are rejected and its path is used exactly as entered. +`web_search` accepts one to four trimmed, non-empty queries, starts valid +queries concurrently, interleaves successful results deterministically, and +keeps partial successes when one query fails. If every query fails it reports +the failed queries clearly. The selected provider and its credential are read +when each search executes, so the next call observes a settings change without +remounting a preset. Search output uses the same bounded model-facing text +conventions as the other Guion adapters. + The published package is a dual host/browser bundle. Its host and client artifacts, profile patch, and exact DSH `0.1.2-rc.1` peer contract are included in the npm package. Search, HTTP page rendering, page-link discovery, optional browser rendering, diff --git a/packages/dsh-web/cordis.patch.yml b/packages/dsh-web/cordis.patch.yml index 4ade833..cc9fd24 100644 --- a/packages/dsh-web/cordis.patch.yml +++ b/packages/dsh-web/cordis.patch.yml @@ -1,16 +1,30 @@ -# rc.1 stock Web has one configured search provider and no fetch provider. -# Replace only that complete service config; the PTC preset remains untouched. +# Guion owns the complete model-facing Research Surface. Disable every stock +# Web owner so no profile can register a second provider, fetch backend, or +# scoped tool suite beside it. - id: web - name: "@deepseek-ai/dsh-web" - config: - searchProvider: guionai-web-search + disabled: true + +- id: web-search-deepseek + disabled: true + +- id: web-fetch-http + disabled: true -# The root model-facing tool is intentionally disabled. PTC owns its native -# batched web_search surface and reaches the provider through ctx.web. - id: tool-web disabled: true +# DSH prepends its shipped preset root and lets it win duplicate ids by +# design. Guion-managed same-id copies therefore become active only when the +# shipped root is disabled; the user root remains enabled so Yuki and other +# ordinary user presets stay visible. +- id: agent-presets + name: "@deepseek-ai/dsh-agent-presets" + config: + includeShippedRoot: false + includeUserRoot: true + default: standard + - insert: - id: guionai-dsh-web name: "@guionai/dsh-web" - inject: [web, credentials, settings, tools] + inject: [credentials, settings, tools] diff --git a/packages/dsh-web/package.json b/packages/dsh-web/package.json index ec09ea9..64673ef 100644 --- a/packages/dsh-web/package.json +++ b/packages/dsh-web/package.json @@ -1,7 +1,7 @@ { "name": "@guionai/dsh-web", "version": "0.1.0", - "description": "DeepSeek Harness 0.1.2-rc.1 provider for Exa, Brave, DeepSeek, and Kepos Bridge web research with HTTP and optional browser page-rendering modes.", + "description": "DeepSeek Harness 0.1.2-rc.1 Guion-owned research surface for Exa, Brave, DeepSeek, and Kepos Bridge with HTTP and optional browser page-rendering modes.", "homepage": "https://github.com/guionai/web#readme", "bugs": { "url": "https://github.com/guionai/web/issues" @@ -71,7 +71,6 @@ "@deepseek-ai/dsh-credentials": "0.1.2-rc.1", "@deepseek-ai/dsh-settings": "0.1.2-rc.1", "@deepseek-ai/dsh-tools": "0.1.2-rc.1", - "@deepseek-ai/dsh-web": "0.1.2-rc.1", "@deepseek-ai/schemastery": "3.18.2", "react": "^18.2.0" }, @@ -90,7 +89,6 @@ "@deepseek-ai/dsh-credentials": "0.1.2-rc.1", "@deepseek-ai/dsh-settings": "0.1.2-rc.1", "@deepseek-ai/dsh-tools": "0.1.2-rc.1", - "@deepseek-ai/dsh-web": "0.1.2-rc.1", "@deepseek-ai/schemastery": "3.18.2", "@types/react": "^18.3.0", "react": "18.3.1", diff --git a/packages/dsh-web/src/client.ts b/packages/dsh-web/src/client.ts index 4344c35..7d00231 100644 --- a/packages/dsh-web/src/client.ts +++ b/packages/dsh-web/src/client.ts @@ -65,7 +65,7 @@ function installStyles(css: string): () => void { return () => style.remove(); } -type ResearchToolName = "web_fetch" | "web_links" | "web_docs"; +type ResearchToolName = "web_search" | "web_fetch" | "web_links" | "web_docs"; function WebResearchToolCard({ toolName, @@ -77,13 +77,15 @@ function WebResearchToolCard({ const running = !isToolResult(block); const error = isToolResult(block) && block.isError; const title = - name === "web_fetch" - ? "Fetch page" - : name === "web_links" - ? "Find links" - : args.action === "resolve" - ? "Find documentation" - : "Fetch documentation"; + name === "web_search" + ? "Search the web" + : name === "web_fetch" + ? "Fetch page" + : name === "web_links" + ? "Find links" + : args.action === "resolve" + ? "Find documentation" + : "Fetch documentation"; const summary = toolSummary(name, args); const details = name === "web_fetch" ? fetchDetails(args) : []; @@ -152,6 +154,23 @@ function toolBody( args: Record, output: string, ): ReturnType | null { + if (name === "web_search") { + const count = /^Found (\d+) search results/.exec(output)?.[1]; + return createElement( + "div", + { className: "guionai-web__tool-body" }, + createElement( + "p", + { className: "guionai-web__result-count" }, + count ? `${count} results found` : "Search complete", + ), + createElement( + "p", + { className: "guionai-web__excerpt" }, + excerpt(output), + ), + ); + } if (name === "web_links") { const links = linksFromOutput(output); const count = /^Found (\d+) links?/.exec(output)?.[1]; @@ -272,6 +291,13 @@ function toolSummary( name: ResearchToolName, args: Record, ): string { + if (name === "web_search") { + const queries = args.queries; + return Array.isArray(queries) && + queries.every((query) => typeof query === "string") + ? queries.join(", ") + : "Web search"; + } if (name === "web_docs") { const identifier = args.action === "resolve" ? args.query : args.library_id; return typeof identifier === "string" @@ -781,12 +807,11 @@ export function apply(ctx: ClientContext): void { ), ); ctx.slots.inject("tool.call.toolview", function* () { - for (const key of ["web_fetch", "web_links", "web_docs"]) { + for (const key of ["web_search", "web_fetch", "web_links", "web_docs"]) { yield ctx.slots.register( { name: "tool.call.toolview", key, - ...(key === "web_fetch" ? { priority: -1 } : {}), inject: () => ({}), } as never, ((props: ToolCallViewProps) => diff --git a/packages/dsh-web/src/contract.ts b/packages/dsh-web/src/contract.ts index 2eeb147..3788838 100644 --- a/packages/dsh-web/src/contract.ts +++ b/packages/dsh-web/src/contract.ts @@ -1,5 +1,4 @@ export const SETTINGS_NAMESPACE = "guionai-web" as const; -export const SEARCH_PROVIDER_ID = "guionai-web-search" as const; export { DEFAULT_KEPOS_BRIDGE_ENDPOINT, diff --git a/packages/dsh-web/src/index.ts b/packages/dsh-web/src/index.ts index e4d2c1b..a04a723 100644 --- a/packages/dsh-web/src/index.ts +++ b/packages/dsh-web/src/index.ts @@ -10,11 +10,10 @@ import { type GuionSettings, validateKeposBridgeEndpoint, } from "./contract.js"; -import { createGuionSearchProvider } from "./provider.js"; import { registerKeposTools, registerWebTools } from "./tools.js"; export const name = "guionai-dsh-web"; -export const inject = ["web", "credentials", "settings", "tools"] as const; +export const inject = ["credentials", "settings", "tools"] as const; const keposBridgeEndpointSchema = z .string() @@ -38,18 +37,10 @@ export function apply(ctx: Context): void { }, }); const operations = createWebOperations(); - ctx.web.registerSearchProvider( - createGuionSearchProvider({ - getProvider: () => (settings.get() as GuionSettings).provider, - getKeposBridgeEndpoint: () => - (settings.get() as GuionSettings).keposBridgeEndpoint, - credentials: ctx.credentials, - operations, - }), - ); const toolDependencies = { credentials: ctx.credentials, operations, + getProvider: () => (settings.get() as GuionSettings).provider, getKeposBridgeEndpoint: () => (settings.get() as GuionSettings).keposBridgeEndpoint, }; @@ -85,5 +76,5 @@ export function apply(ctx: Context): void { }; }; - ctx.effect(install, "guionai-dsh-web: provider tools"); + ctx.effect(install, "guionai-dsh-web: research tools"); } diff --git a/packages/dsh-web/src/provider.ts b/packages/dsh-web/src/provider.ts deleted file mode 100644 index 4c9adae..0000000 --- a/packages/dsh-web/src/provider.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - createWebOperations, - type SearchCredentials, - type SearchResponse, - type WebOperations, -} from "@guionai/web-core"; -import { - credentialRef, - type CredentialRef, - type ResolvedCredential, -} from "@deepseek-ai/dsh-credentials"; -import type { WebSearchProvider, WebSearchResult } from "@deepseek-ai/dsh-web"; - -import { - BRAVE_CREDENTIAL_REF, - DEEPSEEK_CREDENTIAL_REF, - EXA_CREDENTIAL_REF, - SEARCH_PROVIDER_ID, - type SearchProviderName, -} from "./contract.js"; - -export interface SearchProviderDependencies { - getProvider: () => SearchProviderName; - /** Reads the complete bridge route from the live settings scope. */ - getKeposBridgeEndpoint: () => string; - credentials: { - resolve(ref: CredentialRef): Promise; - }; - operations?: WebOperations; -} - -function credentialFor(provider: SearchProviderName): - | { - ref: CredentialRef; - field: keyof SearchCredentials; - } - | undefined { - switch (provider) { - case "exa": - return { ref: credentialRef(EXA_CREDENTIAL_REF), field: "exaApiKey" }; - case "brave": - return { ref: credentialRef(BRAVE_CREDENTIAL_REF), field: "braveApiKey" }; - case "deepseek": - return { - ref: credentialRef(DEEPSEEK_CREDENTIAL_REF), - field: "deepseekApiKey", - }; - case "kepos-bridge": - return undefined; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function validateSearchResult( - value: unknown, - provider: SearchProviderName, -): WebSearchResult { - if (!isRecord(value) || !Array.isArray(value.results)) { - throw new Error(`${provider} search returned an invalid results array`); - } - - const sources = value.results.map((raw, index) => { - if (!isRecord(raw)) { - throw new Error( - `${provider} search returned an invalid result at index ${index}: source must be an object`, - ); - } - const link = raw.link; - const title = raw.title; - const snippet = raw.snippet; - if (typeof link !== "string" || link.length === 0) { - throw new Error( - `${provider} search returned an invalid result at index ${index}: link must be a non-empty string`, - ); - } - if (typeof title !== "string") { - throw new Error( - `${provider} search returned an invalid result at index ${index}: title must be a string`, - ); - } - if (typeof snippet !== "string") { - throw new Error( - `${provider} search returned an invalid result at index ${index}: snippet must be a string`, - ); - } - return { url: link, title, snippet }; - }); - - return { sources, truncated: false }; -} - -export function createGuionSearchProvider( - dependencies: SearchProviderDependencies, -): WebSearchProvider { - const operations = dependencies.operations ?? createWebOperations(); - return { - id: SEARCH_PROVIDER_ID, - // Selection is explicit in live DSH settings. Credentials are resolved for - // each call and never escape into process-global state. - available: () => true, - async search(request, signal): Promise { - const provider = dependencies.getProvider(); - const credential = credentialFor(provider); - let resolved: ResolvedCredential | undefined; - if (credential !== undefined) { - try { - resolved = await dependencies.credentials.resolve(credential.ref); - } catch { - throw new Error(`${provider} credential resolution failed`); - } - } - - const credentials: SearchCredentials = - credential === undefined || resolved === undefined - ? {} - : { [credential.field]: resolved.value }; - let result: SearchResponse; - try { - const searchInput = { - query: request.query, - provider, - credentials, - signal, - ...(request.maxResults === undefined || provider !== "kepos-bridge" - ? {} - : { maxResults: request.maxResults }), - ...(provider === "kepos-bridge" - ? { - keposBridgeEndpoint: dependencies.getKeposBridgeEndpoint(), - } - : {}), - }; - result = await operations.search(searchInput); - } catch (error) { - if (error instanceof Error && error.message === "Operation aborted") - throw error; - throw new Error(`${provider} search failed`); - } - try { - return validateSearchResult(result, provider); - } catch (error) { - const detail = - error instanceof Error ? error.message : "invalid source"; - throw new Error( - `${provider} search result validation failed: ${detail}`, - ); - } - }, - }; -} diff --git a/packages/dsh-web/src/tools.ts b/packages/dsh-web/src/tools.ts index d22e11a..67056f2 100644 --- a/packages/dsh-web/src/tools.ts +++ b/packages/dsh-web/src/tools.ts @@ -18,6 +18,8 @@ import { type SGraphResult, type KeposBridgeResponse, type WebOperations, + type SearchCredentials, + type SearchResponse, } from "@guionai/web-core"; import { credentialRef, @@ -27,7 +29,13 @@ import { import type { Context } from "@deepseek-ai/cordis"; import { defineTool, type ToolDefinition } from "@deepseek-ai/dsh-tools"; -import { CONTEXT7_CREDENTIAL_REF } from "./contract.js"; +import { + BRAVE_CREDENTIAL_REF, + CONTEXT7_CREDENTIAL_REF, + DEEPSEEK_CREDENTIAL_REF, + EXA_CREDENTIAL_REF, + type SearchProviderName, +} from "./contract.js"; export interface WebToolDependencies { credentials: { @@ -35,9 +43,71 @@ export interface WebToolDependencies { }; /** Reads the current validated bridge endpoint when a Kepos tool executes. */ getKeposBridgeEndpoint: () => string; + /** Reads the selected provider from the live settings scope per search. */ + getProvider?: () => SearchProviderName; operations?: WebOperations; } +const searchParameters = { + queries: { + type: "array", + required: true, + description: "One to four web search queries", + items: { + type: "string", + description: "A non-empty web search query", + }, + }, +} as const; + +type SearchToolResult = SearchResponse & { + errors?: Array<{ query: string; error: string }>; +}; + +const searchOutput = { + schema: { + type: "object", + additionalProperties: false, + properties: { + provider: { type: "string", required: true }, + results: { + type: "array", + required: true, + items: { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string", required: true }, + link: { type: "string", required: true }, + snippet: { type: "string", required: true }, + position: { type: "integer", required: true }, + }, + }, + }, + errors: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + query: { type: "string", required: true }, + error: { type: "string", required: true }, + }, + }, + }, + }, + } as const, + render: (_args: unknown, value: SearchToolResult) => [ + { + type: "text" as const, + text: boundedToolText( + formatSearch(value), + "Use a narrower search query to reduce results.", + ), + }, + ], +}; + const fetchParameters = { url: { type: "string", @@ -452,6 +522,193 @@ function normalizeDocs(input: unknown): DocsToolInput { return normalizeDocsToolInput(input); } +function requireSearchQueries(input: unknown): string[] { + if (!isRecord(input)) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + rejectUnknownFields(input, ["queries"], "web_search"); + if (!Array.isArray(input.queries)) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + if (input.queries.length < 1 || input.queries.length > 4) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + + const queries: string[] = []; + for (const value of input.queries) { + if (typeof value !== "string" || value.trim().length === 0) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + const query = value.trim(); + if (!queries.includes(query)) queries.push(query); + } + if (queries.length < 1 || queries.length > 4) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + return queries; +} + +function searchCredential( + provider: SearchProviderName, +): { ref: CredentialRef; field: keyof SearchCredentials } | undefined { + switch (provider) { + case "exa": + return { + ref: credentialRef(EXA_CREDENTIAL_REF), + field: "exaApiKey", + }; + case "brave": + return { + ref: credentialRef(BRAVE_CREDENTIAL_REF), + field: "braveApiKey", + }; + case "deepseek": + return { + ref: credentialRef(DEEPSEEK_CREDENTIAL_REF), + field: "deepseekApiKey", + }; + case "kepos-bridge": + return undefined; + } +} + +function searchCredentials( + field: keyof SearchCredentials, + value: string, +): SearchCredentials { + switch (field) { + case "exaApiKey": + return { exaApiKey: value }; + case "braveApiKey": + return { braveApiKey: value }; + case "deepseekApiKey": + return { deepseekApiKey: value }; + } +} + +function errorMessage(reason: unknown, secret?: string): string { + const message = reason instanceof Error ? reason.message : String(reason); + return secret !== undefined && message.includes(secret) + ? "search request failed" + : message || "web search failed"; +} + +async function executeSearchQuery( + query: string, + signal: AbortSignal | undefined, + dependencies: WebToolDependencies, + operations: WebOperations, +): Promise { + if (signal?.aborted) throw new Error("Operation aborted"); + const provider = dependencies.getProvider?.() ?? "exa"; + const credential = searchCredential(provider); + let resolved: ResolvedCredential | undefined; + if (credential !== undefined) { + try { + resolved = await dependencies.credentials.resolve(credential.ref); + } catch { + throw new Error(`${provider} credential resolution failed`); + } + } + if (signal?.aborted) throw new Error("Operation aborted"); + const credentials = + credential === undefined || resolved === undefined + ? {} + : searchCredentials(credential.field, resolved.value); + const input = { + query, + provider, + credentials, + signal, + ...(provider === "kepos-bridge" + ? { keposBridgeEndpoint: dependencies.getKeposBridgeEndpoint() } + : {}), + }; + try { + return await operations.search(input); + } catch (error) { + if ( + signal?.aborted || + (error instanceof Error && error.message === "Operation aborted") + ) + throw new Error("Operation aborted"); + throw new Error(errorMessage(error, resolved?.value)); + } +} + +function mergeSearchResults(responses: SearchResponse[]): SearchResponse { + const results: SearchResponse["results"] = []; + for (let resultIndex = 0; ; resultIndex += 1) { + let added = false; + for (const response of responses) { + const result = response.results[resultIndex]; + if (result === undefined) continue; + results.push({ ...result, position: results.length + 1 }); + added = true; + } + if (!added) return { provider: responses[0]?.provider ?? "", results }; + } +} + +function formatSearch(value: SearchToolResult): string { + const lines = value.results.map( + (result) => + `${result.position}. ${result.title}\n URL: ${result.link}\n ${result.snippet}`, + ); + const failures = + value.errors === undefined || value.errors.length === 0 + ? "" + : `\n\nSearch failures:\n${value.errors.map((failure) => `- ${JSON.stringify(failure.query)}: ${failure.error}`).join("\n")}`; + if (lines.length === 0) return `No search results.${failures}`; + return `Found ${lines.length} search results (provider: ${value.provider}):\n\n${lines.join("\n\n")}${failures}`; +} + +function webSearchTool( + dependencies: WebToolDependencies, + operations: WebOperations, +): ToolDefinition { + return strictDefinition( + defineTool({ + name: "web_search", + description: + "Search the web for current facts. Provide one to four queries; repeated queries are merged and model-facing text is bounded.", + parameters: searchParameters, + output: searchOutput, + isConcurrencySafe: () => true, + async execute(args, exec) { + const queries = requireSearchQueries(args); + const settled = await Promise.allSettled( + queries.map((query) => + executeSearchQuery(query, exec.signal, dependencies, operations), + ), + ); + if (exec.signal.aborted) throw new Error("Operation aborted"); + + const responses: SearchResponse[] = []; + const errors: Array<{ query: string; error: string }> = []; + for (const [index, result] of settled.entries()) { + if (result.status === "fulfilled") responses.push(result.value); + else + errors.push({ + query: queries[index]!, + error: errorMessage(result.reason), + }); + } + if (responses.length === 0) { + throw new Error( + `web search failed for all queries:\n${errors + .map( + (failure) => + `- ${JSON.stringify(failure.query)}: ${failure.error}`, + ) + .join("\n")}`, + ); + } + const merged = mergeSearchResults(responses); + return { + ...merged, + ...(errors.length > 0 ? { errors } : {}), + } satisfies SearchToolResult; + }, + }), + ); +} + async function context7Credentials( dependencies: WebToolDependencies, ): Promise { @@ -897,9 +1154,16 @@ function webTimeTool( export function createWebToolDefinitions( dependencies: WebToolDependencies, -): readonly [ToolDefinition, ToolDefinition, ToolDefinition, ToolDefinition] { +): readonly [ + ToolDefinition, + ToolDefinition, + ToolDefinition, + ToolDefinition, + ToolDefinition, +] { const operations = dependencies.operations ?? createWebOperations(); return [ + webSearchTool(dependencies, operations), webFetchTool(dependencies, operations), webLinksTool(dependencies, operations), webDocsTool(dependencies, operations), diff --git a/packages/dsh-web/test/artifact.test.ts b/packages/dsh-web/test/artifact.test.ts index df223f5..c3d660e 100644 --- a/packages/dsh-web/test/artifact.test.ts +++ b/packages/dsh-web/test/artifact.test.ts @@ -173,6 +173,7 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { expect(packed.peerDependencies["@deepseek-ai/dsh-client-runtime"]).toBe( undefined, ); + expect(packed.peerDependencies["@deepseek-ai/dsh-web"]).toBeUndefined(); for (const [name, version] of Object.entries(packed.peerDependencies)) { if ( name.startsWith("@deepseek-ai/") && @@ -212,8 +213,7 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { ).length, ).toBeGreaterThan(10); expect(host.name).toBe("guionai-dsh-web"); - expect(host.inject).toEqual(["web", "credentials", "settings", "tools"]); - let provider: any; + expect(host.inject).toEqual(["credentials", "settings", "tools"]); const tools: any[] = []; const browser = writeFakeAgentBrowser(); const originalFetch = globalThis.fetch; @@ -259,12 +259,6 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { credentials: { resolve: async () => ({ value: "fixture-key", source: "test" }), }, - web: { - registerSearchProvider: (value: unknown) => { - provider = value; - return () => undefined; - }, - }, tools: { register: (definition: unknown) => { tools.push(definition); @@ -273,13 +267,26 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { }, effect: (execute: () => () => void) => execute(), }); + const searchTool = tools.find( + (definition) => definition.name === "web_search", + ); + if (!searchTool) + throw new Error("packed DSH artifact did not register web_search"); await expect( - provider.search({ query: "packed fixture" }), - ).resolves.toEqual({ - sources: [ - { url: "https://example.test", title: "Packed", snippet: "fixture" }, + searchTool.execute( + { queries: ["packed fixture"] }, + { signal: new AbortController().signal }, + ), + ).resolves.toMatchObject({ + provider: "Exa", + results: [ + { + link: "https://example.test", + title: "Packed", + snippet: "fixture", + position: 1, + }, ], - truncated: false, }); const fetchTool = tools.find( (definition) => definition.name === "web_fetch", @@ -348,21 +355,31 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { const patch = parse( readFileSync(join(artifactRoot, "cordis.patch.yml"), "utf8"), ) as any[]; - expect(patch.find((entry) => entry.id === "web")).toEqual({ - id: "web", - name: "@deepseek-ai/dsh-web", - config: { searchProvider: "guionai-web-search" }, - }); - expect(patch.find((entry) => entry.id === "tool-web")).toEqual({ - id: "tool-web", - disabled: true, + for (const id of [ + "web", + "web-search-deepseek", + "web-fetch-http", + "tool-web", + ]) + expect(patch.find((entry) => entry.id === id)).toEqual({ + id, + disabled: true, + }); + expect(patch.find((entry) => entry.id === "agent-presets")).toEqual({ + id: "agent-presets", + name: "@deepseek-ai/dsh-agent-presets", + config: { + includeShippedRoot: false, + includeUserRoot: true, + default: "standard", + }, }); expect( patch.find((entry) => Array.isArray(entry.insert))?.insert, ).toContainEqual({ id: "guionai-dsh-web", name: "@guionai/dsh-web", - inject: ["web", "credentials", "settings", "tools"], + inject: ["credentials", "settings", "tools"], }); }, 30_000); diff --git a/packages/dsh-web/test/client.test.ts b/packages/dsh-web/test/client.test.ts index a0c287c..d19535a 100644 --- a/packages/dsh-web/test/client.test.ts +++ b/packages/dsh-web/test/client.test.ts @@ -83,9 +83,7 @@ describe("DSH settings client credential surface", () => { }); it("shadows the host fetch view and registers dedicated views for links and docs", () => { - const registrations: Array<{ key: string; priority?: number }> = [ - { key: "web_fetch", priority: 0 }, - ]; + const registrations: Array<{ key: string; priority?: number }> = []; const fixture = fakeApi(); const ctx = { effect: (_execute: () => () => void) => () => undefined, @@ -132,9 +130,9 @@ describe("DSH settings client credential surface", () => { expect( registrations.map(({ key, priority }) => ({ key, priority })), ).toEqual([ - { key: "web_fetch", priority: 0 }, { key: SETTINGS_NAMESPACE }, - { key: "web_fetch", priority: -1 }, + { key: "web_search" }, + { key: "web_fetch" }, { key: "web_links" }, { key: "web_docs" }, ]); diff --git a/packages/dsh-web/test/package.test.ts b/packages/dsh-web/test/package.test.ts index c8f2779..e6a08ee 100644 --- a/packages/dsh-web/test/package.test.ts +++ b/packages/dsh-web/test/package.test.ts @@ -1,41 +1,26 @@ -import { Context } from "@deepseek-ai/cordis"; -import { WebRuntime } from "@deepseek-ai/dsh-web"; import { describe, expect, it } from "vitest"; -import { createWebOperations, type WebOperations } from "@guionai/web-core"; import { DEFAULT_KEPOS_BRIDGE_ENDPOINT, - SEARCH_PROVIDER_ID, type SearchProviderName, } from "../src/contract.js"; -import { createGuionSearchProvider } from "../src/provider.js"; -import { apply, SettingsSchema } from "../src/index.js"; - -function withOperations(overrides: Partial): WebOperations { - return { ...createWebOperations(), ...overrides }; -} +import { apply, inject, SettingsSchema } from "../src/index.js"; describe("DSH Web package composition", () => { - it("resolves the Kepos provider and deployed route defaults", () => { + it("keeps the live provider settings contract", () => { expect(SettingsSchema()).toMatchObject({ provider: "exa", keposBridgeEndpoint: DEFAULT_KEPOS_BRIDGE_ENDPOINT, }); expect( SettingsSchema({ - provider: "exa", + provider: "kepos-bridge", keposBridgeEndpoint: "https://bridge.example.test/route", }), ).toMatchObject({ - provider: "exa", + provider: "kepos-bridge", keposBridgeEndpoint: "https://bridge.example.test/route", }); - expect( - SettingsSchema({ - provider: "deepseek", - keposBridgeEndpoint: DEFAULT_KEPOS_BRIDGE_ENDPOINT, - }), - ).toMatchObject({ provider: "deepseek" }); expect(() => SettingsSchema({ provider: "kepos-bridge", @@ -43,46 +28,8 @@ describe("DSH Web package composition", () => { }), ).toThrow(); }); - it("works at the supported rc.1 WebRuntime provider seam for concurrent PTC queries", async () => { - const calls: string[] = []; - const root = new Context(); - await root.plugin(WebRuntime, { searchProvider: SEARCH_PROVIDER_ID }); - await root.plugin({ - inject: ["web"], - apply(ctx) { - ctx.web.registerSearchProvider( - createGuionSearchProvider({ - getProvider: () => "exa", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async () => ({ value: "test-secret", source: "file" }), - }, - operations: withOperations({ - search: async (input) => { - calls.push(input.query); - return { provider: "Exa", results: [] }; - }, - }), - }), - ); - }, - }); - const controller = new AbortController(); - await expect( - Promise.all( - ["first", "second"].map((query) => - root.web.search({ query, maxResults: 8 }, controller.signal), - ), - ), - ).resolves.toEqual([ - { sources: [], truncated: false }, - { sources: [], truncated: false }, - ]); - expect(calls).toEqual(["first", "second"]); - await root.fiber.dispose(); - }); - it("owns Kepos-only tool registrations and the settings watcher across live transitions", () => { + it("registers the complete Guion suite without an official Web service", () => { let current: { provider: SearchProviderName; keposBridgeEndpoint: string; @@ -91,7 +38,7 @@ describe("DSH Web package composition", () => { keposBridgeEndpoint: "http://fixture.test/one", }; const watchers = new Set< - (next: typeof current, prev: typeof current) => void + (next: typeof current, previous: typeof current) => void >(); const registered = new Map void>(); let effectDisposer: (() => void) | undefined; @@ -100,7 +47,7 @@ describe("DSH Web package composition", () => { register: () => ({ get: () => current, watch: ( - callback: (next: typeof current, prev: typeof current) => void, + callback: (next: typeof current, previous: typeof current) => void, ) => { watchers.add(callback); return () => watchers.delete(callback); @@ -108,7 +55,6 @@ describe("DSH Web package composition", () => { }), }, credentials: { resolve: async () => undefined }, - web: { registerSearchProvider: () => () => undefined }, tools: { register: (definition: { name: string }) => { const dispose = () => { @@ -127,13 +73,16 @@ describe("DSH Web package composition", () => { }, }; + expect(inject).toEqual(["credentials", "settings", "tools"]); apply(ctx as never); expect([...registered.keys()]).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); + const transition = (next: typeof current) => { const previous = current; current = next; @@ -144,6 +93,7 @@ describe("DSH Web package composition", () => { keposBridgeEndpoint: current.keposBridgeEndpoint, }); expect([...registered.keys()]).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", @@ -153,26 +103,17 @@ describe("DSH Web package composition", () => { "web_finance", "web_time", ]); - transition({ - provider: "kepos-bridge", - keposBridgeEndpoint: "https://fixture.test/two", - }); - expect([...registered.keys()]).toHaveLength(8); transition({ provider: "exa", keposBridgeEndpoint: current.keposBridgeEndpoint, }); expect([...registered.keys()]).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); - transition({ - provider: "kepos-bridge", - keposBridgeEndpoint: current.keposBridgeEndpoint, - }); - expect([...registered.keys()]).toHaveLength(8); effectDisposer?.(); expect(registered.size).toBe(0); expect(watchers.size).toBe(0); diff --git a/packages/dsh-web/test/provider.test.ts b/packages/dsh-web/test/provider.test.ts deleted file mode 100644 index ece317b..0000000 --- a/packages/dsh-web/test/provider.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createWebOperations, type WebOperations } from "@guionai/web-core"; - -import { - BRAVE_CREDENTIAL_REF, - DEEPSEEK_CREDENTIAL_REF, - EXA_CREDENTIAL_REF, - SEARCH_PROVIDER_ID, -} from "../src/contract.js"; -import { createGuionSearchProvider } from "../src/provider.js"; - -function withOperations(overrides: Partial): WebOperations { - return { ...createWebOperations(), ...overrides }; -} - -describe("Guion DSH search provider", () => { - it("resolves only the namespaced DeepSeek key and forwards explicit selection", async () => { - let received: unknown; - const provider = createGuionSearchProvider({ - getProvider: () => "deepseek", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async (ref) => { - expect(ref).toBe(DEEPSEEK_CREDENTIAL_REF); - return { value: "deepseek-secret", source: "file" }; - }, - }, - operations: withOperations({ - search: async (input) => { - received = input; - return { provider: "DeepSeek", results: [] }; - }, - }), - }); - - await expect(provider.search({ query: "latest" })).resolves.toEqual({ - sources: [], - truncated: false, - }); - expect(received).toEqual({ - query: "latest", - provider: "deepseek", - credentials: { deepseekApiKey: "deepseek-secret" }, - signal: undefined, - }); - }); - - it("routes each stock PTC query through the live selected provider and resolved credential", async () => { - let received: unknown; - const provider = createGuionSearchProvider({ - getProvider: () => "brave", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async (ref) => { - expect(ref).toBe(BRAVE_CREDENTIAL_REF); - return { value: "dsh-secret", source: "file" }; - }, - }, - operations: withOperations({ - search: async (input) => { - received = input; - return { - provider: "Brave", - results: [ - { - title: "One", - link: "https://example.test/one", - snippet: "First", - position: 1, - }, - ], - }; - }, - }), - }); - const controller = new AbortController(); - const results = await Promise.all( - ["--flag-like query"].map((query) => - provider.search({ query, maxResults: 8 }, controller.signal), - ), - ); - - expect(provider.id).toBe(SEARCH_PROVIDER_ID); - expect(provider.available()).toBe(true); - expect(received).toEqual({ - query: "--flag-like query", - provider: "brave", - credentials: { braveApiKey: "dsh-secret" }, - signal: controller.signal, - }); - expect(results).toEqual([ - { - sources: [ - { url: "https://example.test/one", title: "One", snippet: "First" }, - ], - truncated: false, - }, - ]); - }); - - it("uses one resolved credential per direct core call without process environment fallback or secret leaks", async () => { - const secret = "exa-secret-never-in-error"; - const provider = createGuionSearchProvider({ - getProvider: () => "exa", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async (ref) => { - expect(ref).toBe(EXA_CREDENTIAL_REF); - return { value: secret, source: "file" }; - }, - }, - operations: withOperations({ - search: async () => { - throw new Error(`transport saw ${secret}`); - }, - }), - }); - - await expect(provider.search({ query: "failed" })).rejects.toThrow( - "exa search failed", - ); - await expect(provider.search({ query: "failed" })).rejects.not.toThrow( - secret, - ); - }); - - it("rejects malformed core output before returning a DSH result", async () => { - const provider = createGuionSearchProvider({ - getProvider: () => "exa", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async () => ({ value: "secret", source: "file" }), - }, - operations: withOperations({ - search: async () => ({ - provider: "Exa", - results: [{ title: "ok", link: "", snippet: "bad", position: 1 }], - }), - }), - }); - await expect(provider.search({ query: "invalid" })).rejects.toThrow( - /link must be a non-empty string/, - ); - }); - - it("routes Kepos search without resolving credentials and reads the endpoint per call", async () => { - let endpoint = "http://fixture.test/one"; - const resolve = vi.fn(async () => { - throw new Error("must not resolve a third-party credential"); - }); - const calls: unknown[] = []; - const provider = createGuionSearchProvider({ - getProvider: () => "kepos-bridge", - getKeposBridgeEndpoint: () => endpoint, - credentials: { resolve }, - operations: withOperations({ - search: async (input) => { - calls.push(input); - return { - provider: "Kepos Bridge", - results: [ - { - title: "Bridge", - link: "https://example.test", - snippet: "result", - position: 1, - }, - ], - }; - }, - }), - }); - - await provider.search({ query: "first", maxResults: 4 }); - endpoint = "https://fixture.test/two"; - await provider.search({ query: "second", maxResults: 2 }); - expect(resolve).not.toHaveBeenCalled(); - expect(calls).toEqual([ - { - query: "first", - provider: "kepos-bridge", - credentials: {}, - signal: undefined, - maxResults: 4, - keposBridgeEndpoint: "http://fixture.test/one", - }, - { - query: "second", - provider: "kepos-bridge", - credentials: {}, - signal: undefined, - maxResults: 2, - keposBridgeEndpoint: "https://fixture.test/two", - }, - ]); - }); -}); diff --git a/packages/dsh-web/test/tools.test.ts b/packages/dsh-web/test/tools.test.ts index d5e0b71..df37e22 100644 --- a/packages/dsh-web/test/tools.test.ts +++ b/packages/dsh-web/test/tools.test.ts @@ -6,6 +6,7 @@ import { } from "@guionai/web-core"; import { + BRAVE_CREDENTIAL_REF, CONTEXT7_CREDENTIAL_REF, DEFAULT_KEPOS_BRIDGE_ENDPOINT, } from "../src/contract.js"; @@ -40,7 +41,7 @@ function dependencies( } describe("DSH direct web tools", () => { - it("registers fetch, links, docs, and Sourcegraph tools with current schemas and concurrent execution", () => { + it("registers the complete research suite with current schemas and concurrency metadata", () => { const definitions = createWebToolDefinitions(dependencies()); const registered: ToolDefinition[] = []; registerWebTools( @@ -55,65 +56,322 @@ describe("DSH direct web tools", () => { dependencies(), ); expect(definitions.map((definition) => definition.name)).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); expect(registered.map((definition) => definition.name)).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); expect([ - definitions[0]!.isConcurrencySafe?.({ url: "https://example.test" }), - definitions[1]!.isConcurrencySafe?.({ + definitions[0]!.isConcurrencySafe?.({ queries: ["latest"] }), + definitions[1]!.isConcurrencySafe?.({ url: "https://example.test" }), + definitions[2]!.isConcurrencySafe?.({ url: "https://example.test", render: "browser", waitMs: 0, }), - definitions[2]!.isConcurrencySafe?.({ + definitions[3]!.isConcurrencySafe?.({ action: "resolve", query: "react", }), - definitions[3]!.isConcurrencySafe?.({ query: "repo:guionai" }), - ]).toEqual([true, true, true, true]); + definitions[4]!.isConcurrencySafe?.({ query: "repo:guionai" }), + ]).toEqual([true, true, true, true, true]); expect((definitions[0]!.parameters as any).additionalProperties).toBe( false, ); - expect((definitions[0]!.parameters as any).properties.render.enum).toEqual([ + expect((definitions[0]!.parameters as any).properties.queries.type).toBe( + "array", + ); + expect((definitions[1]!.parameters as any).additionalProperties).toBe( + false, + ); + expect((definitions[1]!.parameters as any).properties.render.enum).toEqual([ "http", "browser", ]); - expect((definitions[0]!.parameters as any).properties.mode.enum).toEqual([ + expect((definitions[1]!.parameters as any).properties.mode.enum).toEqual([ "auto", "full", "tree", ]); - expect((definitions[0]!.parameters as any).properties.waitMs.type).toBe( + expect((definitions[1]!.parameters as any).properties.waitMs.type).toBe( "integer", ); - expect((definitions[1]!.parameters as any).properties.limit.default).toBe( + expect((definitions[2]!.parameters as any).properties.limit.default).toBe( 100, ); - expect((definitions[1]!.parameters as any).properties.render.enum).toEqual([ + expect((definitions[2]!.parameters as any).properties.render.enum).toEqual([ "http", "browser", ]); - expect((definitions[2]!.parameters as any).properties.action.enum).toEqual([ + expect((definitions[3]!.parameters as any).properties.action.enum).toEqual([ "resolve", "fetch", ]); - expect(Object.keys((definitions[3]!.parameters as any).properties)).toEqual( + expect(Object.keys((definitions[4]!.parameters as any).properties)).toEqual( ["query", "count", "context", "timeout"], ); }); + it("trims and deduplicates queries, runs them concurrently, and keeps partial results", async () => { + const pending = new Map< + string, + { + promise: Promise<{ + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }>; + resolve: (value: { + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }) => void; + reject: (reason?: unknown) => void; + } + >(); + const deferred = () => { + let resolve!: (value: { + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise<{ + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }>((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; + }; + const calls: Array<{ + query: string; + provider: unknown; + signal?: AbortSignal; + }> = []; + const search = vi.fn((input: any) => { + calls.push({ + query: input.query, + provider: input.provider, + signal: input.signal, + }); + const value = deferred(); + pending.set(input.query, value); + return value.promise; + }); + const controller = new AbortController(); + const [tool] = createWebToolDefinitions( + dependencies({ + getProvider: () => "brave", + credentials: { + resolve: async (ref) => { + expect(ref).toBe(BRAVE_CREDENTIAL_REF); + return { value: "brave-secret", source: "test" }; + }, + }, + operations: operations({ search }), + }), + ); + const resultPromise = call( + tool!, + { queries: [" first ", "second", " first", "third"] }, + controller.signal, + ); + await Promise.resolve(); + expect(calls.map(({ query }) => query)).toEqual([ + "first", + "second", + "third", + ]); + expect( + calls.every( + ({ provider, signal }) => + provider === "brave" && signal === controller.signal, + ), + ).toBe(true); + pending.get("second")!.resolve({ + provider: "Brave", + results: [ + { title: "B1", link: "https://b/1", snippet: "b", position: 1 }, + ], + }); + pending.get("third")!.reject(new Error("fixture unavailable")); + pending.get("first")!.resolve({ + provider: "Brave", + results: [ + { title: "A1", link: "https://a/1", snippet: "a", position: 1 }, + { title: "A2", link: "https://a/2", snippet: "a", position: 2 }, + ], + }); + await expect(resultPromise).resolves.toMatchObject({ + provider: "Brave", + results: [ + { title: "A1", position: 1 }, + { title: "B1", position: 2 }, + { title: "A2", position: 3 }, + ], + errors: [{ query: "third", error: "fixture unavailable" }], + }); + }); + + it("rejects invalid search arguments and accepts trimmed non-empty queries", async () => { + const search = vi.fn(async () => ({ + provider: "Exa" as const, + results: [], + })); + const [tool] = createWebToolDefinitions( + dependencies({ operations: operations({ search }) }), + ); + + await expect( + call(tool!, { queries: [" \t trimmed query \n"] }), + ).resolves.toEqual({ provider: "Exa", results: [] }); + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ query: "trimmed query" }), + ); + search.mockClear(); + + for (const queries of [ + [], + ["one", "two", "three", "four", "five"], + [" "], + ["one", "\t"], + ]) { + await expect(call(tool!, { queries })).rejects.toThrow( + "queries must be an array of 1 to 4 non-empty strings", + ); + } + await expect( + call(tool!, { queries: ["one"], unexpected: true }), + ).rejects.toThrow(/does not accept field unexpected/); + expect(search).not.toHaveBeenCalled(); + }); + + it("forwards search cancellation and reports the final aborted outcome", async () => { + const controller = new AbortController(); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let receivedSignal: AbortSignal | undefined; + const search = vi.fn( + (input: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + receivedSignal = input.signal; + resolveStarted(); + input.signal?.addEventListener( + "abort", + () => reject(new Error("Operation aborted")), + { once: true }, + ); + }), + ); + const [tool] = createWebToolDefinitions( + dependencies({ operations: operations({ search }) }), + ); + + const pending = call(tool!, { queries: ["cancel me"] }, controller.signal); + await started; + expect(receivedSignal).toBe(controller.signal); + controller.abort(); + await expect(pending).rejects.toThrow("Operation aborted"); + }); + + it("bounds model-facing search rendering", () => { + const [tool] = createWebToolDefinitions(dependencies()); + const marker = "search-render-tail-marker"; + const blocks = tool!.output.render( + { queries: ["large"] }, + { + provider: "Exa", + results: [ + { + title: "Large result", + link: "https://example.test/large", + snippet: `${"x".repeat(60_000)}${marker}`, + position: 1, + }, + ], + }, + ); + const text = blocks[0]?.type === "text" ? blocks[0].text : ""; + + expect(text).toContain("[Truncated:"); + expect(text).not.toContain(marker); + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(50 * 1024); + }); + + it("reports every failure when no search query succeeds and reads live provider credentials", async () => { + let provider: "exa" | "brave" = "exa"; + const refs: string[] = []; + const search = vi.fn(async () => { + throw new Error("provider unavailable"); + }); + const [tool] = createWebToolDefinitions( + dependencies({ + getProvider: () => provider, + credentials: { + resolve: async (ref) => { + refs.push(ref); + return { value: `${provider}-secret`, source: "test" }; + }, + }, + operations: operations({ search }), + }), + ); + await expect(call(tool!, { queries: ["one", "two"] })).rejects.toThrow( + 'web search failed for all queries:\n- "one": provider unavailable\n- "two": provider unavailable', + ); + provider = "brave"; + await expect(call(tool!, { queries: ["three"] })).rejects.toThrow( + "provider unavailable", + ); + expect(refs).toEqual([ + "GUIONAI_DSH_WEB_EXA_API_KEY", + "GUIONAI_DSH_WEB_EXA_API_KEY", + BRAVE_CREDENTIAL_REF, + ]); + expect( + search.mock.calls.map( + (call) => ((call as unknown as unknown[])[0] as any).credentials, + ), + ).toEqual([ + { exaApiKey: "exa-secret" }, + { exaApiKey: "exa-secret" }, + { braveApiKey: "brave-secret" }, + ]); + }); + it("calls the bundled operations once with current direct inputs and caller cancellation", async () => { const calls: unknown[] = []; const controller = new AbortController(); - const [fetch, links, docs, sgraph] = createWebToolDefinitions( + const [, fetch, links, docs, sgraph] = createWebToolDefinitions( dependencies({ operations: operations({ fetch: async (input, abortSignal) => { @@ -263,7 +521,7 @@ describe("DSH direct web tools", () => { }); it("preserves shared fetch validation, cancellation, and structured renderer failures", async () => { - const fetch = createWebToolDefinitions(dependencies())[0]!; + const fetch = createWebToolDefinitions(dependencies())[1]!; await expect( call(fetch, { url: "https://example.test", @@ -330,7 +588,7 @@ describe("DSH direct web tools", () => { }), }), }), - )[0]!; + )[1]!; const pending = call( cancel, { url: "https://example.test", render: "http" }, @@ -348,7 +606,7 @@ describe("DSH direct web tools", () => { }, }), }), - )[0]!; + )[1]!; await expect( call(retryDefinition, { url: "https://example.test", @@ -370,7 +628,7 @@ describe("DSH direct web tools", () => { }, }), }), - )[0]!; + )[1]!; await expect( call(allowlistDefinition, { url: "https://example.test", @@ -404,7 +662,7 @@ describe("DSH direct web tools", () => { }, }), }), - )[2]!; + )[3]!; await expect( call(docs, { action: "fetch", library_id: "/react" }), ).rejects.toThrow("web docs fetch failed"); @@ -417,7 +675,7 @@ describe("DSH direct web tools", () => { const fetch = vi.fn(); const links = vi.fn(); const docsResolve = vi.fn(); - const [fetchTool, linksTool, docsTool, sgraphTool] = + const [, fetchTool, linksTool, docsTool, sgraphTool] = createWebToolDefinitions( dependencies({ operations: operations({ fetch, links, docsResolve }) }), ); @@ -631,7 +889,7 @@ describe("DSH direct web tools", () => { content: "fixture", truncated: false, })); - const [fetchTool] = createWebToolDefinitions( + const [, fetchTool] = createWebToolDefinitions( dependencies({ operations: operations({ fetch }) }), ); await expect(call(fetchTool!, { url: " " })).resolves.toMatchObject({ diff --git a/packages/dsh-web/tsup.config.ts b/packages/dsh-web/tsup.config.ts index 2c48633..800e26e 100644 --- a/packages/dsh-web/tsup.config.ts +++ b/packages/dsh-web/tsup.config.ts @@ -16,7 +16,6 @@ const dshExternals = [ "@deepseek-ai/dsh-credentials", "@deepseek-ai/dsh-settings", "@deepseek-ai/dsh-tools", - "@deepseek-ai/dsh-web", "@deepseek-ai/schemastery", ]; diff --git a/packages/web/src/dsh.ts b/packages/web/src/dsh.ts new file mode 100644 index 0000000..c37d850 --- /dev/null +++ b/packages/web/src/dsh.ts @@ -0,0 +1,964 @@ +import { createHash } from "node:crypto"; +import { + cp, + lstat, + mkdtemp, + mkdir, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createRequire as createNodeRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { parseDocument, type Document } from "yaml"; + +export const SUPPORTED_DSH_VERSION = "0.1.2-rc.1" as const; +export const OFFICIAL_PRESET_PACKAGE = + "@deepseek-ai/dsh-agent-presets" as const; +export const MANAGED_PRESET_IDS = [ + "standard", + "ptc", + "cordis", + "minimal", +] as const; +export type ManagedPresetId = (typeof MANAGED_PRESET_IDS)[number]; + +/** The file which establishes that a preset directory is replaceable by Guion. */ +export const MANAGED_MARKER_FILE = ".guion-managed.json" as const; +const MANAGED_MARKER_VERSION = 1 as const; +const MANAGED_BY = "@guionai/web" as const; +const USER_PRESET_DIRECTORY = ".agent-presets" as const; + +/** + * Explicit filesystem seams used by tests and by callers embedding the CLI. + * When omitted, sync and doctor discover the installed `dsh` executable and + * use the same DSH_HOME precedence as the official harness. + */ +export interface DshPathOverrides { + dshExecutable?: string; + executable?: string; + dshHome?: string; + home?: string; + sourcePackageRoot?: string; + packageRoot?: string; + sourcePresetRoot?: string; + sourceRoot?: string; + sourcePackageVersion?: string; + sourceVersion?: string; + userPresetRoot?: string; + environment?: NodeJS.ProcessEnv; +} + +export interface DshRuntimeInfo { + readonly dshExecutable?: string; + readonly dshHome: string; + readonly userPresetRoot: string; + readonly sourcePackageRoot: string; + readonly sourcePresetRoot: string; + readonly sourcePackageName: typeof OFFICIAL_PRESET_PACKAGE; + readonly sourcePackageVersion: typeof SUPPORTED_DSH_VERSION; + readonly sourceIdentity: string; +} + +export interface DshSyncResult { + readonly runtime: DshRuntimeInfo; + readonly ids: readonly ManagedPresetId[]; + readonly replaced: readonly ManagedPresetId[]; + readonly created: readonly ManagedPresetId[]; +} + +export interface DshDoctorPreset { + readonly id: ManagedPresetId; + readonly status: "ok" | "missing" | "incomplete" | "conflict" | "stale"; + readonly detail?: string; +} + +export interface DshDoctorReport { + readonly ok: boolean; + readonly runtime?: DshRuntimeInfo; + readonly userPresetRoot?: string; + readonly presets: readonly DshDoctorPreset[]; + readonly issues: readonly string[]; +} + +export class DshPresetError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "DshPresetError"; + this.code = code; + } +} + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function option( + options: DshPathOverrides, + ...names: readonly string[] +): string | undefined { + for (const name of names) { + const value = (options as UnknownRecord)[name]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return undefined; +} + +function expandHomePath(value: string): string { + if (value === "~") return homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) + return join(homedir(), value.slice(2)); + return value; +} + +function resolveHome(options: DshPathOverrides): string { + const environment = options.environment ?? process.env; + const configured = option(options, "dshHome", "home"); + const fromEnvironment = environment.DSH_HOME; + return resolve( + expandHomePath( + configured ?? + (typeof fromEnvironment === "string" && fromEnvironment.trim() !== "" + ? fromEnvironment + : join(homedir(), ".dsh")), + ), + ); +} + +function findDshExecutables( + environment: NodeJS.ProcessEnv = process.env, +): string[] { + try { + const output = execFileSync("which", ["-a", "dsh"], { + encoding: "utf8", + env: { ...process.env, ...environment }, + stdio: ["ignore", "pipe", "ignore"], + }); + return [ + ...new Set( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ), + ]; + } catch { + return []; + } +} + +async function canonicalPath(path: string): Promise { + try { + return await (await import("node:fs/promises")).realpath(path); + } catch { + return resolve(path); + } +} + +async function packageRootForExecutable(executable: string): Promise { + let resolvedExecutable = await canonicalPath(executable); + // npm and pnpm expose `dsh` as a generated POSIX wrapper rather than a + // symlink. Read the wrapper's absolute Node entry so discovery follows the + // runtime actually selected by PATH. + try { + const launcher = await readFile(resolvedExecutable, "utf8"); + const suffix = "/node_modules/@deepseek-ai/dsh/lib/bin.js"; + const suffixAt = launcher.indexOf(suffix); + if (suffixAt >= 0) { + let tokenStart = suffixAt; + while (tokenStart > 0 && !/\s|["'`]/.test(launcher[tokenStart - 1]!)) + tokenStart -= 1; + const token = launcher.slice(tokenStart, suffixAt + suffix.length); + resolvedExecutable = await canonicalPath( + token.startsWith("/") + ? token + : join( + dirname(resolvedExecutable), + token.replace(/^\$basedir(?:_win)?\//, ""), + ), + ); + } + } catch { + // A direct JavaScript entry is handled by the normal ancestor walk. + } + let current = dirname(resolvedExecutable); + while (true) { + try { + const manifest = JSON.parse( + await readFile(join(current, "package.json"), "utf8"), + ) as UnknownRecord; + if (manifest.name === "@deepseek-ai/dsh") return current; + } catch { + // Keep walking until the filesystem root. + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + throw new DshPresetError( + "dsh-runtime-unresolved", + `could not resolve the installed DSH runtime package from ${executable}`, + ); +} + +async function officialPresetPackageRoot(runtimeRoot: string): Promise { + const candidates = new Set(); + const runtimeEntry = join(runtimeRoot, "lib", "bin.js"); + try { + const require = createNodeRequire(pathToFileURL(runtimeEntry)); + candidates.add( + dirname(require.resolve(`${OFFICIAL_PRESET_PACKAGE}/package.json`)), + ); + } catch { + // The runtime may be installed with a strict package manager layout. The + // ancestor walk below handles the package-level node_modules roots. + } + // DSH's web application owns the agent-presets dependency in strict + // package-manager layouts (including pnpm's virtual store). Resolve from + // that sibling package as well as from the CLI entry itself. + const runtimeScope = dirname(runtimeRoot); + candidates.add(join(runtimeScope, "dsh-agent-presets")); + for (const sibling of ["dsh-web-app", "dsh-app-boot"]) { + try { + const require = createNodeRequire( + pathToFileURL(join(runtimeScope, sibling, "lib", "index.js")), + ); + candidates.add( + dirname(require.resolve(`${OFFICIAL_PRESET_PACKAGE}/package.json`)), + ); + } catch { + // Try the next package layout. + } + } + let current = runtimeRoot; + while (true) { + candidates.add( + join(current, "node_modules", "@deepseek-ai", "dsh-agent-presets"), + ); + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + const existing: string[] = []; + for (const candidate of candidates) { + const manifest = await readPackageManifest(candidate); + if (manifest?.name === OFFICIAL_PRESET_PACKAGE) + existing.push(await canonicalPath(candidate)); + } + const unique = [...new Set(existing)]; + if (unique.length === 0) + throw new DshPresetError( + "dsh-source-missing", + `could not resolve ${OFFICIAL_PRESET_PACKAGE} from the installed DSH runtime at ${runtimeRoot}`, + ); + if (unique.length > 1) + throw new DshPresetError( + "dsh-source-ambiguous", + `found multiple installed ${OFFICIAL_PRESET_PACKAGE} packages (${unique.join(", ")}); provide one explicit source package path`, + ); + return unique[0]!; +} + +async function packageRootForPresetPath(presetRoot: string): Promise { + let current = resolve(presetRoot); + try { + const directory = (await stat(current)).isDirectory() + ? current + : dirname(current); + current = directory; + } catch { + current = dirname(current); + } + while (true) { + try { + const manifest = JSON.parse( + await readFile(join(current, "package.json"), "utf8"), + ) as UnknownRecord; + if (manifest.name === OFFICIAL_PRESET_PACKAGE) return current; + } catch { + // Keep walking until the filesystem root. + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return resolve(presetRoot, ".."); +} + +async function readPackageManifest( + packageRoot: string, +): Promise { + try { + return JSON.parse( + await readFile(join(packageRoot, "package.json"), "utf8"), + ) as UnknownRecord; + } catch { + return undefined; + } +} + +async function discoverPresetPackage(options: DshPathOverrides): Promise<{ + packageRoot: string; + presetRoot: string; + executable?: string; + manifest?: UnknownRecord; +}> { + const explicitPackageRoot = option( + options, + "sourcePackageRoot", + "packageRoot", + ); + const explicitPresetRoot = option(options, "sourcePresetRoot", "sourceRoot"); + let executable = option(options, "dshExecutable", "executable"); + let packageRoot: string | undefined = explicitPackageRoot; + + if (packageRoot === undefined && explicitPresetRoot !== undefined) + packageRoot = await packageRootForPresetPath(explicitPresetRoot); + + if (packageRoot === undefined) { + const candidates = + executable === undefined + ? findDshExecutables(options.environment) + : [executable]; + if (candidates.length === 0) + throw new DshPresetError( + "dsh-runtime-missing", + "could not find the installed `dsh` executable; install DSH 0.1.2-rc.1 or provide a test-owned runtime path", + ); + const canonicalCandidates = [ + ...new Set(await Promise.all(candidates.map(canonicalPath))), + ]; + if (canonicalCandidates.length !== 1) + throw new DshPresetError( + "dsh-runtime-ambiguous", + `found multiple installed dsh executables (${canonicalCandidates.join(", ")}); provide one explicit runtime path`, + ); + executable = candidates[0]; + const runtimeRoot = await packageRootForExecutable(canonicalCandidates[0]!); + packageRoot = await officialPresetPackageRoot(runtimeRoot); + } + + packageRoot = resolve(packageRoot); + const manifest = await readPackageManifest(packageRoot); + const packageName = manifest?.name; + if (manifest !== undefined && packageName !== OFFICIAL_PRESET_PACKAGE) + throw new DshPresetError( + "dsh-source-invalid", + `source package at ${packageRoot} is ${String(packageName ?? "unnamed")}, expected ${OFFICIAL_PRESET_PACKAGE}`, + ); + const presetRoot = resolve( + explicitPresetRoot ?? join(packageRoot, "presets"), + ); + return { packageRoot, presetRoot, executable, manifest }; +} + +async function listFiles(root: string, prefix = ""): Promise { + const entries = await readdir(join(root, prefix), { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const next = join(prefix, entry.name); + if (entry.isSymbolicLink()) + throw new DshPresetError( + "dsh-source-invalid", + `source tree contains a symbolic link: ${next}`, + ); + if (entry.isDirectory()) files.push(...(await listFiles(root, next))); + else if (entry.isFile()) files.push(next); + else + throw new DshPresetError( + "dsh-source-invalid", + `source tree contains a special file: ${next}`, + ); + } + return files; +} + +async function sourceIdentity(root: string, version: string): Promise { + const hash = createHash("sha256"); + hash.update(`${OFFICIAL_PRESET_PACKAGE}\0${version}\0`); + for (const file of await listFiles(root)) { + hash.update(file); + hash.update("\0"); + hash.update(await readFile(join(root, file))); + hash.update("\0"); + } + return `sha256:${hash.digest("hex")}`; +} + +function parseYaml( + source: string, + filename: string, +): { + document: Document; + value: unknown; +} { + const document = parseDocument(source, { + keepSourceTokens: true, + logLevel: "silent", + }); + if (document.errors.length > 0) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} is not valid YAML: ${document.errors[0]!.message}`, + ); + try { + return { document, value: document.toJS() }; + } catch (error) { + throw new DshPresetError( + "dsh-source-invalid", + `${filename} could not be decoded: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function rowId(row: unknown): string | undefined { + return isRecord(row) && typeof row.id === "string" ? row.id : undefined; +} + +function validateComposition( + source: string, + filename: string, + expectedWebRow: boolean, +): { document: Document; rows: unknown[]; webNode?: unknown } { + const parsed = parseYaml(source, filename); + const contents = parsed.document.contents as { items?: unknown[] } | null; + if (!Array.isArray(parsed.value) || !Array.isArray(contents?.items)) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} must contain a top-level list of composition rows`, + ); + const rows = parsed.value; + const items = contents.items; + for (const [index, row] of rows.entries()) { + if ( + !isRecord(row) || + typeof row.id !== "string" || + typeof row.name !== "string" + ) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} row ${index + 1} must contain string id and name fields`, + ); + } + const webIndexes = rows + .map((row, index) => (rowId(row) === "tool-web" ? index : -1)) + .filter((index) => index >= 0); + if (expectedWebRow) { + if (webIndexes.length !== 1) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} must contain exactly one top-level tool-web row`, + ); + const row = rows[webIndexes[0]!]!; + const rowKeys = isRecord(row) ? Object.keys(row) : []; + if ( + !isRecord(row) || + rowKeys.length !== 3 || + !rowKeys.every((key) => ["id", "name", "config"].includes(key)) || + row.name !== "@deepseek-ai/dsh-tool-web" || + row.disabled === true || + !isRecord(row.config) || + Object.keys(row.config).length !== 2 || + row.config.fetch !== true || + row.config.searchTimeoutMs !== 60_000 + ) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} tool-web row does not match the supported DSH 0.1.2-rc.1 shape`, + ); + } else if (webIndexes.length !== 0) { + throw new DshPresetError( + "dsh-source-invalid", + `${filename} minimal preset must omit the top-level tool-web row`, + ); + } + return { + document: parsed.document, + rows, + webNode: webIndexes.length === 1 ? items[webIndexes[0]!] : undefined, + }; +} + +async function validateSourceTree( + sourceRoot: string, + id: ManagedPresetId, + removeWeb: boolean, +): Promise<{ + composition: string; + metadata: string; + transformedComposition: string; +}> { + const root = join(sourceRoot, id); + let rootStat; + try { + rootStat = await lstat(root); + } catch { + throw new DshPresetError( + "dsh-source-invalid", + `official preset tree is missing: ${root}`, + ); + } + if (!rootStat.isDirectory()) + throw new DshPresetError( + "dsh-source-invalid", + `official preset path is not a directory: ${root}`, + ); + const compositionPath = join(root, "agent.cordis.yml"); + const metadataPath = join(root, "preset.yml"); + const composition = await readFile(compositionPath, "utf8").catch(() => { + throw new DshPresetError( + "dsh-source-invalid", + `official preset is missing ${id}/agent.cordis.yml`, + ); + }); + const metadata = await readFile(metadataPath, "utf8").catch(() => { + throw new DshPresetError( + "dsh-source-invalid", + `official preset is missing ${id}/preset.yml`, + ); + }); + const parsed = validateComposition( + composition, + `${id}/agent.cordis.yml`, + removeWeb, + ); + const metadataParsed = parseYaml(metadata, `${id}/preset.yml`); + if (!isRecord(metadataParsed.value)) + throw new DshPresetError( + "dsh-source-invalid", + `${id}/preset.yml must contain a mapping`, + ); + + let transformedComposition = composition; + if (removeWeb) { + const node = parsed.webNode as + | { range?: readonly [number, number, number] } + | undefined; + if (node?.range === undefined) + throw new DshPresetError( + "dsh-source-invalid", + `${id}/agent.cordis.yml tool-web row has no source range`, + ); + let start = composition.lastIndexOf("\n", node.range[0] - 1) + 1; + let end = composition.indexOf("\n", node.range[2]); + if (end < 0) end = composition.length; + else end += 1; + transformedComposition = + composition.slice(0, start) + composition.slice(end); + } + validateComposition( + transformedComposition, + `${id}/agent.cordis.yml (managed)`, + false, + ); + return { composition, metadata, transformedComposition }; +} + +async function resolveRuntime( + options: DshPathOverrides = {}, +): Promise { + const discovered = await discoverPresetPackage(options); + const manifestVersion = discovered.manifest?.version; + const requestedVersion = option( + options, + "sourcePackageVersion", + "sourceVersion", + ); + const version = + requestedVersion ?? + (typeof manifestVersion === "string" + ? manifestVersion + : SUPPORTED_DSH_VERSION); + if (version !== SUPPORTED_DSH_VERSION) + throw new DshPresetError( + "dsh-source-unsupported", + `unsupported ${OFFICIAL_PRESET_PACKAGE} version ${version}; this integration supports exactly ${SUPPORTED_DSH_VERSION}`, + ); + if ( + !(await stat(discovered.presetRoot).catch(() => undefined))?.isDirectory() + ) + throw new DshPresetError( + "dsh-source-missing", + `could not resolve the official preset root: ${discovered.presetRoot}`, + ); + const identity = await sourceIdentity(discovered.presetRoot, version); + const dshHome = resolveHome(options); + const userPresetRoot = resolve( + option(options, "userPresetRoot") ?? join(dshHome, USER_PRESET_DIRECTORY), + ); + return { + dshExecutable: discovered.executable, + dshHome, + userPresetRoot, + sourcePackageRoot: discovered.packageRoot, + sourcePresetRoot: discovered.presetRoot, + sourcePackageName: OFFICIAL_PRESET_PACKAGE, + sourcePackageVersion: SUPPORTED_DSH_VERSION, + sourceIdentity: identity, + }; +} + +export async function resolveDshRuntime( + options: DshPathOverrides = {}, +): Promise { + return resolveRuntime(options); +} + +function markerFor( + runtime: DshRuntimeInfo, + id: ManagedPresetId, +): UnknownRecord { + return { + managedBy: MANAGED_BY, + markerVersion: MANAGED_MARKER_VERSION, + presetId: id, + sourcePackage: runtime.sourcePackageName, + sourcePackageVersion: runtime.sourcePackageVersion, + sourceIdentity: runtime.sourceIdentity, + sourcePreset: `${OFFICIAL_PRESET_PACKAGE}/presets/${id}`, + }; +} + +function isManagedMarker( + value: unknown, + id: ManagedPresetId, +): value is UnknownRecord { + return ( + isRecord(value) && + value.managedBy === MANAGED_BY && + value.markerVersion === MANAGED_MARKER_VERSION && + value.presetId === id + ); +} + +async function readMarker( + directory: string, + id: ManagedPresetId, +): Promise { + try { + const value: unknown = JSON.parse( + await readFile(join(directory, MANAGED_MARKER_FILE), "utf8"), + ); + return isManagedMarker(value, id) ? value : undefined; + } catch { + return undefined; + } +} + +async function targetKind( + root: string, + id: ManagedPresetId, +): Promise<{ + kind: "missing" | "managed" | "conflict"; + marker?: UnknownRecord; +}> { + const directory = join(root, id); + let info; + try { + info = await lstat(directory); + } catch { + return { kind: "missing" }; + } + if (!info.isDirectory()) return { kind: "conflict" }; + const marker = await readMarker(directory, id); + return marker === undefined + ? { kind: "conflict" } + : { kind: "managed", marker }; +} + +async function assertUserRootSafe(root: string): Promise { + try { + const info = await lstat(root); + if (!info.isDirectory() || info.isSymbolicLink()) + throw new DshPresetError( + "dsh-home-invalid", + `DSH user preset root is not a directory: ${root}`, + ); + } catch (error) { + if (error instanceof DshPresetError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +async function stagePreset( + stageRoot: string, + sourceRoot: string, + runtime: DshRuntimeInfo, + id: ManagedPresetId, +): Promise { + const removeWeb = id !== "minimal"; + const validation = await validateSourceTree(sourceRoot, id, removeWeb); + const sourceDirectory = join(sourceRoot, id); + const stagedDirectory = join(stageRoot, id); + await cp(sourceDirectory, stagedDirectory, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); + await writeFile( + join(stagedDirectory, "agent.cordis.yml"), + validation.transformedComposition, + "utf8", + ); + await writeFile( + join(stagedDirectory, MANAGED_MARKER_FILE), + `${JSON.stringify(markerFor(runtime, id), null, 2)}\n`, + "utf8", + ); +} + +export async function syncManagedPresets( + options: DshPathOverrides = {}, +): Promise { + const runtime = await resolveRuntime(options); + // Validate every source tree before creating the user root or staging files. + for (const id of MANAGED_PRESET_IDS) + await validateSourceTree(runtime.sourcePresetRoot, id, id !== "minimal"); + await assertUserRootSafe(runtime.userPresetRoot); + + const existing = new Map< + ManagedPresetId, + Awaited> + >(); + for (const id of MANAGED_PRESET_IDS) { + const result = await targetKind(runtime.userPresetRoot, id); + existing.set(id, result); + if (result.kind === "conflict") + throw new DshPresetError( + "dsh-preset-conflict", + `refusing to overwrite unmarked same-id preset ${join(runtime.userPresetRoot, id)}; move it or add a Guion marker explicitly`, + ); + } + + await mkdir(runtime.userPresetRoot, { recursive: true }); + const stageRoot = await mkdtemp(join(runtime.dshHome, ".guion-dsh-presets-")); + const backups: Array<{ target: string; backup: string }> = []; + const replaced: ManagedPresetId[] = []; + const created: ManagedPresetId[] = []; + try { + for (const id of MANAGED_PRESET_IDS) + await stagePreset(stageRoot, runtime.sourcePresetRoot, runtime, id); + + // Recheck all targets after staging and before the first replacement. A + // failed preflight therefore cannot leave a partially refreshed roster. + for (const id of MANAGED_PRESET_IDS) { + const result = await targetKind(runtime.userPresetRoot, id); + if (result.kind === "conflict") + throw new DshPresetError( + "dsh-preset-conflict", + `refusing to overwrite unmarked same-id preset ${join(runtime.userPresetRoot, id)}`, + ); + } + + for (const id of MANAGED_PRESET_IDS) { + const target = join(runtime.userPresetRoot, id); + const current = existing.get(id)!; + if (current.kind === "managed") { + const backup = join( + runtime.userPresetRoot, + `.guion-dsh-backup-${id}-${process.pid}-${Date.now()}`, + ); + await rename(target, backup); + backups.push({ target, backup }); + replaced.push(id); + } else created.push(id); + await rename(join(stageRoot, id), target); + } + for (const backup of backups) + await rm(backup.backup, { recursive: true, force: true }); + return { runtime, ids: MANAGED_PRESET_IDS, replaced, created }; + } catch (error) { + // Restore managed directories if a replacement fails. Unmarked user data + // is never moved by this function and is therefore never part of rollback. + for (const backup of [...backups].reverse()) { + try { + await rm(backup.target, { recursive: true, force: true }); + await rename(backup.backup, backup.target); + } catch { + // An interrupted filesystem operation remains diagnosable by doctor. + } + } + throw error; + } finally { + await rm(stageRoot, { recursive: true, force: true }); + } +} + +async function inspectManagedPreset( + runtime: DshRuntimeInfo, + id: ManagedPresetId, +): Promise { + const directory = join(runtime.userPresetRoot, id); + let info; + try { + info = await lstat(directory); + } catch { + return { + id, + status: "missing", + detail: "managed preset directory is missing", + }; + } + if (!info.isDirectory() || info.isSymbolicLink()) + return { + id, + status: "conflict", + detail: "same-id path is not a directory", + }; + let markerValue: unknown; + try { + markerValue = JSON.parse( + await readFile(join(directory, MANAGED_MARKER_FILE), "utf8"), + ); + } catch { + return { + id, + status: "conflict", + detail: `missing or invalid ${MANAGED_MARKER_FILE}`, + }; + } + if (!isManagedMarker(markerValue, id)) + return { id, status: "conflict", detail: "directory is not Guion-managed" }; + if ( + markerValue.sourcePackage !== runtime.sourcePackageName || + markerValue.sourcePackageVersion !== runtime.sourcePackageVersion || + markerValue.sourceIdentity !== runtime.sourceIdentity + ) + return { + id, + status: "stale", + detail: "managed copy was generated from a different official source", + }; + try { + const composition = await readFile( + join(directory, "agent.cordis.yml"), + "utf8", + ); + const metadata = await readFile(join(directory, "preset.yml"), "utf8"); + validateComposition(composition, `${id}/agent.cordis.yml`, false); + const metadataParsed = parseYaml(metadata, `${id}/preset.yml`); + if (!isRecord(metadataParsed.value)) + throw new Error("metadata is not a mapping"); + } catch (error) { + return { + id, + status: "incomplete", + detail: + error instanceof Error ? error.message : "managed files are incomplete", + }; + } + return { id, status: "ok" }; +} + +export async function inspectManagedPresets( + options: DshPathOverrides = {}, +): Promise { + let runtime: DshRuntimeInfo; + try { + runtime = await resolveRuntime(options); + } catch (error) { + const home = resolveHome(options); + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + userPresetRoot: resolve( + option(options, "userPresetRoot") ?? join(home, USER_PRESET_DIRECTORY), + ), + presets: [], + issues: [message], + }; + } + const presets: DshDoctorPreset[] = []; + const issues: string[] = []; + for (const id of MANAGED_PRESET_IDS) { + try { + await validateSourceTree(runtime.sourcePresetRoot, id, id !== "minimal"); + } catch (error) { + issues.push(error instanceof Error ? error.message : String(error)); + } + } + await assertUserRootSafe(runtime.userPresetRoot).catch((error) => { + issues.push(error instanceof Error ? error.message : String(error)); + }); + if (issues.length === 0) { + for (const id of MANAGED_PRESET_IDS) { + const preset = await inspectManagedPreset(runtime, id); + presets.push(preset); + if (preset.status !== "ok") + issues.push(`${id}: ${preset.detail ?? preset.status}`); + } + } + return { + ok: issues.length === 0, + runtime, + userPresetRoot: runtime.userPresetRoot, + presets, + issues, + }; +} + +export async function doctorManagedPresets( + options: DshPathOverrides = {}, +): Promise { + return inspectManagedPresets(options); +} + +export function formatDshDoctor(report: DshDoctorReport): string { + const lines = [`DSH doctor: ${report.ok ? "OK" : "FAILED"}`]; + if (report.runtime !== undefined) { + lines.push( + `Source: ${report.runtime.sourcePackageName}@${report.runtime.sourcePackageVersion}`, + ); + lines.push(`Source identity: ${report.runtime.sourceIdentity}`); + lines.push(`Preset root: ${report.runtime.userPresetRoot}`); + } else if (report.userPresetRoot !== undefined) + lines.push(`Preset root: ${report.userPresetRoot}`); + for (const preset of report.presets) + lines.push( + `- ${preset.id}: ${preset.status}${preset.detail ? ` — ${preset.detail}` : ""}`, + ); + for (const issue of report.issues) lines.push(`Issue: ${issue}`); + return `${lines.join("\n")}\n`; +} + +export function formatDshSync(result: DshSyncResult): string { + const action = + result.created.length === MANAGED_PRESET_IDS.length + ? "created all four presets" + : [ + result.created.length > 0 + ? `created ${result.created.join(", ")}` + : "", + result.replaced.length > 0 + ? `refreshed ${result.replaced.join(", ")}` + : "", + ] + .filter(Boolean) + .join("; "); + return `DSH managed presets ${action} from ${result.runtime.sourcePackageName}@${result.runtime.sourcePackageVersion}.\nPreset root: ${result.runtime.userPresetRoot}\n`; +} + +export function sourcePresetPath( + runtime: DshRuntimeInfo, + id: ManagedPresetId, +): string { + return join(runtime.sourcePresetRoot, id); +} + +export function managedPresetPath( + runtime: DshRuntimeInfo, + id: ManagedPresetId, +): string { + return join(runtime.userPresetRoot, id); +} diff --git a/packages/web/src/program.ts b/packages/web/src/program.ts index 423e84c..f182886 100644 --- a/packages/web/src/program.ts +++ b/packages/web/src/program.ts @@ -1,6 +1,13 @@ import { Command } from "commander"; import { createMcpCommand } from "./mcp.js"; +import { + doctorManagedPresets, + formatDshDoctor, + formatDshSync, + syncManagedPresets, + type DshPathOverrides, +} from "./dsh.js"; import { parseHttpPort, startHttpServer } from "./serve.js"; import { DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT } from "./serve.js"; import { WEB_PACKAGE_VERSION } from "./version.js"; @@ -19,6 +26,8 @@ export type ProgramDependencies = { operations: WebOperations; credentials: () => WebCredentials; writeOut?: (text: string) => void; + /** Optional explicit DSH paths used by tests and embedded callers. */ + dsh?: DshPathOverrides; }; export function createProgram(dependencies: ProgramDependencies): Command { @@ -36,11 +45,38 @@ export function createProgram(dependencies: ProgramDependencies): Command { .addCommand(createLinksCommand(dependencies)) .addCommand(createDocsCommand(dependencies)) .addCommand(createSGraphCommand(dependencies)) + .addCommand(createDshCommand(dependencies)) .addCommand(createServeCommand(dependencies)) .addCommand(createMcpCommand(dependencies)); return program; } +function createDshCommand(dependencies: ProgramDependencies): Command { + const writeOut = + dependencies.writeOut ?? ((text: string) => process.stdout.write(text)); + const options = dependencies.dsh ?? {}; + return new Command("dsh") + .description("Synchronize and diagnose Guion-managed DSH presets") + .addCommand( + new Command("sync") + .description("Create or refresh compatible stock-equivalent presets") + .action(async () => { + const result = await syncManagedPresets(options); + writeOut(formatDshSync(result)); + }), + ) + .addCommand( + new Command("doctor") + .description("Check managed preset health without changing files") + .action(async () => { + const report = await doctorManagedPresets(options); + writeOut(formatDshDoctor(report)); + if (!report.ok) + throw new Error("DSH managed preset doctor found problems"); + }), + ); +} + function createServeCommand(dependencies: ProgramDependencies): Command { const writeOut = dependencies.writeOut ?? ((text: string) => process.stdout.write(text)); diff --git a/packages/web/src/runner.ts b/packages/web/src/runner.ts index d720082..7cc7dcc 100644 --- a/packages/web/src/runner.ts +++ b/packages/web/src/runner.ts @@ -1,4 +1,5 @@ import { createProgram } from "./program.js"; +import type { DshPathOverrides } from "./dsh.js"; import { FetchCapabilityError, type WebCredentials, @@ -8,6 +9,8 @@ import { export type CliDependencies = { operations: WebOperations; credentials: () => WebCredentials; + /** Optional test-owned DSH runtime/home seam for `web dsh` commands. */ + dsh?: DshPathOverrides; }; export type CliOutput = { diff --git a/packages/web/test/dsh.test.ts b/packages/web/test/dsh.test.ts new file mode 100644 index 0000000..54078a4 --- /dev/null +++ b/packages/web/test/dsh.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from "vitest"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "yaml"; + +import { createWebOperations } from "@guionai/web-core"; +import { runCli } from "../src/runner.js"; +import { + DshPresetError, + MANAGED_MARKER_FILE, + MANAGED_PRESET_IDS, + formatDshDoctor, + inspectManagedPresets, + syncManagedPresets, + type DshPathOverrides, +} from "../src/dsh.js"; + +type Fixture = { + root: string; + sourcePackageRoot: string; + sourcePresetRoot: string; + dshHome: string; + options: DshPathOverrides; +}; + +const sourceComposition = (id: string, includeWeb: boolean): string => + [ + `# ${id} fixture composition`, + "- id: persona", + " name: fixture:persona", + ...(includeWeb + ? [ + "", + "# The source row is removed structurally by sync.", + "- id: tool-web", + " name: '@deepseek-ai/dsh-tool-web'", + " config:", + " fetch: true", + " searchTimeoutMs: 60000", + ] + : []), + "", + "- id: tail", + " name: fixture:tail", + "", + ].join("\n"); + +async function fixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "guionai-web-dsh-test-")); + const sourcePackageRoot = join( + root, + "node_modules", + "@deepseek-ai", + "dsh-agent-presets", + ); + const sourcePresetRoot = join(sourcePackageRoot, "presets"); + const dshHome = join(root, "dsh-home"); + await mkdir(sourcePresetRoot, { recursive: true }); + await writeFile( + join(sourcePackageRoot, "package.json"), + JSON.stringify({ + name: "@deepseek-ai/dsh-agent-presets", + version: "0.1.2-rc.1", + }) + "\n", + ); + for (const id of MANAGED_PRESET_IDS) { + const directory = join(sourcePresetRoot, id); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "agent.cordis.yml"), + sourceComposition(id, id !== "minimal"), + ); + await writeFile( + join(directory, "preset.yml"), + `name: Fixture ${id}\ndescription: A test preset\norder: 1\n`, + ); + await writeFile(join(directory, "prompt.md"), `prompt for ${id}\n`); + } + return { + root, + sourcePackageRoot, + sourcePresetRoot, + dshHome, + options: { sourcePackageRoot, dshHome }, + }; +} + +async function dispose(value: Fixture): Promise { + await rm(value.root, { recursive: true, force: true }); +} + +async function readPreset( + fixtureValue: Fixture, + id: string, +): Promise<{ + composition: string; + marker: Record; +}> { + const directory = join(fixtureValue.dshHome, ".agent-presets", id); + return { + composition: await readFile(join(directory, "agent.cordis.yml"), "utf8"), + marker: JSON.parse( + await readFile(join(directory, MANAGED_MARKER_FILE), "utf8"), + ) as Record, + }; +} + +describe("managed DSH presets", () => { + it("derives all four same-id presets and removes only official tool-web rows", async () => { + const value = await fixture(); + try { + const result = await syncManagedPresets(value.options); + expect(result.created).toEqual([...MANAGED_PRESET_IDS]); + expect(result.replaced).toEqual([]); + for (const id of MANAGED_PRESET_IDS) { + const preset = await readPreset(value, id); + const rows = parse(preset.composition) as Array< + Record + >; + expect(rows.map((row) => row.id)).toEqual(["persona", "tail"]); + expect(preset.marker).toMatchObject({ + managedBy: "@guionai/web", + markerVersion: 1, + presetId: id, + sourcePackage: "@deepseek-ai/dsh-agent-presets", + sourcePackageVersion: "0.1.2-rc.1", + sourcePreset: `@deepseek-ai/dsh-agent-presets/presets/${id}`, + }); + expect(typeof preset.marker.sourceIdentity).toBe("string"); + } + const minimal = await readPreset(value, "minimal"); + expect(minimal.composition).toContain("# minimal fixture composition"); + expect(minimal.composition).not.toContain("tool-web"); + } finally { + await dispose(value); + } + }); + + it("refreshes marked copies idempotently while retaining ordinary user presets", async () => { + const value = await fixture(); + try { + await syncManagedPresets(value.options); + const userRoot = join(value.dshHome, ".agent-presets"); + await mkdir(join(userRoot, "yuki"), { recursive: true }); + await writeFile( + join(userRoot, "yuki", "agent.cordis.yml"), + "- id: yuki\n name: fixture:yuki\n", + ); + const first = await readPreset(value, "standard"); + const second = await syncManagedPresets(value.options); + expect(second.replaced).toEqual([...MANAGED_PRESET_IDS]); + expect(second.created).toEqual([]); + expect(await readPreset(value, "standard")).toEqual(first); + expect( + await stat(join(userRoot, "yuki", "agent.cordis.yml")), + ).toBeDefined(); + expect((await readdir(userRoot)).sort()).toEqual( + [...MANAGED_PRESET_IDS, "yuki"].sort(), + ); + } finally { + await dispose(value); + } + }); + + it("preflights unmarked same-id conflicts without mutating any preset", async () => { + const value = await fixture(); + try { + const userRoot = join(value.dshHome, ".agent-presets"); + await mkdir(join(userRoot, "standard"), { recursive: true }); + await writeFile( + join(userRoot, "standard", "agent.cordis.yml"), + "user-owned standard\n", + ); + await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ + code: "dsh-preset-conflict", + }); + expect( + await readFile(join(userRoot, "standard", "agent.cordis.yml"), "utf8"), + ).toBe("user-owned standard\n"); + expect(await readdir(userRoot)).toEqual(["standard"]); + } finally { + await dispose(value); + } + }); + + it("fails closed for unsupported versions and unexpected source rows", async () => { + const value = await fixture(); + try { + await expect( + syncManagedPresets({ ...value.options, sourcePackageVersion: "0.1.1" }), + ).rejects.toMatchObject({ code: "dsh-source-unsupported" }); + expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); + + await writeFile( + join(value.sourcePresetRoot, "standard", "agent.cordis.yml"), + sourceComposition("standard", false), + ); + await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ + code: "dsh-source-invalid", + }); + expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); + + await writeFile( + join(value.sourcePresetRoot, "standard", "agent.cordis.yml"), + sourceComposition("standard", true).replace( + " config:\n", + " extra: true\n config:\n", + ), + ); + await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ + code: "dsh-source-invalid", + }); + expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); + } finally { + await dispose(value); + } + }); + + it("doctor is read-only and distinguishes missing, incomplete, stale, and conflict outputs", async () => { + const value = await fixture(); + try { + const missing = await inspectManagedPresets(value.options); + expect(missing.ok).toBe(false); + expect(missing.presets.map((preset) => preset.status)).toEqual([ + "missing", + "missing", + "missing", + "missing", + ]); + await syncManagedPresets(value.options); + const standard = join(value.dshHome, ".agent-presets", "standard"); + await writeFile( + join(standard, "agent.cordis.yml"), + "not: a composition\n", + ); + await writeFile( + join(value.dshHome, ".agent-presets", "ptc", MANAGED_MARKER_FILE), + "{}\n", + ); + await writeFile( + join(value.dshHome, ".agent-presets", "cordis", MANAGED_MARKER_FILE), + JSON.stringify({ + managedBy: "@guionai/web", + markerVersion: 1, + presetId: "cordis", + sourcePackage: "@deepseek-ai/dsh-agent-presets", + sourcePackageVersion: "0.1.2-rc.1", + sourceIdentity: "sha256:stale", + }) + "\n", + ); + await mkdir(join(value.dshHome, ".agent-presets", "minimal", "nested"), { + recursive: true, + }); + const report = await inspectManagedPresets(value.options); + expect(report.ok).toBe(false); + expect(report.presets.map((preset) => preset.status)).toEqual([ + "incomplete", + "conflict", + "stale", + "ok", + ]); + expect(formatDshDoctor(report)).toContain("DSH doctor: FAILED"); + expect(await readFile(join(standard, "agent.cordis.yml"), "utf8")).toBe( + "not: a composition\n", + ); + } finally { + await dispose(value); + } + }); + + it("exposes sync and doctor through the runner with fixture-owned paths", async () => { + const value = await fixture(); + try { + let stdout = ""; + let stderr = ""; + const dependencies = { + operations: createWebOperations(), + credentials: () => ({}), + dsh: value.options, + }; + expect( + await runCli(["node", "web", "dsh", "sync"], dependencies, { + stdout: (text) => (stdout += text), + stderr: (text) => (stderr += text), + }), + ).toBe(0); + expect(stdout).toContain("DSH managed presets created all four presets"); + expect(stderr).toBe(""); + stdout = ""; + expect( + await runCli(["node", "web", "dsh", "doctor"], dependencies, { + stdout: (text) => (stdout += text), + stderr: (text) => (stderr += text), + }), + ).toBe(0); + expect(stdout).toContain("DSH doctor: OK"); + expect(stderr).toBe(""); + + await writeFile( + join(value.dshHome, ".agent-presets", "standard", "agent.cordis.yml"), + "broken\n", + ); + stdout = ""; + stderr = ""; + expect( + await runCli(["node", "web", "dsh", "doctor"], dependencies, { + stdout: (text) => (stdout += text), + stderr: (text) => (stderr += text), + }), + ).toBe(1); + expect(stdout).toContain("standard: incomplete"); + expect(stderr).toBe("DSH managed preset doctor found problems\n"); + } finally { + await dispose(value); + } + }); + + it("classifies a direct source fixture without requiring an installed manifest", async () => { + const value = await fixture(); + try { + const runtime = await ( + await import("../src/dsh.js") + ).resolveDshRuntime({ + sourcePresetRoot: value.sourcePresetRoot, + dshHome: value.dshHome, + }); + expect(runtime.sourcePresetRoot).toBe(value.sourcePresetRoot); + expect(runtime.sourcePackageName).toBe("@deepseek-ai/dsh-agent-presets"); + } finally { + await dispose(value); + } + }); + + it("exports its custom error type for actionable source failures", () => { + const error = new DshPresetError("dsh-source-invalid", "fixture failure"); + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe("dsh-source-invalid"); + expect(error.message).toBe("fixture failure"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3209e8..347a4b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,9 +80,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: 0.1.2-rc.1 version: 0.1.2-rc.1(d08203521d340062cf3410a8ee8700ca) - '@deepseek-ai/dsh-web': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) '@deepseek-ai/schemastery': specifier: 3.18.2 version: 3.18.2 @@ -457,12 +454,6 @@ packages: peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-web@0.1.2-rc.1': - resolution: {integrity: sha512-+umPNfJ+1Gshq38jR3tjgf4j0QaJZT5PretdHi1J+RU61Ou9Sc5w9n8haxa69os7g6FjBNxvGv8fnMeFDgcLKg==} - peerDependencies: - '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-llm': ^0.1.2-rc.1 - '@deepseek-ai/schemastery@3.18.2': resolution: {integrity: sha512-njDtZsznjYxok7KLLlHOPyuv2efdWVbSflAHgztSfbMsg+CVraEoRe2DjOCgClYv3ZCSm7WXoaUkbB/+RY7tWQ==} @@ -2841,12 +2832,6 @@ snapshots: dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-web@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))': - dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/schemastery@3.18.2': dependencies: '@deepseek-ai/cosmokit': 1.8.3 From a328694305110e86a7c5dcf0174e588b9167634a Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 16:17:59 +0800 Subject: [PATCH 02/10] chore(dsh): add implementation report --- .../implementation-report.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .scratch/dsh-managed-presets/implementation-report.md diff --git a/.scratch/dsh-managed-presets/implementation-report.md b/.scratch/dsh-managed-presets/implementation-report.md new file mode 100644 index 0000000..27f467e --- /dev/null +++ b/.scratch/dsh-managed-presets/implementation-report.md @@ -0,0 +1,154 @@ +# DSH managed presets implementation report + +## Scope + +- Repository: `guionai/web` +- Branch: `dsh-managed-presets` +- Fixed point: `1bb01fd` (main) +- Implementation commit: `3d437a6` (`feat(dsh): manage compatible stock presets`) +- Delivery boundary: the complete `dsh-managed-presets` spec and tickets 01–04. + Code review and deployment were excluded. + +## Ticket outcomes + +### 01 — Own the complete DSH Research Surface + +- Replaced the official Web registry/provider integration with direct Guion + ownership of `web_search`, `web_fetch`, `web_links`, `web_docs`, and + `web_source_search`. +- Preserved live Exa, Brave, DeepSeek, and Kepos Bridge selection and + namespaced credential resolution. Search validates one-to-four trimmed + queries, runs them concurrently, merges successful results deterministically, + reports partial/total failures, forwards cancellation, and bounds rendered + model text. +- Retained the complete fetch navigation and rendering contract (`mode`, + `section_id`, `render`, and `waitMs`) and the provider-conditional Kepos + tools. +- Removed the obsolete provider module, official Web peer/development + dependency, and registry injection. Packed host, client, tool, and schema + tests cover the owned surface. + +### 02 — Sync and diagnose managed presets + +- Added `web dsh sync` and read-only `web dsh doctor` with DSH executable + discovery, standard `DSH_HOME` resolution, explicit test-owned path seams, + exact official preset-package version validation, source identity markers, + structural `tool-web` removal, and complete source-tree validation. +- Sync creates or refreshes marked same-id `standard`, `ptc`, `cordis`, and + `minimal` snapshots under `.agent-presets`; it stages writes, preflights all + conflicts, preserves unmarked user data, and rolls back managed replacements + when a filesystem operation is interrupted. +- Doctor distinguishes missing, incomplete, stale, and conflicting outputs and + never writes the DSH home. Fixture CLI tests cover successful and failed + workflows, idempotence, source-shape rejection, marker contents, and + non-mutating failures. + +### 03 — Hide shipped presets end to end + +- Updated the bundle patch to disable the complete official Web stack and the + scoped `tool-web` row, while targeting the existing `agent-presets` row with + `includeShippedRoot: false`, `includeUserRoot: true`, and `default: standard`. +- Parsed and packed artifact tests assert the full patch and peer-only package + contract, including the absence of `@deepseek-ai/dsh-web`. +- Completed packed Linux validation against the official rc.1 Loader path and + disposable DSH home (details below). + +### 04 — Document the managed preset workflow + +- Updated the root and DSH-package READMEs with sync-before-activation, + doctor, hidden shipped-root, same-id ownership/conflict, upgrade refresh, and + effective Guion schema guidance. +- Added the DSH vocabulary to `CONTEXT.md`, recorded the ownership decision in + ADR 0004, and added test-owned/real-Linux requirements to `AGENTS.md`. +- The root/package READMEs, `CONTEXT.md`, and `AGENTS.md` are the only project + documents that describe this workflow; no other project documentation + exposes a DSH preset contract requiring an update. + +## Changed paths + +- `AGENTS.md` +- `CONTEXT.md` +- `README.md` +- `docs/adr/0004-dsh-research-surface-owner.md` +- `packages/dsh-web/README.md` +- `packages/dsh-web/cordis.patch.yml` +- `packages/dsh-web/package.json` +- `packages/dsh-web/src/client.ts` +- `packages/dsh-web/src/contract.ts` +- `packages/dsh-web/src/index.ts` +- `packages/dsh-web/src/provider.ts` (removed) +- `packages/dsh-web/src/tools.ts` +- `packages/dsh-web/test/artifact.test.ts` +- `packages/dsh-web/test/client.test.ts` +- `packages/dsh-web/test/package.test.ts` +- `packages/dsh-web/test/provider.test.ts` (removed) +- `packages/dsh-web/test/tools.test.ts` +- `packages/dsh-web/tsup.config.ts` +- `packages/web/src/dsh.ts` +- `packages/web/src/program.ts` +- `packages/web/src/runner.ts` +- `packages/web/test/dsh.test.ts` +- `pnpm-lock.yaml` + +## Verification + +All final local checks passed: + +- `pnpm install --frozen-lockfile --ignore-scripts` +- `pnpm typecheck` +- `pnpm test` — 20 files, 160 tests passed +- `pnpm format:check` +- `pnpm build` — all four package builds passed +- `pnpm test:pack` — Web, Pi, and DSH packed smoke tests passed +- `pnpm test:release` +- `git diff --check` + +The packed Linux validation used NUC work directory +`/tmp/guion-dsh-linux-final-heK1hs` with Node `v24.19.0`. The official +`@deepseek-ai/dsh@0.1.2-rc.1` and +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` graph was copied into the +test-owned `install` directory. Loader/profile commands used the required +entrypoint form: + +```text +node --expose-internals /tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js ... +``` + +The packed `@guionai/web` CLI ran `dsh sync` and `dsh doctor` with +`DSH_HOME=/tmp/guion-dsh-linux-final-heK1hs/home`; both succeeded and doctor +reported all four presets current. The composed profile was dumped and booted +through the same official entrypoint with a test-owned probe bundle and +`--no-open --host 127.0.0.1 --port 0`. The probe reported `standard`, `ptc`, +`minimal`, and `cordis` as user presets, with row counts `26`, `27`, `8`, and +`27`, respectively, and no `tool-web` rows. It also observed the Guion +`web_fetch` schema containing `mode`, `section_id`, `render`, and `waitMs`, and +the Guion `web_search` schema. The composed dump showed all four official Web +rows disabled and the complete agent-presets configuration. The profile process +was stopped after the probe completed; the disposable home and package paths +were never connected to the live DSH home, credentials, overrides, or services. + +Vitest emits the existing non-failing missing source-map warning from the DSH +primitives package. + +## LOC accounting + +Against fixed point `1bb01fd`, excluding the lockfile, generated artifacts, +ignored tracker files, and this report: + +| Category | Additions | Deletions | +| --- | ---: | ---: | +| Product code | 1,307 | 179 | +| Tests | 682 | 316 | +| Configuration and docs | 224 | 25 | +| **Total** | **2,213** | **520** | + +The estimate was 1,070–1,820 changed lines. The material variance comes from +the complete filesystem-safe runtime discovery, source validation, staging, and +rollback path plus the required fixture and packed Loader coverage; no +compatibility migration, live-state fallback, or unrelated optional feature was +added. + +## Acceptance result + +Tickets 01, 02, 03, and 04 and the complete spec are implemented and verified. +The code is committed as `3d437a6`; no code review or deployment was performed. From 3e3e95c5e0842f85b129699f81aa3f146a40870d Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 16:35:24 +0800 Subject: [PATCH 03/10] fix(dsh): harden managed preset synchronization --- packages/web/src/dsh.ts | 413 +++++++++++++++++++++++------ packages/web/test/dsh.test.ts | 184 ++++++++++++- packages/web/test/packed-smoke.mjs | 95 +++++++ 3 files changed, 601 insertions(+), 91 deletions(-) diff --git a/packages/web/src/dsh.ts b/packages/web/src/dsh.ts index c37d850..e91d714 100644 --- a/packages/web/src/dsh.ts +++ b/packages/web/src/dsh.ts @@ -40,19 +40,36 @@ const USER_PRESET_DIRECTORY = ".agent-presets" as const; * When omitted, sync and doctor discover the installed `dsh` executable and * use the same DSH_HOME precedence as the official harness. */ +export type DshRenamePhase = + | "before-backup-rename" + | "after-backup-rename" + | "before-install-rename" + | "after-install-rename" + | "before-backup-delete"; + +export interface DshRenameEvent { + readonly id: ManagedPresetId; + readonly phase: DshRenamePhase; + readonly target: string; + readonly staged?: string; + readonly backup?: string; +} + +export interface DshSyncHooks { + /** A test-owned seam for deterministic filesystem races and failures. */ + readonly beforeRename?: + | ((event: DshRenameEvent) => void | Promise) + | undefined; +} + export interface DshPathOverrides { dshExecutable?: string; - executable?: string; dshHome?: string; - home?: string; sourcePackageRoot?: string; - packageRoot?: string; sourcePresetRoot?: string; - sourceRoot?: string; - sourcePackageVersion?: string; - sourceVersion?: string; userPresetRoot?: string; environment?: NodeJS.ProcessEnv; + hooks?: DshSyncHooks; } export interface DshRuntimeInfo { @@ -103,17 +120,6 @@ function isRecord(value: unknown): value is UnknownRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } -function option( - options: DshPathOverrides, - ...names: readonly string[] -): string | undefined { - for (const name of names) { - const value = (options as UnknownRecord)[name]; - if (typeof value === "string" && value.trim().length > 0) return value; - } - return undefined; -} - function expandHomePath(value: string): string { if (value === "~") return homedir(); if (value.startsWith("~/") || value.startsWith("~\\")) @@ -123,7 +129,7 @@ function expandHomePath(value: string): string { function resolveHome(options: DshPathOverrides): string { const environment = options.environment ?? process.env; - const configured = option(options, "dshHome", "home"); + const configured = options.dshHome; const fromEnvironment = environment.DSH_HOME; return resolve( expandHomePath( @@ -299,9 +305,10 @@ async function readPackageManifest( packageRoot: string, ): Promise { try { - return JSON.parse( + const value: unknown = JSON.parse( await readFile(join(packageRoot, "package.json"), "utf8"), - ) as UnknownRecord; + ); + return isRecord(value) ? value : undefined; } catch { return undefined; } @@ -311,15 +318,11 @@ async function discoverPresetPackage(options: DshPathOverrides): Promise<{ packageRoot: string; presetRoot: string; executable?: string; - manifest?: UnknownRecord; + manifest: UnknownRecord; }> { - const explicitPackageRoot = option( - options, - "sourcePackageRoot", - "packageRoot", - ); - const explicitPresetRoot = option(options, "sourcePresetRoot", "sourceRoot"); - let executable = option(options, "dshExecutable", "executable"); + const explicitPackageRoot = options.sourcePackageRoot; + const explicitPresetRoot = options.sourcePresetRoot; + let executable = options.dshExecutable; let packageRoot: string | undefined = explicitPackageRoot; if (packageRoot === undefined && explicitPresetRoot !== undefined) @@ -350,8 +353,13 @@ async function discoverPresetPackage(options: DshPathOverrides): Promise<{ packageRoot = resolve(packageRoot); const manifest = await readPackageManifest(packageRoot); - const packageName = manifest?.name; - if (manifest !== undefined && packageName !== OFFICIAL_PRESET_PACKAGE) + if (manifest === undefined) + throw new DshPresetError( + "dsh-source-invalid", + `source package at ${packageRoot} is missing a readable package.json manifest`, + ); + const packageName = manifest.name; + if (packageName !== OFFICIAL_PRESET_PACKAGE) throw new DshPresetError( "dsh-source-invalid", `source package at ${packageRoot} is ${String(packageName ?? "unnamed")}, expected ${OFFICIAL_PRESET_PACKAGE}`, @@ -370,14 +378,14 @@ async function listFiles(root: string, prefix = ""): Promise { if (entry.isSymbolicLink()) throw new DshPresetError( "dsh-source-invalid", - `source tree contains a symbolic link: ${next}`, + `preset tree contains a symbolic link: ${next}`, ); if (entry.isDirectory()) files.push(...(await listFiles(root, next))); else if (entry.isFile()) files.push(next); else throw new DshPresetError( "dsh-source-invalid", - `source tree contains a special file: ${next}`, + `preset tree contains a special file: ${next}`, ); } return files; @@ -568,17 +576,12 @@ async function resolveRuntime( options: DshPathOverrides = {}, ): Promise { const discovered = await discoverPresetPackage(options); - const manifestVersion = discovered.manifest?.version; - const requestedVersion = option( - options, - "sourcePackageVersion", - "sourceVersion", - ); - const version = - requestedVersion ?? - (typeof manifestVersion === "string" - ? manifestVersion - : SUPPORTED_DSH_VERSION); + const version = discovered.manifest.version; + if (typeof version !== "string" || version.trim().length === 0) + throw new DshPresetError( + "dsh-source-invalid", + `source package at ${discovered.packageRoot} is missing a package version`, + ); if (version !== SUPPORTED_DSH_VERSION) throw new DshPresetError( "dsh-source-unsupported", @@ -594,7 +597,7 @@ async function resolveRuntime( const identity = await sourceIdentity(discovered.presetRoot, version); const dshHome = resolveHome(options); const userPresetRoot = resolve( - option(options, "userPresetRoot") ?? join(dshHome, USER_PRESET_DIRECTORY), + options.userPresetRoot ?? join(dshHome, USER_PRESET_DIRECTORY), ); return { dshExecutable: discovered.executable, @@ -718,10 +721,147 @@ async function stagePreset( ); } +type ManagedSnapshot = ReadonlyMap; + +/** + * Build the exact byte snapshot that a managed copy is allowed to contain. + * The official composition is the one transformed by this integration; every + * other source file is copied byte-for-byte. The marker is generated by + * Guion and is therefore deliberately excluded from the source snapshot. + */ +async function expectedManagedSnapshot( + runtime: DshRuntimeInfo, + id: ManagedPresetId, +): Promise { + const validation = await validateSourceTree( + runtime.sourcePresetRoot, + id, + id !== "minimal", + ); + const sourceDirectory = join(runtime.sourcePresetRoot, id); + const expected = new Map(); + for (const file of await listFiles(sourceDirectory)) { + if (file === MANAGED_MARKER_FILE) continue; + expected.set( + file, + file === "agent.cordis.yml" + ? Buffer.from(validation.transformedComposition) + : await readFile(join(sourceDirectory, file)), + ); + } + return expected; +} + +async function snapshotMismatch( + runtime: DshRuntimeInfo, + id: ManagedPresetId, + directory: string, +): Promise { + return compareManagedSnapshot( + await expectedManagedSnapshot(runtime, id), + directory, + ); +} + +async function compareManagedSnapshot( + expected: ManagedSnapshot, + directory: string, +): Promise { + const expectedFiles = new Set([...expected.keys(), MANAGED_MARKER_FILE]); + const actualFiles = new Set(await listFiles(directory)); + for (const file of expectedFiles) + if (!actualFiles.has(file)) return `managed file is missing: ${file}`; + for (const file of actualFiles) + if (!expectedFiles.has(file)) return `unexpected managed file: ${file}`; + for (const [file, expectedBytes] of expected) { + const actualBytes = await readFile(join(directory, file)); + if ( + actualBytes.length !== expectedBytes.length || + !actualBytes.equals(expectedBytes) + ) + return `managed file differs from the official source: ${file}`; + } + return undefined; +} + +async function managedTargetMatches( + runtime: DshRuntimeInfo, + id: ManagedPresetId, + directory: string, + expected?: ManagedSnapshot, +): Promise { + const marker = await readMarker(directory, id); + if ( + marker === undefined || + marker.sourcePackage !== runtime.sourcePackageName || + marker.sourcePackageVersion !== runtime.sourcePackageVersion || + marker.sourceIdentity !== runtime.sourceIdentity + ) + return false; + try { + return ( + (await compareManagedSnapshot( + expected ?? (await expectedManagedSnapshot(runtime, id)), + directory, + )) === undefined + ); + } catch { + return false; + } +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function invokeRenameHook( + options: DshPathOverrides, + event: DshRenameEvent, +): Promise { + await options.hooks?.beforeRename?.(event); +} + +const syncLocks = new Map>(); + +async function withSyncLock( + key: string, + action: () => Promise, +): Promise { + const predecessor = syncLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const queued = predecessor.then(() => current); + syncLocks.set(key, queued); + await predecessor; + try { + return await action(); + } finally { + release(); + if (syncLocks.get(key) === queued) syncLocks.delete(key); + } +} + export async function syncManagedPresets( options: DshPathOverrides = {}, ): Promise { const runtime = await resolveRuntime(options); + return withSyncLock(runtime.userPresetRoot, () => + syncManagedPresetsLocked(runtime, options), + ); +} + +async function syncManagedPresetsLocked( + runtime: DshRuntimeInfo, + options: DshPathOverrides, +): Promise { // Validate every source tree before creating the user root or staging files. for (const id of MANAGED_PRESET_IDS) await validateSourceTree(runtime.sourcePresetRoot, id, id !== "minimal"); @@ -743,55 +883,180 @@ export async function syncManagedPresets( await mkdir(runtime.userPresetRoot, { recursive: true }); const stageRoot = await mkdtemp(join(runtime.dshHome, ".guion-dsh-presets-")); - const backups: Array<{ target: string; backup: string }> = []; + const snapshots = new Map(); + const backups: Array<{ + id: ManagedPresetId; + target: string; + backup: string; + }> = []; + const installed: Array<{ + id: ManagedPresetId; + target: string; + expected: ManagedSnapshot; + }> = []; const replaced: ManagedPresetId[] = []; const created: ManagedPresetId[] = []; + let backupNonce = 0; try { - for (const id of MANAGED_PRESET_IDS) + for (const id of MANAGED_PRESET_IDS) { + snapshots.set(id, await expectedManagedSnapshot(runtime, id)); await stagePreset(stageRoot, runtime.sourcePresetRoot, runtime, id); + } // Recheck all targets after staging and before the first replacement. A // failed preflight therefore cannot leave a partially refreshed roster. for (const id of MANAGED_PRESET_IDS) { const result = await targetKind(runtime.userPresetRoot, id); - if (result.kind === "conflict") + if (result.kind === "conflict" || result.kind !== existing.get(id)!.kind) throw new DshPresetError( "dsh-preset-conflict", - `refusing to overwrite unmarked same-id preset ${join(runtime.userPresetRoot, id)}`, + `same-id preset changed while preparing ${join(runtime.userPresetRoot, id)}; refusing to overwrite it`, ); } for (const id of MANAGED_PRESET_IDS) { const target = join(runtime.userPresetRoot, id); - const current = existing.get(id)!; + const expected = snapshots.get(id)!; + // Revalidate ownership immediately before each rename. The hook is + // intentionally called after this check so tests can deterministically + // model a race in the tiny rename window and exercise post-rename + // marker verification. + const current = await targetKind(runtime.userPresetRoot, id); + if (current.kind !== existing.get(id)!.kind) + throw new DshPresetError( + "dsh-preset-conflict", + `same-id preset changed before replacement ${target}; refusing to overwrite it`, + ); if (current.kind === "managed") { const backup = join( runtime.userPresetRoot, - `.guion-dsh-backup-${id}-${process.pid}-${Date.now()}`, + `.guion-dsh-backup-${id}-${process.pid}-${Date.now()}-${backupNonce++}`, ); + await invokeRenameHook(options, { + id, + phase: "before-backup-rename", + target, + staged: join(stageRoot, id), + backup, + }); await rename(target, backup); - backups.push({ target, backup }); + if ((await readMarker(backup, id)) === undefined) { + // A same-ID directory may have been swapped after the ownership + // check. Restore it immediately and never make it deletable. + try { + if (!(await pathExists(target))) await rename(backup, target); + } catch { + // Keep the unknown directory at the backup path if restoration is + // itself raced; rollback below will also leave it untouched. + } + throw new DshPresetError( + "dsh-preset-conflict", + `same-id preset changed during replacement ${target}; unknown data was preserved`, + ); + } + backups.push({ id, target, backup }); + await invokeRenameHook(options, { + id, + phase: "after-backup-rename", + target, + staged: join(stageRoot, id), + backup, + }); replaced.push(id); - } else created.push(id); - await rename(join(stageRoot, id), target); + } else { + created.push(id); + } + const staged = join(stageRoot, id); + await invokeRenameHook(options, { + id, + phase: "before-install-rename", + target, + staged, + backup: backups.at(-1)?.id === id ? backups.at(-1)?.backup : undefined, + }); + if ((await targetKind(runtime.userPresetRoot, id)).kind !== "missing") + throw new DshPresetError( + "dsh-preset-conflict", + `same-id preset appeared before install ${target}; refusing to overwrite it`, + ); + await rename(staged, target); + installed.push({ id, target, expected }); + if (!(await managedTargetMatches(runtime, id, target, expected))) + throw new DshPresetError( + "dsh-preset-incomplete", + `managed preset install did not match the expected source snapshot: ${id}`, + ); + await invokeRenameHook(options, { + id, + phase: "after-install-rename", + target, + backup: backups.at(-1)?.id === id ? backups.at(-1)?.backup : undefined, + }); } + + // Validate every backup before deleting any of them. If cleanup itself + // fails, retaining a verified Guion-managed backup is safe and recoverable. for (const backup of backups) - await rm(backup.backup, { recursive: true, force: true }); + await invokeRenameHook(options, { + id: backup.id, + phase: "before-backup-delete", + target: backup.target, + backup: backup.backup, + }); + for (const backup of backups) { + if ((await readMarker(backup.backup, backup.id)) === undefined) + throw new DshPresetError( + "dsh-preset-conflict", + `backup for ${backup.id} is no longer Guion-managed; preserving it at ${backup.backup}`, + ); + } + for (const backup of backups) { + try { + await rm(backup.backup, { recursive: true, force: true }); + } catch { + // A cleanup failure must not turn a successful, complete roster into + // a destructive rollback. The verified backup remains recoverable. + } + } return { runtime, ids: MANAGED_PRESET_IDS, replaced, created }; } catch (error) { - // Restore managed directories if a replacement fails. Unmarked user data - // is never moved by this function and is therefore never part of rollback. + // Remove only targets that still exactly match this transaction's staged + // snapshot, then restore backups. Unknown data is never deleted. + for (const entry of [...installed].reverse()) { + try { + if ( + await managedTargetMatches( + runtime, + entry.id, + entry.target, + entry.expected, + ) + ) + await rm(entry.target, { recursive: true, force: true }); + } catch { + // Leave anything that cannot be proven to be ours untouched. + } + } for (const backup of [...backups].reverse()) { try { - await rm(backup.target, { recursive: true, force: true }); - await rename(backup.backup, backup.target); + if (await pathExists(backup.target)) { + if (await managedTargetMatches(runtime, backup.id, backup.target)) + await rm(backup.target, { recursive: true, force: true }); + else continue; + } + if (await pathExists(backup.backup)) + await rename(backup.backup, backup.target); } catch { - // An interrupted filesystem operation remains diagnosable by doctor. + // Preserve both paths when restoration is not safe. } } throw error; } finally { - await rm(stageRoot, { recursive: true, force: true }); + try { + await rm(stageRoot, { recursive: true, force: true }); + } catch { + // Staged data is disposable; never mask the sync result or error. + } } } @@ -841,15 +1106,9 @@ async function inspectManagedPreset( detail: "managed copy was generated from a different official source", }; try { - const composition = await readFile( - join(directory, "agent.cordis.yml"), - "utf8", - ); - const metadata = await readFile(join(directory, "preset.yml"), "utf8"); - validateComposition(composition, `${id}/agent.cordis.yml`, false); - const metadataParsed = parseYaml(metadata, `${id}/preset.yml`); - if (!isRecord(metadataParsed.value)) - throw new Error("metadata is not a mapping"); + const mismatch = await snapshotMismatch(runtime, id, directory); + if (mismatch !== undefined) + return { id, status: "incomplete", detail: mismatch }; } catch (error) { return { id, @@ -873,7 +1132,7 @@ export async function inspectManagedPresets( return { ok: false, userPresetRoot: resolve( - option(options, "userPresetRoot") ?? join(home, USER_PRESET_DIRECTORY), + options.userPresetRoot ?? join(home, USER_PRESET_DIRECTORY), ), presets: [], issues: [message], @@ -948,17 +1207,3 @@ export function formatDshSync(result: DshSyncResult): string { .join("; "); return `DSH managed presets ${action} from ${result.runtime.sourcePackageName}@${result.runtime.sourcePackageVersion}.\nPreset root: ${result.runtime.userPresetRoot}\n`; } - -export function sourcePresetPath( - runtime: DshRuntimeInfo, - id: ManagedPresetId, -): string { - return join(runtime.sourcePresetRoot, id); -} - -export function managedPresetPath( - runtime: DshRuntimeInfo, - id: ManagedPresetId, -): string { - return join(runtime.userPresetRoot, id); -} diff --git a/packages/web/test/dsh.test.ts b/packages/web/test/dsh.test.ts index 54078a4..5d318c1 100644 --- a/packages/web/test/dsh.test.ts +++ b/packages/web/test/dsh.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, readFile, readdir, + rename, rm, stat, writeFile, @@ -20,6 +21,7 @@ import { MANAGED_PRESET_IDS, formatDshDoctor, inspectManagedPresets, + resolveDshRuntime, syncManagedPresets, type DshPathOverrides, } from "../src/dsh.js"; @@ -195,11 +197,26 @@ describe("managed DSH presets", () => { it("fails closed for unsupported versions and unexpected source rows", async () => { const value = await fixture(); try { - await expect( - syncManagedPresets({ ...value.options, sourcePackageVersion: "0.1.1" }), - ).rejects.toMatchObject({ code: "dsh-source-unsupported" }); + await writeFile( + join(value.sourcePackageRoot, "package.json"), + JSON.stringify({ + name: "@deepseek-ai/dsh-agent-presets", + version: "0.1.1", + }) + "\n", + ); + await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ + code: "dsh-source-unsupported", + }); expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); + await writeFile( + join(value.sourcePackageRoot, "package.json"), + JSON.stringify({ + name: "@deepseek-ai/dsh-agent-presets", + version: "0.1.2-rc.1", + }) + "\n", + ); + await writeFile( join(value.sourcePresetRoot, "standard", "agent.cordis.yml"), sourceComposition("standard", false), @@ -277,6 +294,153 @@ describe("managed DSH presets", () => { } }); + it("doctor compares every copied source file, including deletion and tampering", async () => { + const value = await fixture(); + try { + await syncManagedPresets(value.options); + const standard = join(value.dshHome, ".agent-presets", "standard"); + await rm(join(standard, "prompt.md")); + let report = await inspectManagedPresets(value.options); + expect( + report.presets.find((preset) => preset.id === "standard"), + ).toMatchObject({ status: "incomplete" }); + expect(report.issues.join("\n")).toContain("missing: prompt.md"); + + await writeFile(join(standard, "prompt.md"), "tampered prompt\n"); + report = await inspectManagedPresets(value.options); + expect( + report.presets.find((preset) => preset.id === "standard"), + ).toMatchObject({ status: "incomplete" }); + expect(report.issues.join("\n")).toContain( + "differs from the official source: prompt.md", + ); + + await writeFile( + join(standard, "preset.yml"), + "name: Tampered\ndescription: still valid\norder: 1\n", + ); + report = await inspectManagedPresets(value.options); + expect( + report.presets.find((preset) => preset.id === "standard"), + ).toMatchObject({ status: "incomplete" }); + expect(report.issues.join("\n")).toContain( + "differs from the official source: preset.yml", + ); + } finally { + await dispose(value); + } + }); + + it("aborts a same-id ownership race without deleting unknown data", async () => { + const value = await fixture(); + try { + await syncManagedPresets(value.options); + const target = join(value.dshHome, ".agent-presets", "standard"); + const original = join(value.root, "original-standard"); + let raced = false; + await expect( + syncManagedPresets({ + ...value.options, + hooks: { + beforeRename: async (event) => { + if ( + !raced && + event.id === "standard" && + event.phase === "before-backup-rename" + ) { + raced = true; + await rename(target, original); + await mkdir(target); + await writeFile(join(target, "operator-data.txt"), "keep me\n"); + } + }, + }, + }), + ).rejects.toMatchObject({ code: "dsh-preset-conflict" }); + expect(await readFile(join(target, "operator-data.txt"), "utf8")).toBe( + "keep me\n", + ); + expect( + JSON.parse(await readFile(join(original, MANAGED_MARKER_FILE), "utf8")) + .managedBy, + ).toBe("@guionai/web"); + expect(await readdir(join(value.dshHome, ".agent-presets"))).toContain( + "standard", + ); + } finally { + await dispose(value); + } + }); + + it("serializes concurrent syncs for one DSH_HOME", async () => { + const value = await fixture(); + try { + let enteredResolve!: () => void; + const entered = new Promise((resolve) => { + enteredResolve = resolve; + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let paused = false; + const firstRun = syncManagedPresets({ + ...value.options, + hooks: { + beforeRename: async (event) => { + if ( + !paused && + event.id === "standard" && + event.phase === "before-install-rename" + ) { + paused = true; + enteredResolve(); + await gate; + } + }, + }, + }); + await entered; + let secondDone = false; + const secondRun = syncManagedPresets(value.options).then((result) => { + secondDone = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(secondDone).toBe(false); + release(); + const [first, second] = await Promise.all([firstRun, secondRun]); + expect(first.created).toEqual([...MANAGED_PRESET_IDS]); + expect(second.replaced).toEqual([...MANAGED_PRESET_IDS]); + expect( + (await readdir(join(value.dshHome, ".agent-presets"))).sort(), + ).toEqual([...MANAGED_PRESET_IDS].sort()); + } finally { + await dispose(value); + } + }); + + it("rolls back newly installed targets after a mid-swap failure", async () => { + const value = await fixture(); + try { + let installs = 0; + await expect( + syncManagedPresets({ + ...value.options, + hooks: { + beforeRename: (event) => { + if (event.phase === "after-install-rename" && ++installs === 2) + throw new Error("injected mid-swap failure"); + }, + }, + }), + ).rejects.toThrow("injected mid-swap failure"); + expect(await readdir(join(value.dshHome, ".agent-presets"))).toEqual([]); + } finally { + await dispose(value); + } + }); + it("exposes sync and doctor through the runner with fixture-owned paths", async () => { const value = await fixture(); try { @@ -324,17 +488,23 @@ describe("managed DSH presets", () => { } }); - it("classifies a direct source fixture without requiring an installed manifest", async () => { + it("requires the official source package manifest and version", async () => { const value = await fixture(); try { - const runtime = await ( - await import("../src/dsh.js") - ).resolveDshRuntime({ + const runtime = await resolveDshRuntime({ sourcePresetRoot: value.sourcePresetRoot, dshHome: value.dshHome, }); expect(runtime.sourcePresetRoot).toBe(value.sourcePresetRoot); expect(runtime.sourcePackageName).toBe("@deepseek-ai/dsh-agent-presets"); + + await rm(join(value.sourcePackageRoot, "package.json")); + await expect( + resolveDshRuntime({ + sourcePresetRoot: value.sourcePresetRoot, + dshHome: value.dshHome, + }), + ).rejects.toMatchObject({ code: "dsh-source-invalid" }); } finally { await dispose(value); } diff --git a/packages/web/test/packed-smoke.mjs b/packages/web/test/packed-smoke.mjs index 1f44bd3..3dbc56a 100644 --- a/packages/web/test/packed-smoke.mjs +++ b/packages/web/test/packed-smoke.mjs @@ -134,6 +134,101 @@ try { if (!help.stdout.includes("Search the web") || !help.stdout.includes("mcp")) throw new Error("installed web CLI did not start with its MCP command"); + // Exercise the packed DSH commands through normal executable discovery. + // This graph is deliberately disposable: no source paths or live DSH_HOME + // are injected into the CLI, and the fake launcher points at an installed + // runtime-shaped package just as the official package manager does. + const fakeDshInstall = join(root, "fake-dsh-install"); + const fakeDshBin = join(fakeDshInstall, "node_modules", ".bin"); + const fakeDshRoot = join( + fakeDshInstall, + "node_modules", + "@deepseek-ai", + "dsh", + ); + const fakePresetRoot = join( + fakeDshInstall, + "node_modules", + "@deepseek-ai", + "dsh-agent-presets", + ); + const fakeDshHome = join(root, "dsh-home"); + await mkdir(join(fakeDshRoot, "lib"), { recursive: true }); + await mkdir(join(fakeDshBin), { recursive: true }); + await writeFile( + join(fakeDshRoot, "package.json"), + JSON.stringify({ name: "@deepseek-ai/dsh", version: "0.1.2-rc.1" }) + "\n", + ); + await writeFile( + join(fakeDshRoot, "lib", "bin.js"), + "// disposable DSH entry\n", + ); + await writeFile( + join(fakeDshBin, "dsh"), + `#!/bin/sh +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(fakeDshRoot, "lib", "bin.js"))} "$@" +`, + ); + await chmod(join(fakeDshBin, "dsh"), 0o700); + await mkdir(fakePresetRoot, { recursive: true }); + await writeFile( + join(fakePresetRoot, "package.json"), + JSON.stringify({ + name: "@deepseek-ai/dsh-agent-presets", + version: "0.1.2-rc.1", + }) + "\n", + ); + const packedComposition = (id, includeWeb) => + [ + `# ${id} packed composition`, + "- id: persona", + " name: packed:persona", + ...(includeWeb + ? [ + "", + "- id: tool-web", + " name: '@deepseek-ai/dsh-tool-web'", + " config:", + " fetch: true", + " searchTimeoutMs: 60000", + ] + : []), + "", + "- id: tail", + " name: packed:tail", + "", + ].join("\n"); + for (const id of ["standard", "ptc", "cordis", "minimal"]) { + const preset = join(fakePresetRoot, "presets", id); + await mkdir(preset, { recursive: true }); + await writeFile( + join(preset, "agent.cordis.yml"), + packedComposition(id, id !== "minimal"), + ); + await writeFile( + join(preset, "preset.yml"), + `name: Packed ${id}\ndescription: packed smoke fixture\norder: 1\n`, + ); + await writeFile(join(preset, "prompt.md"), `packed prompt ${id}\n`); + } + const dshEnvironment = { + PATH: `${fakeDshBin}:${dirname(process.execPath)}:/usr/bin:/bin`, + HOME: join(root, "dsh-home-home"), + DSH_HOME: fakeDshHome, + }; + const dshSync = await execFileAsync(binary, ["dsh", "sync"], { + cwd: root, + env: dshEnvironment, + }); + if (!dshSync.stdout.includes("DSH managed presets created all four presets")) + throw new Error("packed web CLI could not sync managed DSH presets"); + const dshDoctor = await execFileAsync(binary, ["dsh", "doctor"], { + cwd: root, + env: dshEnvironment, + }); + if (!dshDoctor.stdout.includes("DSH doctor: OK")) + throw new Error("packed web CLI could not doctor managed DSH presets"); + const fakeBin = join(root, "fake-browser-bin"); const fakeLog = join(root, "agent-browser.jsonl"); await mkdir(fakeBin); From 3123a18e8cc20c96ff2a31d75236e7e39b16737c Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 16:44:55 +0800 Subject: [PATCH 04/10] chore(dsh): update implementation report --- .../implementation-report.md | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/.scratch/dsh-managed-presets/implementation-report.md b/.scratch/dsh-managed-presets/implementation-report.md index 27f467e..c702084 100644 --- a/.scratch/dsh-managed-presets/implementation-report.md +++ b/.scratch/dsh-managed-presets/implementation-report.md @@ -5,7 +5,8 @@ - Repository: `guionai/web` - Branch: `dsh-managed-presets` - Fixed point: `1bb01fd` (main) -- Implementation commit: `3d437a6` (`feat(dsh): manage compatible stock presets`) +- Implementation commits: `3d437a6` (`feat(dsh): manage compatible stock presets`) + and `3e3e95c` (`fix(dsh): harden managed preset synchronization`) - Delivery boundary: the complete `dsh-managed-presets` spec and tickets 01–04. Code review and deployment were excluded. @@ -37,11 +38,17 @@ - Sync creates or refreshes marked same-id `standard`, `ptc`, `cordis`, and `minimal` snapshots under `.agent-presets`; it stages writes, preflights all conflicts, preserves unmarked user data, and rolls back managed replacements - when a filesystem operation is interrupted. -- Doctor distinguishes missing, incomplete, stale, and conflicting outputs and - never writes the DSH home. Fixture CLI tests cover successful and failed - workflows, idempotence, source-shape rejection, marker contents, and - non-mutating failures. + when a filesystem operation is interrupted. Concurrent syncs for one user + root are serialized; each rename revalidates ownership, verifies a moved + backup marker before it can be deleted, preserves unknown races, and removes + newly installed targets during rollback. +- Doctor compares every managed file byte-for-byte with the expected + transformed source snapshot (while allowing only the generated marker), so + deletion, tampering, and unexpected copied files are reported as incomplete. + The source package manifest and version are authoritative; version override + seams and unused path aliases/helpers were removed. Fixture tests cover + source-file deletion/tampering, deterministic ownership races, concurrent + sync serialization, and injected mid-swap rollback. ### 03 — Hide shipped presets end to end @@ -88,6 +95,7 @@ - `packages/web/src/program.ts` - `packages/web/src/runner.ts` - `packages/web/test/dsh.test.ts` +- `packages/web/test/packed-smoke.mjs` - `pnpm-lock.yaml` ## Verification @@ -96,36 +104,43 @@ All final local checks passed: - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm typecheck` -- `pnpm test` — 20 files, 160 tests passed +- `pnpm test` — 20 files, 164 tests passed - `pnpm format:check` - `pnpm build` — all four package builds passed - `pnpm test:pack` — Web, Pi, and DSH packed smoke tests passed - `pnpm test:release` - `git diff --check` -The packed Linux validation used NUC work directory -`/tmp/guion-dsh-linux-final-heK1hs` with Node `v24.19.0`. The official +The blocker pass added packed Web CLI coverage with a fake installed DSH and +agent-presets graph under a test-owned temporary directory. It invokes +`dsh sync` and `dsh doctor` through PATH discovery with a disposable DSH_HOME; +no source paths or live state are injected. + +The updated Linux validation used NUC work directory +`/tmp/guion-dsh-linux-blocker-final-ss2sfY` with Node `v24.19.0`. The official `@deepseek-ai/dsh@0.1.2-rc.1` and -`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` graph was copied into the -test-owned `install` directory. Loader/profile commands used the required -entrypoint form: +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` graph was used from the +test-owned `install` directory, and the current packed Web and DSH bundles +were installed into disposable profile paths. Loader/profile commands used +the required entrypoint form: ```text node --expose-internals /tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js ... ``` -The packed `@guionai/web` CLI ran `dsh sync` and `dsh doctor` with -`DSH_HOME=/tmp/guion-dsh-linux-final-heK1hs/home`; both succeeded and doctor -reported all four presets current. The composed profile was dumped and booted -through the same official entrypoint with a test-owned probe bundle and +The current packed `@guionai/web` CLI ran `dsh sync` and `dsh doctor` with +`DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home`; both succeeded and +doctor reported all four presets current. The composed profile was dumped and +booted through the same official entrypoint with a test-owned probe bundle and `--no-open --host 127.0.0.1 --port 0`. The probe reported `standard`, `ptc`, `minimal`, and `cordis` as user presets, with row counts `26`, `27`, `8`, and `27`, respectively, and no `tool-web` rows. It also observed the Guion `web_fetch` schema containing `mode`, `section_id`, `render`, and `waitMs`, and the Guion `web_search` schema. The composed dump showed all four official Web -rows disabled and the complete agent-presets configuration. The profile process -was stopped after the probe completed; the disposable home and package paths -were never connected to the live DSH home, credentials, overrides, or services. +rows disabled and the complete agent-presets configuration. The profile +process was stopped after the probe completed; both the blocker home and all +package/profile paths were test-owned and never connected to the live DSH +home, credentials, overrides, or services. Vitest emits the existing non-failing missing source-map warning from the DSH primitives package. @@ -137,10 +152,10 @@ ignored tracker files, and this report: | Category | Additions | Deletions | | --- | ---: | ---: | -| Product code | 1,307 | 179 | -| Tests | 682 | 316 | +| Product code | 1,552 | 179 | +| Tests | 947 | 316 | | Configuration and docs | 224 | 25 | -| **Total** | **2,213** | **520** | +| **Total** | **2,723** | **520** | The estimate was 1,070–1,820 changed lines. The material variance comes from the complete filesystem-safe runtime discovery, source validation, staging, and @@ -151,4 +166,5 @@ added. ## Acceptance result Tickets 01, 02, 03, and 04 and the complete spec are implemented and verified. -The code is committed as `3d437a6`; no code review or deployment was performed. +The implementation is committed as `3d437a6` and `3e3e95c`; the report update +is committed separately. No code review or deployment was performed. From e9ca3b8a87897062d56d7972be95aed77bb4fae5 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 16:56:15 +0800 Subject: [PATCH 05/10] fix(dsh): require explicit provider wiring --- packages/dsh-web/src/tools.ts | 4 +-- packages/dsh-web/test/tools.test.ts | 1 + packages/web/src/dsh.ts | 26 +--------------- packages/web/test/dsh.test.ts | 48 ----------------------------- 4 files changed, 4 insertions(+), 75 deletions(-) diff --git a/packages/dsh-web/src/tools.ts b/packages/dsh-web/src/tools.ts index 67056f2..242a1b4 100644 --- a/packages/dsh-web/src/tools.ts +++ b/packages/dsh-web/src/tools.ts @@ -44,7 +44,7 @@ export interface WebToolDependencies { /** Reads the current validated bridge endpoint when a Kepos tool executes. */ getKeposBridgeEndpoint: () => string; /** Reads the selected provider from the live settings scope per search. */ - getProvider?: () => SearchProviderName; + getProvider: () => SearchProviderName; operations?: WebOperations; } @@ -595,7 +595,7 @@ async function executeSearchQuery( operations: WebOperations, ): Promise { if (signal?.aborted) throw new Error("Operation aborted"); - const provider = dependencies.getProvider?.() ?? "exa"; + const provider = dependencies.getProvider(); const credential = searchCredential(provider); let resolved: ResolvedCredential | undefined; if (credential !== undefined) { diff --git a/packages/dsh-web/test/tools.test.ts b/packages/dsh-web/test/tools.test.ts index df37e22..a2f4556 100644 --- a/packages/dsh-web/test/tools.test.ts +++ b/packages/dsh-web/test/tools.test.ts @@ -36,6 +36,7 @@ function dependencies( return { credentials: { resolve: async () => undefined }, getKeposBridgeEndpoint: () => DEFAULT_KEPOS_BRIDGE_ENDPOINT, + getProvider: () => "exa", ...overrides, }; } diff --git a/packages/web/src/dsh.ts b/packages/web/src/dsh.ts index e91d714..25950b4 100644 --- a/packages/web/src/dsh.ts +++ b/packages/web/src/dsh.ts @@ -827,35 +827,11 @@ async function invokeRenameHook( await options.hooks?.beforeRename?.(event); } -const syncLocks = new Map>(); - -async function withSyncLock( - key: string, - action: () => Promise, -): Promise { - const predecessor = syncLocks.get(key) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((resolve) => { - release = resolve; - }); - const queued = predecessor.then(() => current); - syncLocks.set(key, queued); - await predecessor; - try { - return await action(); - } finally { - release(); - if (syncLocks.get(key) === queued) syncLocks.delete(key); - } -} - export async function syncManagedPresets( options: DshPathOverrides = {}, ): Promise { const runtime = await resolveRuntime(options); - return withSyncLock(runtime.userPresetRoot, () => - syncManagedPresetsLocked(runtime, options), - ); + return syncManagedPresetsLocked(runtime, options); } async function syncManagedPresetsLocked( diff --git a/packages/web/test/dsh.test.ts b/packages/web/test/dsh.test.ts index 5d318c1..5eb0203 100644 --- a/packages/web/test/dsh.test.ts +++ b/packages/web/test/dsh.test.ts @@ -372,54 +372,6 @@ describe("managed DSH presets", () => { } }); - it("serializes concurrent syncs for one DSH_HOME", async () => { - const value = await fixture(); - try { - let enteredResolve!: () => void; - const entered = new Promise((resolve) => { - enteredResolve = resolve; - }); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let paused = false; - const firstRun = syncManagedPresets({ - ...value.options, - hooks: { - beforeRename: async (event) => { - if ( - !paused && - event.id === "standard" && - event.phase === "before-install-rename" - ) { - paused = true; - enteredResolve(); - await gate; - } - }, - }, - }); - await entered; - let secondDone = false; - const secondRun = syncManagedPresets(value.options).then((result) => { - secondDone = true; - return result; - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(secondDone).toBe(false); - release(); - const [first, second] = await Promise.all([firstRun, secondRun]); - expect(first.created).toEqual([...MANAGED_PRESET_IDS]); - expect(second.replaced).toEqual([...MANAGED_PRESET_IDS]); - expect( - (await readdir(join(value.dshHome, ".agent-presets"))).sort(), - ).toEqual([...MANAGED_PRESET_IDS].sort()); - } finally { - await dispose(value); - } - }); - it("rolls back newly installed targets after a mid-swap failure", async () => { const value = await fixture(); try { From 5b1c96383d3631e761cb1c6906cce8db6827aca2 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 16:59:00 +0800 Subject: [PATCH 06/10] chore(dsh): refresh final verification report --- .../implementation-report.md | 130 ++++++++++++------ 1 file changed, 91 insertions(+), 39 deletions(-) diff --git a/.scratch/dsh-managed-presets/implementation-report.md b/.scratch/dsh-managed-presets/implementation-report.md index c702084..33fc62d 100644 --- a/.scratch/dsh-managed-presets/implementation-report.md +++ b/.scratch/dsh-managed-presets/implementation-report.md @@ -5,10 +5,11 @@ - Repository: `guionai/web` - Branch: `dsh-managed-presets` - Fixed point: `1bb01fd` (main) -- Implementation commits: `3d437a6` (`feat(dsh): manage compatible stock presets`) - and `3e3e95c` (`fix(dsh): harden managed preset synchronization`) +- Implementation commits: `3d437a6` (`feat(dsh): manage compatible stock presets`), + `3e3e95c` (`fix(dsh): harden managed preset synchronization`), and `e9ca3b8` + (`fix(dsh): require explicit provider wiring`) - Delivery boundary: the complete `dsh-managed-presets` spec and tickets 01–04. - Code review and deployment were excluded. + Code review and deployment were outside the Implementation worker's scope. ## Ticket outcomes @@ -38,17 +39,16 @@ - Sync creates or refreshes marked same-id `standard`, `ptc`, `cordis`, and `minimal` snapshots under `.agent-presets`; it stages writes, preflights all conflicts, preserves unmarked user data, and rolls back managed replacements - when a filesystem operation is interrupted. Concurrent syncs for one user - root are serialized; each rename revalidates ownership, verifies a moved - backup marker before it can be deleted, preserves unknown races, and removes - newly installed targets during rollback. + when a filesystem operation is interrupted. Each rename revalidates + ownership, verifies a moved backup marker before it can be deleted, preserves + unknown races, and removes newly installed targets during rollback. - Doctor compares every managed file byte-for-byte with the expected transformed source snapshot (while allowing only the generated marker), so deletion, tampering, and unexpected copied files are reported as incomplete. The source package manifest and version are authoritative; version override seams and unused path aliases/helpers were removed. Fixture tests cover - source-file deletion/tampering, deterministic ownership races, concurrent - sync serialization, and injected mid-swap rollback. + source-file deletion/tampering, deterministic ownership races, and injected + mid-swap rollback. ### 03 — Hide shipped presets end to end @@ -104,7 +104,7 @@ All final local checks passed: - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm typecheck` -- `pnpm test` — 20 files, 164 tests passed +- `pnpm test` — 20 files, 163 tests passed - `pnpm format:check` - `pnpm build` — all four package builds passed - `pnpm test:pack` — Web, Pi, and DSH packed smoke tests passed @@ -116,31 +116,82 @@ agent-presets graph under a test-owned temporary directory. It invokes `dsh sync` and `dsh doctor` through PATH discovery with a disposable DSH_HOME; no source paths or live state are injected. -The updated Linux validation used NUC work directory -`/tmp/guion-dsh-linux-blocker-final-ss2sfY` with Node `v24.19.0`. The official -`@deepseek-ai/dsh@0.1.2-rc.1` and -`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` graph was used from the -test-owned `install` directory, and the current packed Web and DSH bundles -were installed into disposable profile paths. Loader/profile commands used -the required entrypoint form: +The updated Linux validation ran on NUC `kosmos-wsl` (Linux, Node +`v24.19.0`) under the disposable work directory +`/tmp/guion-dsh-linux-blocker-final-ss2sfY`. The blocker run reused the prior +disposable official install at +`/tmp/guion-dsh-linux-final-heK1hs/install` rather than reinstalling DSH; that +install contains the official `@deepseek-ai/dsh@0.1.2-rc.1` and +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` graph. The current packed artifacts +and test-owned paths were: + +- Web bundle: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/guionai-web-0.1.0.tgz` +- DSH bundle: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/guionai-dsh-web-0.1.0.tgz` +- Packed Web CLI: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli/node_modules/.bin/web` +- DSH entrypoint: `/tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js` +- DSH home: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/home` +- Profile: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/home/profiles/web` +- Probe: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/home/profiles/probe` + +The Web CLI install was performed in the test-owned `webcli` directory: + +```sh +cd /tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli +HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home-home XDG_CACHE_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/cache npm_config_store_dir=/tmp/guion-dsh-linux-blocker-final-ss2sfY/store pnpm install --offline --ignore-scripts --frozen-lockfile=false +``` + +The profile install used the current packed DSH bundle and probe from the +test-owned profile directory: + +```sh +cd /tmp/guion-dsh-linux-blocker-final-ss2sfY/home/profiles/web +HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/profile-home-repro XDG_CACHE_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/profile-cache-repro npm_config_store_dir=/tmp/guion-dsh-linux-blocker-final-ss2sfY/profile-store-repro pnpm install --offline --ignore-scripts --frozen-lockfile=false +``` + +These are the complete runtime commands used (all paths are explicit; there +are no source-path overrides): + +```sh +PATH=/tmp/guion-dsh-linux-final-heK1hs/install/node_modules/.bin:/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home /tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli/node_modules/.bin/web dsh sync +PATH=/tmp/guion-dsh-linux-final-heK1hs/install/node_modules/.bin:/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home /tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli/node_modules/.bin/web dsh doctor +PATH=/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home node --expose-internals /tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js --profile web --dump-config +PATH=/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home PROBE_OUT=/tmp/guion-dsh-linux-blocker-final-ss2sfY/probe-repro.json node --expose-internals /tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js --profile web --no-open --host 127.0.0.1 --port 0 +``` + +Captured command output was: + +```text +DSH managed presets created all four presets from @deepseek-ai/dsh-agent-presets@0.1.2-rc.1. +Preset root: /tmp/guion-dsh-linux-blocker-final-ss2sfY/home/.agent-presets +DSH doctor: OK +- standard: ok +- ptc: ok +- cordis: ok +- minimal: ok +dsh web: http://127.0.0.1:44554/?token=REDACTED +``` + +The probe's roster/composition summary was: + +```text +standard user rows=26 tool-web=[] +ptc user rows=27 tool-web=[] +minimal user rows=8 tool-web=[] +cordis user rows=27 tool-web=[] +``` + +The probe's schema summary was: ```text -node --expose-internals /tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js ... +web_fetch properties=url,mode,section_id,render,waitMs required=url +web_search properties=queries required=queries ``` -The current packed `@guionai/web` CLI ran `dsh sync` and `dsh doctor` with -`DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home`; both succeeded and -doctor reported all four presets current. The composed profile was dumped and -booted through the same official entrypoint with a test-owned probe bundle and -`--no-open --host 127.0.0.1 --port 0`. The probe reported `standard`, `ptc`, -`minimal`, and `cordis` as user presets, with row counts `26`, `27`, `8`, and -`27`, respectively, and no `tool-web` rows. It also observed the Guion -`web_fetch` schema containing `mode`, `section_id`, `render`, and `waitMs`, and -the Guion `web_search` schema. The composed dump showed all four official Web -rows disabled and the complete agent-presets configuration. The profile -process was stopped after the probe completed; both the blocker home and all -package/profile paths were test-owned and never connected to the live DSH -home, credentials, overrides, or services. +The dump contained `includeShippedRoot: false`, `includeUserRoot: true`, and +`default: standard`; the profile process was stopped after the probe completed. +Every home, profile, probe, and packed bundle path above was test-owned and +disposable, and no live DSH home, credentials, overrides, or services were +read or modified. Vitest emits the existing non-failing missing source-map warning from the DSH primitives package. @@ -150,12 +201,12 @@ primitives package. Against fixed point `1bb01fd`, excluding the lockfile, generated artifacts, ignored tracker files, and this report: -| Category | Additions | Deletions | -| --- | ---: | ---: | -| Product code | 1,552 | 179 | -| Tests | 947 | 316 | -| Configuration and docs | 224 | 25 | -| **Total** | **2,723** | **520** | +| Category | Additions | Deletions | +| ---------------------- | --------: | --------: | +| Product code | 1,528 | 179 | +| Tests | 900 | 316 | +| Configuration and docs | 224 | 25 | +| **Total** | **2,652** | **520** | The estimate was 1,070–1,820 changed lines. The material variance comes from the complete filesystem-safe runtime discovery, source validation, staging, and @@ -166,5 +217,6 @@ added. ## Acceptance result Tickets 01, 02, 03, and 04 and the complete spec are implemented and verified. -The implementation is committed as `3d437a6` and `3e3e95c`; the report update -is committed separately. No code review or deployment was performed. +The implementation is committed as `3d437a6`, `3e3e95c`, and `e9ca3b8`; the report update +is committed separately. Code review and deployment were outside the +Implementation worker's scope. From 3d7d4a1c52d27b33846c0ac35c731e81caf05bd8 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 17:28:16 +0800 Subject: [PATCH 07/10] refactor(dsh): simplify compatible preset sync --- AGENTS.md | 17 +- CONTEXT.md | 22 +- README.md | 17 +- docs/adr/0004-dsh-research-surface-owner.md | 11 +- packages/dsh-web/README.md | 17 +- packages/web/src/dsh.ts | 653 +++++--------------- packages/web/src/program.ts | 41 +- packages/web/src/runner.ts | 1 + packages/web/test/dsh.test.ts | 419 +++++-------- packages/web/test/packed-smoke.mjs | 95 --- 10 files changed, 392 insertions(+), 901 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7dee889..6ee5208 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,17 +6,16 @@ Repository CI and local tests target Linux. Treat Windows behavior as best-effor ## DSH rc.1 Loader validation -For profile-local packaged plugins on DSH `0.1.2-rc.1` with Node 24, install the official rc.1 CLI into a test-owned disposable directory and validate its real installed graph through the Node entrypoint with internals enabled: `node --expose-internals /node_modules/@deepseek-ai/dsh/lib/bin.js ...`. Keep `DSH_HOME` test-owned and disposable, do not use a deployed runtime or live credentials, and do not add custom runtime overrides or compatibility layers. +For profile-local packaged plugins on DSH `0.1.2-rc.1` with Node 24, validate on the NUC through its installed official `dsh` CLI and real Loader. Keep `DSH_HOME` and every generated profile test-owned and disposable; the installed CLI and preset package are read-only inputs. Never use live profiles, presets, credentials, overrides, or services, and do not add custom runtime overrides or compatibility layers. ## Managed DSH presets The supported preset workflow is explicit: run `web dsh sync` before installing or activating `@guionai/dsh-web`, then run `web dsh doctor` and require a zero -exit status. Sync owns only marked copies of `standard`, `ptc`, `cordis`, and -`minimal` under the test-owned DSH user preset root; it must refuse unmarked -same-id conflicts and must not mutate shipped presets, ordinary user presets, -credentials, or sessions. Tests use fixture source trees and disposable -`DSH_HOME` directories. End-to-end validation exercises the packed artifact -through the real Linux rc.1 Loader path and records the disposable paths and -results in the implementation report. Do not validate this integration by -redirecting an agent home to a live deployment or by adding runtime overrides. +exit status. Sync compares `standard`, `ptc`, `cordis`, and `minimal` with the +official and compatible trees; modified same-id content requires interactive +confirmation or `--yes`. It must not mutate shipped presets, unrelated user +presets, credentials, or sessions. Tests use fixture source trees and +disposable `DSH_HOME` directories. End-to-end validation runs on the NUC with +its installed official CLI, a disposable `DSH_HOME`, and current packed Guion +artifacts, and records the paths and results in the implementation report. diff --git a/CONTEXT.md b/CONTEXT.md index 63a888d..90c8b3d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,7 +83,7 @@ _Avoid_: Official Web suite, scoped Web row **Compatible DSH Preset**: A DSH preset that omits its entire scoped `tool-web` row and therefore inherits the global DSH Research Surface from Guion. The `web dsh sync` command creates -four Guion-managed compatible copies with the same ids as the official shipped +four generated compatible copies with the same ids as the official shipped `standard`, `ptc`, `cordis`, and `minimal` presets. The DSH bundle hides the shipped root while retaining the user root, so these copies become active and ordinary user presets such as Yuki remain visible. @@ -96,17 +96,17 @@ never edited by Guion. The bundle sets `includeShippedRoot: false` because DSH shipped entries win same-id duplicates. _Avoid_: Managed preset, user preset -**Guion-Managed DSH Preset**: +**Guion-Compatible DSH Preset**: A full snapshot under `${DSH_HOME:-$HOME/.dsh}/.agent-presets` generated by -`web dsh sync` from the installed official rc.1 package. Its -`.guion-managed.json` marker records the source version and identity. Sync may -replace only this marked output; an unmarked same-id directory is user-owned -and causes a conflict. `web dsh doctor` verifies that all four snapshots are -current, complete, and free of scoped `tool-web` rows. +`web dsh sync` from the installed official rc.1 package. Sync recognizes it by +comparing its complete contents with the expected transformed official tree; +no ownership marker is stored. A different same-id directory requires explicit +interactive confirmation or `--yes`. `web dsh doctor` verifies that all four +snapshots are current, complete, and free of scoped `tool-web` rows. _Avoid_: Hand-edited output, shipped preset **Ordinary DSH User Preset**: -A user-created directory in the DSH user preset root without Guion's marker. -It remains visible when the bundle hides the shipped root and is never -overwritten or deleted by `web dsh sync`. -_Avoid_: Guion-managed preset, shipped preset +A user-created directory in the DSH user preset root. It remains visible when +the bundle hides the shipped root. A same-id preset is overwritten only after +interactive confirmation or an explicit `web dsh sync --yes`. +_Avoid_: Generated compatible preset, shipped preset diff --git a/README.md b/README.md index adbe854..81cc7bd 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ HTML-to-Markdown extraction and explicit browser rendering for client-rendered pages on supported hosts. > **DSH setup note:** Run `web dsh sync` before installing the DSH bundle and -> `web dsh doctor` afterward. The workflow creates Guion-managed copies of the +> `web dsh doctor` afterward. The workflow creates compatible copies of the > familiar stock preset ids and hides their official shipped duplicates while > preserving ordinary user presets. @@ -218,14 +218,15 @@ web dsh doctor ``` `web dsh sync` reads the installed official -`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates marked, -compatible `standard`, `ptc`, `cordis`, and `minimal` copies. It removes only +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates compatible +`standard`, `ptc`, `cordis`, and `minimal` copies. It removes only the top-level official `tool-web` row from the first three; Minimal is copied unchanged because it already omits that row. The command is idempotent and -refreshes only directories bearing Guion's marker. An existing unmarked -same-id directory is never overwritten: move it or choose another id before -retrying. Run sync again after upgrading the supported DSH runtime. The -read-only doctor command reports missing, incomplete, stale, and conflicting +compares existing same-id directories with both the official and compatible +trees. Exact matches are refreshed automatically. Modified same-id presets +require interactive confirmation; use `web dsh sync --yes` for an intentional +non-interactive overwrite. Run sync again after upgrading the supported DSH +runtime. The read-only doctor command reports missing, stale, and conflicting copies and exits nonzero when the roster is not ready. The bundle's preset roster sets `includeShippedRoot: false`, @@ -262,7 +263,7 @@ merges partial successes, and reports total failure clearly. `web_fetch` accepts `mode: "auto" | "full" | "tree"`, optional `section_id` with omitted or `auto` mode, `render: "http" | "browser"`, and browser `waitMs` from 0 through 30,000. `web_links` has the same rendering and wait contract. The -complete schemas are inherited by every managed stock-equivalent preset. +complete schemas are inherited by every compatible stock-equivalent preset. ## Page-rendering modes diff --git a/docs/adr/0004-dsh-research-surface-owner.md b/docs/adr/0004-dsh-research-surface-owner.md index 3012f47..0680f17 100644 --- a/docs/adr/0004-dsh-research-surface-owner.md +++ b/docs/adr/0004-dsh-research-surface-owner.md @@ -35,16 +35,17 @@ to take effect. `@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` tree and writes full snapshots of `standard`, `ptc`, `cordis`, and `minimal` under the user preset root. It removes only the expected top-level official `tool-web` row from the first -three and marks every output with its source version and identity. Sync may -replace only its marked outputs; it refuses unmarked same-id directories and -never edits the shipped package or ordinary user presets. `web dsh doctor` -performs the corresponding read-only checks. +three. Sync compares existing directories with the official and compatible +trees, refreshes exact matches automatically, and requires interactive +confirmation or `--yes` before replacing modified same-id content. It never +edits the shipped package. `web dsh doctor` performs the corresponding +read-only checks. ## Consequences Provider selection and credentials remain one live Guion settings surface, and native and PTC calls receive the same registered tools. Official shipped -presets remain deployment-owned and untouched; their same-id managed copies +presets remain deployment-owned and untouched; their same-id compatible copies are the supported path because the bundle hides the shipped root while retaining Yuki and other ordinary user presets. Existing sessions, credentials, and deployed profiles are not migrated automatically. Runtime upgrades require diff --git a/packages/dsh-web/README.md b/packages/dsh-web/README.md index eab4ae9..ff91744 100644 --- a/packages/dsh-web/README.md +++ b/packages/dsh-web/README.md @@ -12,15 +12,16 @@ web dsh doctor ``` `web dsh sync` reads the installed official -`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates marked copies +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates compatible copies with the familiar `standard`, `ptc`, `cordis`, and `minimal` ids in `${DSH_HOME:-$HOME/.dsh}/.agent-presets`. It removes only the top-level official `tool-web` row from `standard`, `ptc`, and `cordis`; Minimal is copied -unchanged. Sync is idempotent and refreshes only directories bearing Guion's -marker. It refuses an unmarked same-id directory, so a user's custom preset is -never overwritten or deleted. Run sync again after upgrading DSH. `doctor` is -read-only, reports missing/incomplete/conflicting/stale managed copies, and -exits nonzero until all four are current. +unchanged. Sync compares same-id directories with the official and compatible +trees and refreshes exact matches automatically. Modified same-id presets +require interactive confirmation; pass `--yes` only for an intentional +non-interactive overwrite. Run sync again after upgrading DSH. `doctor` is +read-only, reports missing/conflicting/stale copies, and exits nonzero until +all four are current. The bundle patch sets `includeShippedRoot: false`, `includeUserRoot: true`, and `default: standard`. This hides the official shipped duplicates while @@ -31,7 +32,7 @@ deploy the bundle only after sync and doctor succeed. The package owns the global DSH Research Surface and does not require a custom profile. It directly registers `web_search`, `web_fetch`, `web_links`, `web_docs`, and `web_source_search`; while Kepos Bridge is selected it also -registers `web_weather`, `web_sports`, `web_finance`, and `web_time`. Managed +registers `web_weather`, `web_sports`, `web_finance`, and `web_time`. Compatible stock-equivalent presets omit the scoped `tool-web` row so both native and PTC modes inherit these same global registrations. @@ -39,7 +40,7 @@ The profile patch disables the official DSH Web registry, official search and fetch providers, and official `tool-web`; it does not load or depend on the official `@deepseek-ai/dsh-web` package. -The Guion schemas are complete and shared by every managed preset: +The Guion schemas are complete and shared by every compatible preset: `web_search` takes one to four trimmed queries and preserves concurrent, deterministic partial results; `web_fetch` takes `mode: "auto" | "full" | "tree"`, optional `section_id` with omitted/`auto` mode, and explicit diff --git a/packages/web/src/dsh.ts b/packages/web/src/dsh.ts index 25950b4..a17e927 100644 --- a/packages/web/src/dsh.ts +++ b/packages/web/src/dsh.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { cp, lstat, @@ -21,18 +20,14 @@ import { parseDocument, type Document } from "yaml"; export const SUPPORTED_DSH_VERSION = "0.1.2-rc.1" as const; export const OFFICIAL_PRESET_PACKAGE = "@deepseek-ai/dsh-agent-presets" as const; -export const MANAGED_PRESET_IDS = [ +export const COMPATIBLE_PRESET_IDS = [ "standard", "ptc", "cordis", "minimal", ] as const; -export type ManagedPresetId = (typeof MANAGED_PRESET_IDS)[number]; +export type CompatiblePresetId = (typeof COMPATIBLE_PRESET_IDS)[number]; -/** The file which establishes that a preset directory is replaceable by Guion. */ -export const MANAGED_MARKER_FILE = ".guion-managed.json" as const; -const MANAGED_MARKER_VERSION = 1 as const; -const MANAGED_BY = "@guionai/web" as const; const USER_PRESET_DIRECTORY = ".agent-presets" as const; /** @@ -40,28 +35,6 @@ const USER_PRESET_DIRECTORY = ".agent-presets" as const; * When omitted, sync and doctor discover the installed `dsh` executable and * use the same DSH_HOME precedence as the official harness. */ -export type DshRenamePhase = - | "before-backup-rename" - | "after-backup-rename" - | "before-install-rename" - | "after-install-rename" - | "before-backup-delete"; - -export interface DshRenameEvent { - readonly id: ManagedPresetId; - readonly phase: DshRenamePhase; - readonly target: string; - readonly staged?: string; - readonly backup?: string; -} - -export interface DshSyncHooks { - /** A test-owned seam for deterministic filesystem races and failures. */ - readonly beforeRename?: - | ((event: DshRenameEvent) => void | Promise) - | undefined; -} - export interface DshPathOverrides { dshExecutable?: string; dshHome?: string; @@ -69,7 +42,13 @@ export interface DshPathOverrides { sourcePresetRoot?: string; userPresetRoot?: string; environment?: NodeJS.ProcessEnv; - hooks?: DshSyncHooks; +} + +export interface DshSyncOptions { + readonly yes?: boolean; + readonly confirmOverwrite?: ( + ids: readonly CompatiblePresetId[], + ) => boolean | Promise; } export interface DshRuntimeInfo { @@ -80,19 +59,18 @@ export interface DshRuntimeInfo { readonly sourcePresetRoot: string; readonly sourcePackageName: typeof OFFICIAL_PRESET_PACKAGE; readonly sourcePackageVersion: typeof SUPPORTED_DSH_VERSION; - readonly sourceIdentity: string; } export interface DshSyncResult { readonly runtime: DshRuntimeInfo; - readonly ids: readonly ManagedPresetId[]; - readonly replaced: readonly ManagedPresetId[]; - readonly created: readonly ManagedPresetId[]; + readonly ids: readonly CompatiblePresetId[]; + readonly replaced: readonly CompatiblePresetId[]; + readonly created: readonly CompatiblePresetId[]; } export interface DshDoctorPreset { - readonly id: ManagedPresetId; - readonly status: "ok" | "missing" | "incomplete" | "conflict" | "stale"; + readonly id: CompatiblePresetId; + readonly status: "ok" | "missing" | "conflict" | "stale"; readonly detail?: string; } @@ -172,31 +150,19 @@ async function canonicalPath(path: string): Promise { } async function packageRootForExecutable(executable: string): Promise { - let resolvedExecutable = await canonicalPath(executable); - // npm and pnpm expose `dsh` as a generated POSIX wrapper rather than a - // symlink. Read the wrapper's absolute Node entry so discovery follows the - // runtime actually selected by PATH. - try { - const launcher = await readFile(resolvedExecutable, "utf8"); - const suffix = "/node_modules/@deepseek-ai/dsh/lib/bin.js"; - const suffixAt = launcher.indexOf(suffix); - if (suffixAt >= 0) { - let tokenStart = suffixAt; - while (tokenStart > 0 && !/\s|["'`]/.test(launcher[tokenStart - 1]!)) - tokenStart -= 1; - const token = launcher.slice(tokenStart, suffixAt + suffix.length); - resolvedExecutable = await canonicalPath( - token.startsWith("/") - ? token - : join( - dirname(resolvedExecutable), - token.replace(/^\$basedir(?:_win)?\//, ""), - ), - ); - } - } catch { - // A direct JavaScript entry is handled by the normal ancestor walk. - } + const resolvedExecutable = await canonicalPath(executable); + // npm and pnpm put generated wrappers in node_modules/.bin beside the + // package they launch. This avoids depending on package-manager shim text. + const adjacentPackage = resolve( + dirname(resolvedExecutable), + "..", + "@deepseek-ai", + "dsh", + ); + if ((await readPackageManifest(adjacentPackage))?.name === "@deepseek-ai/dsh") + return canonicalPath(adjacentPackage); + + // Direct JavaScript entries and symlinked bins resolve inside the package. let current = dirname(resolvedExecutable); while (true) { try { @@ -391,18 +357,6 @@ async function listFiles(root: string, prefix = ""): Promise { return files; } -async function sourceIdentity(root: string, version: string): Promise { - const hash = createHash("sha256"); - hash.update(`${OFFICIAL_PRESET_PACKAGE}\0${version}\0`); - for (const file of await listFiles(root)) { - hash.update(file); - hash.update("\0"); - hash.update(await readFile(join(root, file))); - hash.update("\0"); - } - return `sha256:${hash.digest("hex")}`; -} - function parseYaml( source: string, filename: string, @@ -499,7 +453,7 @@ function validateComposition( async function validateSourceTree( sourceRoot: string, - id: ManagedPresetId, + id: CompatiblePresetId, removeWeb: boolean, ): Promise<{ composition: string; @@ -566,7 +520,7 @@ async function validateSourceTree( } validateComposition( transformedComposition, - `${id}/agent.cordis.yml (managed)`, + `${id}/agent.cordis.yml (compatible)`, false, ); return { composition, metadata, transformedComposition }; @@ -594,7 +548,6 @@ async function resolveRuntime( "dsh-source-missing", `could not resolve the official preset root: ${discovered.presetRoot}`, ); - const identity = await sourceIdentity(discovered.presetRoot, version); const dshHome = resolveHome(options); const userPresetRoot = resolve( options.userPresetRoot ?? join(dshHome, USER_PRESET_DIRECTORY), @@ -607,7 +560,6 @@ async function resolveRuntime( sourcePresetRoot: discovered.presetRoot, sourcePackageName: OFFICIAL_PRESET_PACKAGE, sourcePackageVersion: SUPPORTED_DSH_VERSION, - sourceIdentity: identity, }; } @@ -617,68 +569,6 @@ export async function resolveDshRuntime( return resolveRuntime(options); } -function markerFor( - runtime: DshRuntimeInfo, - id: ManagedPresetId, -): UnknownRecord { - return { - managedBy: MANAGED_BY, - markerVersion: MANAGED_MARKER_VERSION, - presetId: id, - sourcePackage: runtime.sourcePackageName, - sourcePackageVersion: runtime.sourcePackageVersion, - sourceIdentity: runtime.sourceIdentity, - sourcePreset: `${OFFICIAL_PRESET_PACKAGE}/presets/${id}`, - }; -} - -function isManagedMarker( - value: unknown, - id: ManagedPresetId, -): value is UnknownRecord { - return ( - isRecord(value) && - value.managedBy === MANAGED_BY && - value.markerVersion === MANAGED_MARKER_VERSION && - value.presetId === id - ); -} - -async function readMarker( - directory: string, - id: ManagedPresetId, -): Promise { - try { - const value: unknown = JSON.parse( - await readFile(join(directory, MANAGED_MARKER_FILE), "utf8"), - ); - return isManagedMarker(value, id) ? value : undefined; - } catch { - return undefined; - } -} - -async function targetKind( - root: string, - id: ManagedPresetId, -): Promise<{ - kind: "missing" | "managed" | "conflict"; - marker?: UnknownRecord; -}> { - const directory = join(root, id); - let info; - try { - info = await lstat(directory); - } catch { - return { kind: "missing" }; - } - if (!info.isDirectory()) return { kind: "conflict" }; - const marker = await readMarker(directory, id); - return marker === undefined - ? { kind: "conflict" } - : { kind: "managed", marker }; -} - async function assertUserRootSafe(root: string): Promise { try { const info = await lstat(root); @@ -693,17 +583,9 @@ async function assertUserRootSafe(root: string): Promise { } } -async function stagePreset( - stageRoot: string, - sourceRoot: string, - runtime: DshRuntimeInfo, - id: ManagedPresetId, -): Promise { - const removeWeb = id !== "minimal"; - const validation = await validateSourceTree(sourceRoot, id, removeWeb); - const sourceDirectory = join(sourceRoot, id); - const stagedDirectory = join(stageRoot, id); - await cp(sourceDirectory, stagedDirectory, { +async function stagePreset(stageRoot: string, plan: PresetPlan): Promise { + const stagedDirectory = join(stageRoot, plan.id); + await cp(plan.sourceDirectory, stagedDirectory, { recursive: true, force: false, errorOnExist: true, @@ -711,392 +593,190 @@ async function stagePreset( }); await writeFile( join(stagedDirectory, "agent.cordis.yml"), - validation.transformedComposition, - "utf8", - ); - await writeFile( - join(stagedDirectory, MANAGED_MARKER_FILE), - `${JSON.stringify(markerFor(runtime, id), null, 2)}\n`, - "utf8", + plan.compatible.get("agent.cordis.yml")!, ); } -type ManagedSnapshot = ReadonlyMap; +type PresetSnapshot = ReadonlyMap; +type PresetPlan = { + readonly id: CompatiblePresetId; + readonly sourceDirectory: string; + readonly official: PresetSnapshot; + readonly compatible: PresetSnapshot; +}; +type TargetState = { + readonly kind: "missing" | "official" | "compatible" | "conflict"; + readonly detail?: string; +}; -/** - * Build the exact byte snapshot that a managed copy is allowed to contain. - * The official composition is the one transformed by this integration; every - * other source file is copied byte-for-byte. The marker is generated by - * Guion and is therefore deliberately excluded from the source snapshot. - */ -async function expectedManagedSnapshot( +async function buildPresetPlan( runtime: DshRuntimeInfo, - id: ManagedPresetId, -): Promise { + id: CompatiblePresetId, +): Promise { const validation = await validateSourceTree( runtime.sourcePresetRoot, id, id !== "minimal", ); const sourceDirectory = join(runtime.sourcePresetRoot, id); - const expected = new Map(); + const official = new Map(); for (const file of await listFiles(sourceDirectory)) { - if (file === MANAGED_MARKER_FILE) continue; - expected.set( - file, - file === "agent.cordis.yml" - ? Buffer.from(validation.transformedComposition) - : await readFile(join(sourceDirectory, file)), - ); + official.set(file, await readFile(join(sourceDirectory, file))); } - return expected; -} - -async function snapshotMismatch( - runtime: DshRuntimeInfo, - id: ManagedPresetId, - directory: string, -): Promise { - return compareManagedSnapshot( - await expectedManagedSnapshot(runtime, id), - directory, + const compatible = new Map(official); + compatible.set( + "agent.cordis.yml", + Buffer.from(validation.transformedComposition), ); + return { id, sourceDirectory, official, compatible }; } -async function compareManagedSnapshot( - expected: ManagedSnapshot, +async function compareSnapshot( + expected: PresetSnapshot, directory: string, ): Promise { - const expectedFiles = new Set([...expected.keys(), MANAGED_MARKER_FILE]); + const expectedFiles = new Set(expected.keys()); const actualFiles = new Set(await listFiles(directory)); for (const file of expectedFiles) - if (!actualFiles.has(file)) return `managed file is missing: ${file}`; + if (!actualFiles.has(file)) return `file is missing: ${file}`; for (const file of actualFiles) - if (!expectedFiles.has(file)) return `unexpected managed file: ${file}`; + if (!expectedFiles.has(file)) return `unexpected file: ${file}`; for (const [file, expectedBytes] of expected) { const actualBytes = await readFile(join(directory, file)); if ( actualBytes.length !== expectedBytes.length || !actualBytes.equals(expectedBytes) ) - return `managed file differs from the official source: ${file}`; + return `file differs: ${file}`; } return undefined; } -async function managedTargetMatches( - runtime: DshRuntimeInfo, - id: ManagedPresetId, +async function inspectTarget( + plan: PresetPlan, directory: string, - expected?: ManagedSnapshot, -): Promise { - const marker = await readMarker(directory, id); - if ( - marker === undefined || - marker.sourcePackage !== runtime.sourcePackageName || - marker.sourcePackageVersion !== runtime.sourcePackageVersion || - marker.sourceIdentity !== runtime.sourceIdentity - ) - return false; +): Promise { + let info; try { - return ( - (await compareManagedSnapshot( - expected ?? (await expectedManagedSnapshot(runtime, id)), - directory, - )) === undefined - ); - } catch { - return false; + info = await lstat(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return { kind: "missing" }; + throw error; } -} - -async function pathExists(path: string): Promise { + if (!info.isDirectory() || info.isSymbolicLink()) + return { kind: "conflict", detail: "same-id path is not a directory" }; try { - await lstat(path); - return true; + const compatibleMismatch = await compareSnapshot( + plan.compatible, + directory, + ); + if (compatibleMismatch === undefined) return { kind: "compatible" }; + if ((await compareSnapshot(plan.official, directory)) === undefined) + return { kind: "official" }; + return { kind: "conflict", detail: compatibleMismatch }; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; - throw error; + return { + kind: "conflict", + detail: error instanceof Error ? error.message : "preset cannot be read", + }; } } -async function invokeRenameHook( - options: DshPathOverrides, - event: DshRenameEvent, -): Promise { - await options.hooks?.beforeRename?.(event); -} - -export async function syncManagedPresets( - options: DshPathOverrides = {}, -): Promise { - const runtime = await resolveRuntime(options); - return syncManagedPresetsLocked(runtime, options); -} - -async function syncManagedPresetsLocked( - runtime: DshRuntimeInfo, - options: DshPathOverrides, +export async function syncCompatiblePresets( + paths: DshPathOverrides = {}, + options: DshSyncOptions = {}, ): Promise { - // Validate every source tree before creating the user root or staging files. - for (const id of MANAGED_PRESET_IDS) - await validateSourceTree(runtime.sourcePresetRoot, id, id !== "minimal"); + const runtime = await resolveRuntime(paths); + const plans = await Promise.all( + COMPATIBLE_PRESET_IDS.map((id) => buildPresetPlan(runtime, id)), + ); await assertUserRootSafe(runtime.userPresetRoot); - - const existing = new Map< - ManagedPresetId, - Awaited> - >(); - for (const id of MANAGED_PRESET_IDS) { - const result = await targetKind(runtime.userPresetRoot, id); - existing.set(id, result); - if (result.kind === "conflict") - throw new DshPresetError( - "dsh-preset-conflict", - `refusing to overwrite unmarked same-id preset ${join(runtime.userPresetRoot, id)}; move it or add a Guion marker explicitly`, - ); - } + const states = new Map(); + const conflicts: CompatiblePresetId[] = []; + for (const plan of plans) { + const state = await inspectTarget( + plan, + join(runtime.userPresetRoot, plan.id), + ); + states.set(plan.id, state); + if (state.kind === "conflict") conflicts.push(plan.id); + } + const confirmed = + conflicts.length === 0 || + options.yes === true || + (options.confirmOverwrite !== undefined && + (await options.confirmOverwrite(conflicts))); + if (!confirmed) + throw new DshPresetError( + "dsh-confirmation-required", + `refusing to overwrite modified same-id preset${conflicts.length === 1 ? "" : "s"} ${conflicts.join(", ")}; rerun interactively or pass --yes`, + ); await mkdir(runtime.userPresetRoot, { recursive: true }); const stageRoot = await mkdtemp(join(runtime.dshHome, ".guion-dsh-presets-")); - const snapshots = new Map(); - const backups: Array<{ - id: ManagedPresetId; - target: string; - backup: string; - }> = []; - const installed: Array<{ - id: ManagedPresetId; - target: string; - expected: ManagedSnapshot; - }> = []; - const replaced: ManagedPresetId[] = []; - const created: ManagedPresetId[] = []; - let backupNonce = 0; + const replaced: CompatiblePresetId[] = []; + const created: CompatiblePresetId[] = []; try { - for (const id of MANAGED_PRESET_IDS) { - snapshots.set(id, await expectedManagedSnapshot(runtime, id)); - await stagePreset(stageRoot, runtime.sourcePresetRoot, runtime, id); - } - - // Recheck all targets after staging and before the first replacement. A - // failed preflight therefore cannot leave a partially refreshed roster. - for (const id of MANAGED_PRESET_IDS) { - const result = await targetKind(runtime.userPresetRoot, id); - if (result.kind === "conflict" || result.kind !== existing.get(id)!.kind) + for (const plan of plans) await stagePreset(stageRoot, plan); + for (const plan of plans) { + const target = join(runtime.userPresetRoot, plan.id); + const current = await inspectTarget(plan, target); + if ( + current.kind === "conflict" && + states.get(plan.id)?.kind !== "conflict" && + options.yes !== true + ) throw new DshPresetError( "dsh-preset-conflict", - `same-id preset changed while preparing ${join(runtime.userPresetRoot, id)}; refusing to overwrite it`, + `same-id preset changed after confirmation: ${target}`, ); - } - - for (const id of MANAGED_PRESET_IDS) { - const target = join(runtime.userPresetRoot, id); - const expected = snapshots.get(id)!; - // Revalidate ownership immediately before each rename. The hook is - // intentionally called after this check so tests can deterministically - // model a race in the tiny rename window and exercise post-rename - // marker verification. - const current = await targetKind(runtime.userPresetRoot, id); - if (current.kind !== existing.get(id)!.kind) - throw new DshPresetError( - "dsh-preset-conflict", - `same-id preset changed before replacement ${target}; refusing to overwrite it`, - ); - if (current.kind === "managed") { - const backup = join( - runtime.userPresetRoot, - `.guion-dsh-backup-${id}-${process.pid}-${Date.now()}-${backupNonce++}`, - ); - await invokeRenameHook(options, { - id, - phase: "before-backup-rename", - target, - staged: join(stageRoot, id), - backup, - }); - await rename(target, backup); - if ((await readMarker(backup, id)) === undefined) { - // A same-ID directory may have been swapped after the ownership - // check. Restore it immediately and never make it deletable. - try { - if (!(await pathExists(target))) await rename(backup, target); - } catch { - // Keep the unknown directory at the backup path if restoration is - // itself raced; rollback below will also leave it untouched. - } - throw new DshPresetError( - "dsh-preset-conflict", - `same-id preset changed during replacement ${target}; unknown data was preserved`, - ); - } - backups.push({ id, target, backup }); - await invokeRenameHook(options, { - id, - phase: "after-backup-rename", - target, - staged: join(stageRoot, id), - backup, - }); - replaced.push(id); - } else { - created.push(id); - } - const staged = join(stageRoot, id); - await invokeRenameHook(options, { - id, - phase: "before-install-rename", - target, - staged, - backup: backups.at(-1)?.id === id ? backups.at(-1)?.backup : undefined, - }); - if ((await targetKind(runtime.userPresetRoot, id)).kind !== "missing") - throw new DshPresetError( - "dsh-preset-conflict", - `same-id preset appeared before install ${target}; refusing to overwrite it`, - ); - await rename(staged, target); - installed.push({ id, target, expected }); - if (!(await managedTargetMatches(runtime, id, target, expected))) - throw new DshPresetError( - "dsh-preset-incomplete", - `managed preset install did not match the expected source snapshot: ${id}`, - ); - await invokeRenameHook(options, { - id, - phase: "after-install-rename", - target, - backup: backups.at(-1)?.id === id ? backups.at(-1)?.backup : undefined, - }); - } - - // Validate every backup before deleting any of them. If cleanup itself - // fails, retaining a verified Guion-managed backup is safe and recoverable. - for (const backup of backups) - await invokeRenameHook(options, { - id: backup.id, - phase: "before-backup-delete", - target: backup.target, - backup: backup.backup, - }); - for (const backup of backups) { - if ((await readMarker(backup.backup, backup.id)) === undefined) - throw new DshPresetError( - "dsh-preset-conflict", - `backup for ${backup.id} is no longer Guion-managed; preserving it at ${backup.backup}`, - ); - } - for (const backup of backups) { - try { - await rm(backup.backup, { recursive: true, force: true }); - } catch { - // A cleanup failure must not turn a successful, complete roster into - // a destructive rollback. The verified backup remains recoverable. - } - } - return { runtime, ids: MANAGED_PRESET_IDS, replaced, created }; - } catch (error) { - // Remove only targets that still exactly match this transaction's staged - // snapshot, then restore backups. Unknown data is never deleted. - for (const entry of [...installed].reverse()) { - try { - if ( - await managedTargetMatches( - runtime, - entry.id, - entry.target, - entry.expected, - ) - ) - await rm(entry.target, { recursive: true, force: true }); - } catch { - // Leave anything that cannot be proven to be ours untouched. + const staged = join(stageRoot, plan.id); + if (current.kind === "missing") { + await rename(staged, target); + created.push(plan.id); + continue; } - } - for (const backup of [...backups].reverse()) { + const backup = join( + runtime.userPresetRoot, + `.guion-dsh-backup-${plan.id}-${process.pid}-${Date.now()}`, + ); + await rename(target, backup); try { - if (await pathExists(backup.target)) { - if (await managedTargetMatches(runtime, backup.id, backup.target)) - await rm(backup.target, { recursive: true, force: true }); - else continue; - } - if (await pathExists(backup.backup)) - await rename(backup.backup, backup.target); - } catch { - // Preserve both paths when restoration is not safe. + await rename(staged, target); + } catch (error) { + await rename(backup, target); + throw error; } + replaced.push(plan.id); + await rm(backup, { recursive: true, force: true }).catch(() => undefined); } - throw error; + return { runtime, ids: COMPATIBLE_PRESET_IDS, replaced, created }; } finally { - try { - await rm(stageRoot, { recursive: true, force: true }); - } catch { - // Staged data is disposable; never mask the sync result or error. - } + await rm(stageRoot, { recursive: true, force: true }).catch( + () => undefined, + ); } } -async function inspectManagedPreset( - runtime: DshRuntimeInfo, - id: ManagedPresetId, +async function inspectCompatiblePreset( + plan: PresetPlan, + userPresetRoot: string, ): Promise { - const directory = join(runtime.userPresetRoot, id); - let info; - try { - info = await lstat(directory); - } catch { + const state = await inspectTarget(plan, join(userPresetRoot, plan.id)); + if (state.kind === "compatible") return { id: plan.id, status: "ok" }; + if (state.kind === "missing") + return { id: plan.id, status: "missing", detail: "preset is missing" }; + if (state.kind === "official") return { - id, - status: "missing", - detail: "managed preset directory is missing", - }; - } - if (!info.isDirectory() || info.isSymbolicLink()) - return { - id, - status: "conflict", - detail: "same-id path is not a directory", - }; - let markerValue: unknown; - try { - markerValue = JSON.parse( - await readFile(join(directory, MANAGED_MARKER_FILE), "utf8"), - ); - } catch { - return { - id, - status: "conflict", - detail: `missing or invalid ${MANAGED_MARKER_FILE}`, - }; - } - if (!isManagedMarker(markerValue, id)) - return { id, status: "conflict", detail: "directory is not Guion-managed" }; - if ( - markerValue.sourcePackage !== runtime.sourcePackageName || - markerValue.sourcePackageVersion !== runtime.sourcePackageVersion || - markerValue.sourceIdentity !== runtime.sourceIdentity - ) - return { - id, + id: plan.id, status: "stale", - detail: "managed copy was generated from a different official source", - }; - try { - const mismatch = await snapshotMismatch(runtime, id, directory); - if (mismatch !== undefined) - return { id, status: "incomplete", detail: mismatch }; - } catch (error) { - return { - id, - status: "incomplete", - detail: - error instanceof Error ? error.message : "managed files are incomplete", + detail: "official preset has not been converted; run web dsh sync", }; - } - return { id, status: "ok" }; + return { id: plan.id, status: "conflict", detail: state.detail }; } -export async function inspectManagedPresets( +export async function inspectCompatiblePresets( options: DshPathOverrides = {}, ): Promise { let runtime: DshRuntimeInfo; @@ -1116,9 +796,10 @@ export async function inspectManagedPresets( } const presets: DshDoctorPreset[] = []; const issues: string[] = []; - for (const id of MANAGED_PRESET_IDS) { + const plans: PresetPlan[] = []; + for (const id of COMPATIBLE_PRESET_IDS) { try { - await validateSourceTree(runtime.sourcePresetRoot, id, id !== "minimal"); + plans.push(await buildPresetPlan(runtime, id)); } catch (error) { issues.push(error instanceof Error ? error.message : String(error)); } @@ -1127,11 +808,14 @@ export async function inspectManagedPresets( issues.push(error instanceof Error ? error.message : String(error)); }); if (issues.length === 0) { - for (const id of MANAGED_PRESET_IDS) { - const preset = await inspectManagedPreset(runtime, id); + for (const plan of plans) { + const preset = await inspectCompatiblePreset( + plan, + runtime.userPresetRoot, + ); presets.push(preset); if (preset.status !== "ok") - issues.push(`${id}: ${preset.detail ?? preset.status}`); + issues.push(`${plan.id}: ${preset.detail ?? preset.status}`); } } return { @@ -1143,19 +827,12 @@ export async function inspectManagedPresets( }; } -export async function doctorManagedPresets( - options: DshPathOverrides = {}, -): Promise { - return inspectManagedPresets(options); -} - export function formatDshDoctor(report: DshDoctorReport): string { const lines = [`DSH doctor: ${report.ok ? "OK" : "FAILED"}`]; if (report.runtime !== undefined) { lines.push( `Source: ${report.runtime.sourcePackageName}@${report.runtime.sourcePackageVersion}`, ); - lines.push(`Source identity: ${report.runtime.sourceIdentity}`); lines.push(`Preset root: ${report.runtime.userPresetRoot}`); } else if (report.userPresetRoot !== undefined) lines.push(`Preset root: ${report.userPresetRoot}`); @@ -1169,7 +846,7 @@ export function formatDshDoctor(report: DshDoctorReport): string { export function formatDshSync(result: DshSyncResult): string { const action = - result.created.length === MANAGED_PRESET_IDS.length + result.created.length === COMPATIBLE_PRESET_IDS.length ? "created all four presets" : [ result.created.length > 0 @@ -1181,5 +858,5 @@ export function formatDshSync(result: DshSyncResult): string { ] .filter(Boolean) .join("; "); - return `DSH managed presets ${action} from ${result.runtime.sourcePackageName}@${result.runtime.sourcePackageVersion}.\nPreset root: ${result.runtime.userPresetRoot}\n`; + return `DSH compatible presets ${action} from ${result.runtime.sourcePackageName}@${result.runtime.sourcePackageVersion}.\nPreset root: ${result.runtime.userPresetRoot}\n`; } diff --git a/packages/web/src/program.ts b/packages/web/src/program.ts index f182886..ba4b5eb 100644 --- a/packages/web/src/program.ts +++ b/packages/web/src/program.ts @@ -1,11 +1,12 @@ import { Command } from "commander"; +import { createInterface } from "node:readline/promises"; import { createMcpCommand } from "./mcp.js"; import { - doctorManagedPresets, + inspectCompatiblePresets, formatDshDoctor, formatDshSync, - syncManagedPresets, + syncCompatiblePresets, type DshPathOverrides, } from "./dsh.js"; import { parseHttpPort, startHttpServer } from "./serve.js"; @@ -28,6 +29,7 @@ export type ProgramDependencies = { writeOut?: (text: string) => void; /** Optional explicit DSH paths used by tests and embedded callers. */ dsh?: DshPathOverrides; + confirmDshOverwrite?: (ids: readonly string[]) => boolean | Promise; }; export function createProgram(dependencies: ProgramDependencies): Command { @@ -56,23 +58,46 @@ function createDshCommand(dependencies: ProgramDependencies): Command { dependencies.writeOut ?? ((text: string) => process.stdout.write(text)); const options = dependencies.dsh ?? {}; return new Command("dsh") - .description("Synchronize and diagnose Guion-managed DSH presets") + .description("Synchronize and diagnose Guion-compatible DSH presets") .addCommand( new Command("sync") .description("Create or refresh compatible stock-equivalent presets") - .action(async () => { - const result = await syncManagedPresets(options); + .option( + "-y, --yes", + "overwrite modified same-id presets without prompting", + ) + .action(async (commandOptions: { yes?: boolean }) => { + const result = await syncCompatiblePresets(options, { + yes: commandOptions.yes, + confirmOverwrite: async (ids) => { + if (dependencies.confirmDshOverwrite !== undefined) + return dependencies.confirmDshOverwrite(ids); + if (!process.stdin.isTTY || !process.stderr.isTTY) return false; + const prompt = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const answer = await prompt.question( + `Overwrite modified same-id presets ${ids.join(", ")}? [y/N] `, + ); + return /^(?:y|yes)$/i.test(answer.trim()); + } finally { + prompt.close(); + } + }, + }); writeOut(formatDshSync(result)); }), ) .addCommand( new Command("doctor") - .description("Check managed preset health without changing files") + .description("Check compatible preset health without changing files") .action(async () => { - const report = await doctorManagedPresets(options); + const report = await inspectCompatiblePresets(options); writeOut(formatDshDoctor(report)); if (!report.ok) - throw new Error("DSH managed preset doctor found problems"); + throw new Error("DSH compatible preset doctor found problems"); }), ); } diff --git a/packages/web/src/runner.ts b/packages/web/src/runner.ts index 7cc7dcc..3051856 100644 --- a/packages/web/src/runner.ts +++ b/packages/web/src/runner.ts @@ -11,6 +11,7 @@ export type CliDependencies = { credentials: () => WebCredentials; /** Optional test-owned DSH runtime/home seam for `web dsh` commands. */ dsh?: DshPathOverrides; + confirmDshOverwrite?: (ids: readonly string[]) => boolean | Promise; }; export type CliOutput = { diff --git a/packages/web/test/dsh.test.ts b/packages/web/test/dsh.test.ts index 5eb0203..777ae7e 100644 --- a/packages/web/test/dsh.test.ts +++ b/packages/web/test/dsh.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; import { + cp, mkdir, mkdtemp, readFile, + realpath, readdir, - rename, rm, stat, writeFile, @@ -17,12 +18,11 @@ import { createWebOperations } from "@guionai/web-core"; import { runCli } from "../src/runner.js"; import { DshPresetError, - MANAGED_MARKER_FILE, - MANAGED_PRESET_IDS, + COMPATIBLE_PRESET_IDS, formatDshDoctor, - inspectManagedPresets, + inspectCompatiblePresets, resolveDshRuntime, - syncManagedPresets, + syncCompatiblePresets, type DshPathOverrides, } from "../src/dsh.js"; @@ -42,7 +42,6 @@ const sourceComposition = (id: string, includeWeb: boolean): string => ...(includeWeb ? [ "", - "# The source row is removed structurally by sync.", "- id: tool-web", " name: '@deepseek-ai/dsh-tool-web'", " config:", @@ -69,12 +68,9 @@ async function fixture(): Promise { await mkdir(sourcePresetRoot, { recursive: true }); await writeFile( join(sourcePackageRoot, "package.json"), - JSON.stringify({ - name: "@deepseek-ai/dsh-agent-presets", - version: "0.1.2-rc.1", - }) + "\n", + `${JSON.stringify({ name: "@deepseek-ai/dsh-agent-presets", version: "0.1.2-rc.1" })}\n`, ); - for (const id of MANAGED_PRESET_IDS) { + for (const id of COMPATIBLE_PRESET_IDS) { const directory = join(sourcePresetRoot, id); await mkdir(directory, { recursive: true }); await writeFile( @@ -96,99 +92,103 @@ async function fixture(): Promise { }; } -async function dispose(value: Fixture): Promise { - await rm(value.root, { recursive: true, force: true }); +async function readComposition(value: Fixture, id: string): Promise { + return readFile( + join(value.dshHome, ".agent-presets", id, "agent.cordis.yml"), + "utf8", + ); } -async function readPreset( - fixtureValue: Fixture, - id: string, -): Promise<{ - composition: string; - marker: Record; -}> { - const directory = join(fixtureValue.dshHome, ".agent-presets", id); - return { - composition: await readFile(join(directory, "agent.cordis.yml"), "utf8"), - marker: JSON.parse( - await readFile(join(directory, MANAGED_MARKER_FILE), "utf8"), - ) as Record, - }; +async function dispose(value: Fixture): Promise { + await rm(value.root, { recursive: true, force: true }); } -describe("managed DSH presets", () => { - it("derives all four same-id presets and removes only official tool-web rows", async () => { +describe("compatible DSH presets", () => { + it("derives all four same-id presets without ownership metadata", async () => { const value = await fixture(); try { - const result = await syncManagedPresets(value.options); - expect(result.created).toEqual([...MANAGED_PRESET_IDS]); - expect(result.replaced).toEqual([]); - for (const id of MANAGED_PRESET_IDS) { - const preset = await readPreset(value, id); - const rows = parse(preset.composition) as Array< + const result = await syncCompatiblePresets(value.options); + expect(result.created).toEqual([...COMPATIBLE_PRESET_IDS]); + for (const id of COMPATIBLE_PRESET_IDS) { + const directory = join(value.dshHome, ".agent-presets", id); + const rows = parse(await readComposition(value, id)) as Array< Record >; expect(rows.map((row) => row.id)).toEqual(["persona", "tail"]); - expect(preset.marker).toMatchObject({ - managedBy: "@guionai/web", - markerVersion: 1, - presetId: id, - sourcePackage: "@deepseek-ai/dsh-agent-presets", - sourcePackageVersion: "0.1.2-rc.1", - sourcePreset: `@deepseek-ai/dsh-agent-presets/presets/${id}`, - }); - expect(typeof preset.marker.sourceIdentity).toBe("string"); + expect((await readdir(directory)).sort()).toEqual([ + "agent.cordis.yml", + "preset.yml", + "prompt.md", + ]); } - const minimal = await readPreset(value, "minimal"); - expect(minimal.composition).toContain("# minimal fixture composition"); - expect(minimal.composition).not.toContain("tool-web"); } finally { await dispose(value); } }); - it("refreshes marked copies idempotently while retaining ordinary user presets", async () => { + it("refreshes compatible copies and retains unrelated user presets", async () => { const value = await fixture(); try { - await syncManagedPresets(value.options); + await syncCompatiblePresets(value.options); const userRoot = join(value.dshHome, ".agent-presets"); - await mkdir(join(userRoot, "yuki"), { recursive: true }); + await mkdir(join(userRoot, "yuki")); await writeFile( join(userRoot, "yuki", "agent.cordis.yml"), "- id: yuki\n name: fixture:yuki\n", ); - const first = await readPreset(value, "standard"); - const second = await syncManagedPresets(value.options); - expect(second.replaced).toEqual([...MANAGED_PRESET_IDS]); - expect(second.created).toEqual([]); - expect(await readPreset(value, "standard")).toEqual(first); - expect( - await stat(join(userRoot, "yuki", "agent.cordis.yml")), - ).toBeDefined(); + const before = await readComposition(value, "standard"); + const result = await syncCompatiblePresets(value.options); + expect(result.replaced).toEqual([...COMPATIBLE_PRESET_IDS]); + expect(await readComposition(value, "standard")).toBe(before); expect((await readdir(userRoot)).sort()).toEqual( - [...MANAGED_PRESET_IDS, "yuki"].sort(), + [...COMPATIBLE_PRESET_IDS, "yuki"].sort(), ); } finally { await dispose(value); } }); - it("preflights unmarked same-id conflicts without mutating any preset", async () => { + it("converts an exact official copy without confirmation", async () => { const value = await fixture(); try { - const userRoot = join(value.dshHome, ".agent-presets"); - await mkdir(join(userRoot, "standard"), { recursive: true }); - await writeFile( - join(userRoot, "standard", "agent.cordis.yml"), - "user-owned standard\n", + const target = join(value.dshHome, ".agent-presets", "standard"); + await mkdir(join(value.dshHome, ".agent-presets"), { recursive: true }); + await cp(join(value.sourcePresetRoot, "standard"), target, { + recursive: true, + }); + const result = await syncCompatiblePresets(value.options); + expect(result.replaced).toContain("standard"); + expect(await readComposition(value, "standard")).not.toContain( + "tool-web", + ); + } finally { + await dispose(value); + } + }); + + it("requires confirmation before replacing modified same-id content", async () => { + const value = await fixture(); + try { + const target = join(value.dshHome, ".agent-presets", "standard"); + await mkdir(target, { recursive: true }); + await writeFile(join(target, "operator.txt"), "keep me\n"); + await expect(syncCompatiblePresets(value.options)).rejects.toMatchObject({ + code: "dsh-confirmation-required", + }); + expect(await readFile(join(target, "operator.txt"), "utf8")).toBe( + "keep me\n", ); - await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ - code: "dsh-preset-conflict", + let prompted: readonly string[] = []; + await syncCompatiblePresets(value.options, { + confirmOverwrite: (ids) => { + prompted = ids; + return true; + }, }); - expect( - await readFile(join(userRoot, "standard", "agent.cordis.yml"), "utf8"), - ).toBe("user-owned standard\n"); - expect(await readdir(userRoot)).toEqual(["standard"]); + expect(prompted).toEqual(["standard"]); + expect(await readComposition(value, "standard")).not.toContain( + "tool-web", + ); } finally { await dispose(value); } @@ -199,41 +199,22 @@ describe("managed DSH presets", () => { try { await writeFile( join(value.sourcePackageRoot, "package.json"), - JSON.stringify({ - name: "@deepseek-ai/dsh-agent-presets", - version: "0.1.1", - }) + "\n", + `${JSON.stringify({ name: "@deepseek-ai/dsh-agent-presets", version: "0.1.1" })}\n`, ); - await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ + await expect(syncCompatiblePresets(value.options)).rejects.toMatchObject({ code: "dsh-source-unsupported", }); expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); await writeFile( join(value.sourcePackageRoot, "package.json"), - JSON.stringify({ - name: "@deepseek-ai/dsh-agent-presets", - version: "0.1.2-rc.1", - }) + "\n", + `${JSON.stringify({ name: "@deepseek-ai/dsh-agent-presets", version: "0.1.2-rc.1" })}\n`, ); - await writeFile( join(value.sourcePresetRoot, "standard", "agent.cordis.yml"), sourceComposition("standard", false), ); - await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ - code: "dsh-source-invalid", - }); - expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); - - await writeFile( - join(value.sourcePresetRoot, "standard", "agent.cordis.yml"), - sourceComposition("standard", true).replace( - " config:\n", - " extra: true\n config:\n", - ), - ); - await expect(syncManagedPresets(value.options)).rejects.toMatchObject({ + await expect(syncCompatiblePresets(value.options)).rejects.toMatchObject({ code: "dsh-source-invalid", }); expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); @@ -242,214 +223,92 @@ describe("managed DSH presets", () => { } }); - it("doctor is read-only and distinguishes missing, incomplete, stale, and conflict outputs", async () => { + it("doctor compares output with the official and compatible trees", async () => { const value = await fixture(); try { - const missing = await inspectManagedPresets(value.options); - expect(missing.ok).toBe(false); - expect(missing.presets.map((preset) => preset.status)).toEqual([ - "missing", - "missing", - "missing", - "missing", - ]); - await syncManagedPresets(value.options); - const standard = join(value.dshHome, ".agent-presets", "standard"); - await writeFile( - join(standard, "agent.cordis.yml"), - "not: a composition\n", - ); - await writeFile( - join(value.dshHome, ".agent-presets", "ptc", MANAGED_MARKER_FILE), - "{}\n", - ); + expect( + (await inspectCompatiblePresets(value.options)).presets.map( + (preset) => preset.status, + ), + ).toEqual(["missing", "missing", "missing", "missing"]); + await syncCompatiblePresets(value.options); await writeFile( - join(value.dshHome, ".agent-presets", "cordis", MANAGED_MARKER_FILE), - JSON.stringify({ - managedBy: "@guionai/web", - markerVersion: 1, - presetId: "cordis", - sourcePackage: "@deepseek-ai/dsh-agent-presets", - sourcePackageVersion: "0.1.2-rc.1", - sourceIdentity: "sha256:stale", - }) + "\n", + join(value.dshHome, ".agent-presets", "standard", "prompt.md"), + "tampered\n", ); - await mkdir(join(value.dshHome, ".agent-presets", "minimal", "nested"), { + await rm(join(value.dshHome, ".agent-presets", "ptc"), { recursive: true, }); - const report = await inspectManagedPresets(value.options); + await cp( + join(value.sourcePresetRoot, "ptc"), + join(value.dshHome, ".agent-presets", "ptc"), + { recursive: true }, + ); + const report = await inspectCompatiblePresets(value.options); expect(report.ok).toBe(false); expect(report.presets.map((preset) => preset.status)).toEqual([ - "incomplete", "conflict", "stale", "ok", + "ok", ]); expect(formatDshDoctor(report)).toContain("DSH doctor: FAILED"); - expect(await readFile(join(standard, "agent.cordis.yml"), "utf8")).toBe( - "not: a composition\n", - ); - } finally { - await dispose(value); - } - }); - - it("doctor compares every copied source file, including deletion and tampering", async () => { - const value = await fixture(); - try { - await syncManagedPresets(value.options); - const standard = join(value.dshHome, ".agent-presets", "standard"); - await rm(join(standard, "prompt.md")); - let report = await inspectManagedPresets(value.options); - expect( - report.presets.find((preset) => preset.id === "standard"), - ).toMatchObject({ status: "incomplete" }); - expect(report.issues.join("\n")).toContain("missing: prompt.md"); - - await writeFile(join(standard, "prompt.md"), "tampered prompt\n"); - report = await inspectManagedPresets(value.options); - expect( - report.presets.find((preset) => preset.id === "standard"), - ).toMatchObject({ status: "incomplete" }); - expect(report.issues.join("\n")).toContain( - "differs from the official source: prompt.md", - ); - - await writeFile( - join(standard, "preset.yml"), - "name: Tampered\ndescription: still valid\norder: 1\n", - ); - report = await inspectManagedPresets(value.options); - expect( - report.presets.find((preset) => preset.id === "standard"), - ).toMatchObject({ status: "incomplete" }); - expect(report.issues.join("\n")).toContain( - "differs from the official source: preset.yml", - ); } finally { await dispose(value); } }); - it("aborts a same-id ownership race without deleting unknown data", async () => { + it("supports interactive confirmation and --yes through the CLI", async () => { const value = await fixture(); try { - await syncManagedPresets(value.options); + await syncCompatiblePresets(value.options); const target = join(value.dshHome, ".agent-presets", "standard"); - const original = join(value.root, "original-standard"); - let raced = false; - await expect( - syncManagedPresets({ - ...value.options, - hooks: { - beforeRename: async (event) => { - if ( - !raced && - event.id === "standard" && - event.phase === "before-backup-rename" - ) { - raced = true; - await rename(target, original); - await mkdir(target); - await writeFile(join(target, "operator-data.txt"), "keep me\n"); - } - }, - }, - }), - ).rejects.toMatchObject({ code: "dsh-preset-conflict" }); - expect(await readFile(join(target, "operator-data.txt"), "utf8")).toBe( - "keep me\n", - ); - expect( - JSON.parse(await readFile(join(original, MANAGED_MARKER_FILE), "utf8")) - .managedBy, - ).toBe("@guionai/web"); - expect(await readdir(join(value.dshHome, ".agent-presets"))).toContain( - "standard", - ); - } finally { - await dispose(value); - } - }); - - it("rolls back newly installed targets after a mid-swap failure", async () => { - const value = await fixture(); - try { - let installs = 0; - await expect( - syncManagedPresets({ - ...value.options, - hooks: { - beforeRename: (event) => { - if (event.phase === "after-install-rename" && ++installs === 2) - throw new Error("injected mid-swap failure"); - }, - }, - }), - ).rejects.toThrow("injected mid-swap failure"); - expect(await readdir(join(value.dshHome, ".agent-presets"))).toEqual([]); - } finally { - await dispose(value); - } - }); - - it("exposes sync and doctor through the runner with fixture-owned paths", async () => { - const value = await fixture(); - try { - let stdout = ""; - let stderr = ""; + await writeFile(join(target, "prompt.md"), "modified\n"); + let confirmed: readonly string[] = []; const dependencies = { operations: createWebOperations(), credentials: () => ({}), dsh: value.options, + confirmDshOverwrite: (ids: readonly string[]) => { + confirmed = ids; + return true; + }, }; + const output = { stdout: () => undefined, stderr: () => undefined }; expect( - await runCli(["node", "web", "dsh", "sync"], dependencies, { - stdout: (text) => (stdout += text), - stderr: (text) => (stderr += text), - }), + await runCli(["node", "web", "dsh", "sync"], dependencies, output), ).toBe(0); - expect(stdout).toContain("DSH managed presets created all four presets"); - expect(stderr).toBe(""); - stdout = ""; + expect(confirmed).toEqual(["standard"]); + + await writeFile(join(target, "prompt.md"), "modified again\n"); + confirmed = []; expect( - await runCli(["node", "web", "dsh", "doctor"], dependencies, { - stdout: (text) => (stdout += text), - stderr: (text) => (stderr += text), - }), + await runCli( + ["node", "web", "dsh", "sync", "--yes"], + dependencies, + output, + ), ).toBe(0); - expect(stdout).toContain("DSH doctor: OK"); - expect(stderr).toBe(""); - - await writeFile( - join(value.dshHome, ".agent-presets", "standard", "agent.cordis.yml"), - "broken\n", - ); - stdout = ""; - stderr = ""; + expect(confirmed).toEqual([]); expect( - await runCli(["node", "web", "dsh", "doctor"], dependencies, { - stdout: (text) => (stdout += text), - stderr: (text) => (stderr += text), - }), - ).toBe(1); - expect(stdout).toContain("standard: incomplete"); - expect(stderr).toBe("DSH managed preset doctor found problems\n"); + await runCli(["node", "web", "dsh", "doctor"], dependencies, output), + ).toBe(0); } finally { await dispose(value); } }); - it("requires the official source package manifest and version", async () => { + it("requires the official package manifest and exposes actionable errors", async () => { const value = await fixture(); try { - const runtime = await resolveDshRuntime({ - sourcePresetRoot: value.sourcePresetRoot, - dshHome: value.dshHome, - }); - expect(runtime.sourcePresetRoot).toBe(value.sourcePresetRoot); - expect(runtime.sourcePackageName).toBe("@deepseek-ai/dsh-agent-presets"); - + expect( + ( + await resolveDshRuntime({ + sourcePresetRoot: value.sourcePresetRoot, + dshHome: value.dshHome, + }) + ).sourcePackageName, + ).toBe("@deepseek-ai/dsh-agent-presets"); await rm(join(value.sourcePackageRoot, "package.json")); await expect( resolveDshRuntime({ @@ -457,15 +316,37 @@ describe("managed DSH presets", () => { dshHome: value.dshHome, }), ).rejects.toMatchObject({ code: "dsh-source-invalid" }); + const error = new DshPresetError("dsh-source-invalid", "fixture failure"); + expect(error.message).toBe("fixture failure"); } finally { await dispose(value); } }); - it("exports its custom error type for actionable source failures", () => { - const error = new DshPresetError("dsh-source-invalid", "fixture failure"); - expect(error).toBeInstanceOf(Error); - expect(error.code).toBe("dsh-source-invalid"); - expect(error.message).toBe("fixture failure"); + it("discovers DSH beside a standard node_modules bin shim", async () => { + const value = await fixture(); + try { + const dshRoot = join(value.root, "node_modules", "@deepseek-ai", "dsh"); + const executable = join(value.root, "node_modules", ".bin", "dsh"); + await mkdir(join(dshRoot, "lib"), { recursive: true }); + await mkdir(join(value.root, "node_modules", ".bin"), { + recursive: true, + }); + await writeFile( + join(dshRoot, "package.json"), + `${JSON.stringify({ name: "@deepseek-ai/dsh", version: "0.1.2-rc.1" })}\n`, + ); + await writeFile(join(dshRoot, "lib", "bin.js"), "// fixture\n"); + await writeFile(executable, "#!/bin/sh\n"); + const runtime = await resolveDshRuntime({ + dshExecutable: executable, + dshHome: value.dshHome, + }); + expect(runtime.sourcePackageRoot).toBe( + await realpath(value.sourcePackageRoot), + ); + } finally { + await dispose(value); + } }); }); diff --git a/packages/web/test/packed-smoke.mjs b/packages/web/test/packed-smoke.mjs index 3dbc56a..1f44bd3 100644 --- a/packages/web/test/packed-smoke.mjs +++ b/packages/web/test/packed-smoke.mjs @@ -134,101 +134,6 @@ try { if (!help.stdout.includes("Search the web") || !help.stdout.includes("mcp")) throw new Error("installed web CLI did not start with its MCP command"); - // Exercise the packed DSH commands through normal executable discovery. - // This graph is deliberately disposable: no source paths or live DSH_HOME - // are injected into the CLI, and the fake launcher points at an installed - // runtime-shaped package just as the official package manager does. - const fakeDshInstall = join(root, "fake-dsh-install"); - const fakeDshBin = join(fakeDshInstall, "node_modules", ".bin"); - const fakeDshRoot = join( - fakeDshInstall, - "node_modules", - "@deepseek-ai", - "dsh", - ); - const fakePresetRoot = join( - fakeDshInstall, - "node_modules", - "@deepseek-ai", - "dsh-agent-presets", - ); - const fakeDshHome = join(root, "dsh-home"); - await mkdir(join(fakeDshRoot, "lib"), { recursive: true }); - await mkdir(join(fakeDshBin), { recursive: true }); - await writeFile( - join(fakeDshRoot, "package.json"), - JSON.stringify({ name: "@deepseek-ai/dsh", version: "0.1.2-rc.1" }) + "\n", - ); - await writeFile( - join(fakeDshRoot, "lib", "bin.js"), - "// disposable DSH entry\n", - ); - await writeFile( - join(fakeDshBin, "dsh"), - `#!/bin/sh -exec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(fakeDshRoot, "lib", "bin.js"))} "$@" -`, - ); - await chmod(join(fakeDshBin, "dsh"), 0o700); - await mkdir(fakePresetRoot, { recursive: true }); - await writeFile( - join(fakePresetRoot, "package.json"), - JSON.stringify({ - name: "@deepseek-ai/dsh-agent-presets", - version: "0.1.2-rc.1", - }) + "\n", - ); - const packedComposition = (id, includeWeb) => - [ - `# ${id} packed composition`, - "- id: persona", - " name: packed:persona", - ...(includeWeb - ? [ - "", - "- id: tool-web", - " name: '@deepseek-ai/dsh-tool-web'", - " config:", - " fetch: true", - " searchTimeoutMs: 60000", - ] - : []), - "", - "- id: tail", - " name: packed:tail", - "", - ].join("\n"); - for (const id of ["standard", "ptc", "cordis", "minimal"]) { - const preset = join(fakePresetRoot, "presets", id); - await mkdir(preset, { recursive: true }); - await writeFile( - join(preset, "agent.cordis.yml"), - packedComposition(id, id !== "minimal"), - ); - await writeFile( - join(preset, "preset.yml"), - `name: Packed ${id}\ndescription: packed smoke fixture\norder: 1\n`, - ); - await writeFile(join(preset, "prompt.md"), `packed prompt ${id}\n`); - } - const dshEnvironment = { - PATH: `${fakeDshBin}:${dirname(process.execPath)}:/usr/bin:/bin`, - HOME: join(root, "dsh-home-home"), - DSH_HOME: fakeDshHome, - }; - const dshSync = await execFileAsync(binary, ["dsh", "sync"], { - cwd: root, - env: dshEnvironment, - }); - if (!dshSync.stdout.includes("DSH managed presets created all four presets")) - throw new Error("packed web CLI could not sync managed DSH presets"); - const dshDoctor = await execFileAsync(binary, ["dsh", "doctor"], { - cwd: root, - env: dshEnvironment, - }); - if (!dshDoctor.stdout.includes("DSH doctor: OK")) - throw new Error("packed web CLI could not doctor managed DSH presets"); - const fakeBin = join(root, "fake-browser-bin"); const fakeLog = join(root, "agent-browser.jsonl"); await mkdir(fakeBin); From 7a2f140b9234bd5fec959dbe88d6b132fea0e1c7 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 17:33:46 +0800 Subject: [PATCH 08/10] chore(dsh): refresh simplified sync report --- .../implementation-report.md | 270 ++++++------------ 1 file changed, 86 insertions(+), 184 deletions(-) diff --git a/.scratch/dsh-managed-presets/implementation-report.md b/.scratch/dsh-managed-presets/implementation-report.md index 33fc62d..2894f21 100644 --- a/.scratch/dsh-managed-presets/implementation-report.md +++ b/.scratch/dsh-managed-presets/implementation-report.md @@ -1,222 +1,124 @@ -# DSH managed presets implementation report +# DSH compatible presets implementation report ## Scope - Repository: `guionai/web` - Branch: `dsh-managed-presets` -- Fixed point: `1bb01fd` (main) -- Implementation commits: `3d437a6` (`feat(dsh): manage compatible stock presets`), - `3e3e95c` (`fix(dsh): harden managed preset synchronization`), and `e9ca3b8` - (`fix(dsh): require explicit provider wiring`) +- Fixed point: `1bb01fd` (`main`) +- Final implementation commit: `3d7d4a1` (`refactor(dsh): simplify compatible preset sync`) - Delivery boundary: the complete `dsh-managed-presets` spec and tickets 01–04. - Code review and deployment were outside the Implementation worker's scope. - -## Ticket outcomes - -### 01 — Own the complete DSH Research Surface - -- Replaced the official Web registry/provider integration with direct Guion - ownership of `web_search`, `web_fetch`, `web_links`, `web_docs`, and - `web_source_search`. -- Preserved live Exa, Brave, DeepSeek, and Kepos Bridge selection and - namespaced credential resolution. Search validates one-to-four trimmed - queries, runs them concurrently, merges successful results deterministically, - reports partial/total failures, forwards cancellation, and bounds rendered - model text. -- Retained the complete fetch navigation and rendering contract (`mode`, - `section_id`, `render`, and `waitMs`) and the provider-conditional Kepos - tools. -- Removed the obsolete provider module, official Web peer/development - dependency, and registry injection. Packed host, client, tool, and schema - tests cover the owned surface. - -### 02 — Sync and diagnose managed presets - -- Added `web dsh sync` and read-only `web dsh doctor` with DSH executable - discovery, standard `DSH_HOME` resolution, explicit test-owned path seams, - exact official preset-package version validation, source identity markers, - structural `tool-web` removal, and complete source-tree validation. -- Sync creates or refreshes marked same-id `standard`, `ptc`, `cordis`, and - `minimal` snapshots under `.agent-presets`; it stages writes, preflights all - conflicts, preserves unmarked user data, and rolls back managed replacements - when a filesystem operation is interrupted. Each rename revalidates - ownership, verifies a moved backup marker before it can be deleted, preserves - unknown races, and removes newly installed targets during rollback. -- Doctor compares every managed file byte-for-byte with the expected - transformed source snapshot (while allowing only the generated marker), so - deletion, tampering, and unexpected copied files are reported as incomplete. - The source package manifest and version are authoritative; version override - seams and unused path aliases/helpers were removed. Fixture tests cover - source-file deletion/tampering, deterministic ownership races, and injected - mid-swap rollback. - -### 03 — Hide shipped presets end to end - -- Updated the bundle patch to disable the complete official Web stack and the - scoped `tool-web` row, while targeting the existing `agent-presets` row with - `includeShippedRoot: false`, `includeUserRoot: true`, and `default: standard`. -- Parsed and packed artifact tests assert the full patch and peer-only package - contract, including the absence of `@deepseek-ai/dsh-web`. -- Completed packed Linux validation against the official rc.1 Loader path and - disposable DSH home (details below). - -### 04 — Document the managed preset workflow - -- Updated the root and DSH-package READMEs with sync-before-activation, - doctor, hidden shipped-root, same-id ownership/conflict, upgrade refresh, and - effective Guion schema guidance. -- Added the DSH vocabulary to `CONTEXT.md`, recorded the ownership decision in - ADR 0004, and added test-owned/real-Linux requirements to `AGENTS.md`. -- The root/package READMEs, `CONTEXT.md`, and `AGENTS.md` are the only project - documents that describe this workflow; no other project documentation - exposes a DSH preset contract requiring an update. - -## Changed paths - -- `AGENTS.md` -- `CONTEXT.md` -- `README.md` -- `docs/adr/0004-dsh-research-surface-owner.md` -- `packages/dsh-web/README.md` -- `packages/dsh-web/cordis.patch.yml` -- `packages/dsh-web/package.json` -- `packages/dsh-web/src/client.ts` -- `packages/dsh-web/src/contract.ts` -- `packages/dsh-web/src/index.ts` -- `packages/dsh-web/src/provider.ts` (removed) -- `packages/dsh-web/src/tools.ts` -- `packages/dsh-web/test/artifact.test.ts` -- `packages/dsh-web/test/client.test.ts` -- `packages/dsh-web/test/package.test.ts` -- `packages/dsh-web/test/provider.test.ts` (removed) -- `packages/dsh-web/test/tools.test.ts` -- `packages/dsh-web/tsup.config.ts` -- `packages/web/src/dsh.ts` -- `packages/web/src/program.ts` -- `packages/web/src/runner.ts` -- `packages/web/test/dsh.test.ts` -- `packages/web/test/packed-smoke.mjs` -- `pnpm-lock.yaml` + +## Outcome + +Guion's DSH bundle now owns the complete research surface: +`web_search`, `web_fetch`, `web_links`, `web_docs`, and +`web_source_search`. The official Web registry/provider integration and +dependency are gone. The Guion implementations retain the selected search +providers, namespaced credentials, fetch navigation/rendering options, +cancellation, bounded output, and conditional Kepos tools. + +The bundle patch disables the official Web stack and hides shipped presets. +It exposes only the user preset root, with `standard` as the default. The +effective Guion schemas remain: + +- `web_search`: required `queries` +- `web_fetch`: required `url`; optional `mode`, `section_id`, `render`, and + `waitMs` + +## Compatible preset workflow + +`web dsh sync` creates or refreshes `standard`, `ptc`, `cordis`, and `minimal` +under the DSH user preset root. Each copy comes from the installed official +`@deepseek-ai/dsh-agent-presets` package with the official `tool-web` entry +removed structurally. + +There is no Guion marker file or persistent ownership metadata. For each +same-ID directory, sync compares the complete tree with two snapshots: + +- an exact current compatible copy is safe to refresh; +- an exact official copy is safe to convert; +- any other content is treated as user-modified and requires interactive + confirmation, or `--yes` in automation. + +Non-interactive sync refuses modified same-ID directories unless `--yes` is +present. Unrelated user presets are untouched. Replacement is staged per +preset, with a local backup restored if installation fails; there is no global +four-preset transaction or speculative race-hook machinery. + +`web dsh doctor` is read-only and reports each compatible preset as `ok`, +`missing`, `stale` (an exact official copy), or `conflict` (any other content). + +## Documentation + +The workflow and the requirement to sync before selecting a compatible preset +are documented in the root README and `packages/dsh-web/README.md`. +`CONTEXT.md` defines the vocabulary, ADR 0004 records the ownership decision, +and `AGENTS.md` records the test-state and real-Linux verification rules. ## Verification -All final local checks passed: +Final local checks passed: -- `pnpm install --frozen-lockfile --ignore-scripts` +- `pnpm test` — 20 files, 161 tests - `pnpm typecheck` -- `pnpm test` — 20 files, 163 tests passed +- `pnpm build` — all four packages +- `pnpm test:pack` — Web, Pi, and DSH package smoke checks - `pnpm format:check` -- `pnpm build` — all four package builds passed -- `pnpm test:pack` — Web, Pi, and DSH packed smoke tests passed - `pnpm test:release` - `git diff --check` -The blocker pass added packed Web CLI coverage with a fake installed DSH and -agent-presets graph under a test-owned temporary directory. It invokes -`dsh sync` and `dsh doctor` through PATH discovery with a disposable DSH_HOME; -no source paths or live state are injected. - -The updated Linux validation ran on NUC `kosmos-wsl` (Linux, Node -`v24.19.0`) under the disposable work directory -`/tmp/guion-dsh-linux-blocker-final-ss2sfY`. The blocker run reused the prior -disposable official install at -`/tmp/guion-dsh-linux-final-heK1hs/install` rather than reinstalling DSH; that -install contains the official `@deepseek-ai/dsh@0.1.2-rc.1` and -`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` graph. The current packed artifacts -and test-owned paths were: - -- Web bundle: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/guionai-web-0.1.0.tgz` -- DSH bundle: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/guionai-dsh-web-0.1.0.tgz` -- Packed Web CLI: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli/node_modules/.bin/web` -- DSH entrypoint: `/tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js` -- DSH home: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/home` -- Profile: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/home/profiles/web` -- Probe: `/tmp/guion-dsh-linux-blocker-final-ss2sfY/home/profiles/probe` - -The Web CLI install was performed in the test-owned `webcli` directory: - -```sh -cd /tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli -HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home-home XDG_CACHE_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/cache npm_config_store_dir=/tmp/guion-dsh-linux-blocker-final-ss2sfY/store pnpm install --offline --ignore-scripts --frozen-lockfile=false -``` +The packed Web smoke no longer contains a fake DSH installation or a second +sync/doctor test graph. Runtime discovery is covered by focused temporary +filesystem tests, including a standard `node_modules/.bin/dsh` shim. -The profile install used the current packed DSH bundle and probe from the -test-owned profile directory: +The real integration check ran on `nuc-kep` from the existing checkout +`/home/neil/code/projects/guionai/web`. `og pull` first fast-forwarded its +current `main`; because `og pull` only fetches the current branch, the new +remote feature ref was then fetched and checked out. The checkout was built +with its installed pnpm, and the built Web CLI called the NUC's installed +`dsh` executable. All mutable DSH state was isolated under: -```sh -cd /tmp/guion-dsh-linux-blocker-final-ss2sfY/home/profiles/web -HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/profile-home-repro XDG_CACHE_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/profile-cache-repro npm_config_store_dir=/tmp/guion-dsh-linux-blocker-final-ss2sfY/profile-store-repro pnpm install --offline --ignore-scripts --frozen-lockfile=false +```text +/tmp/guion-dsh-checkout.q3UxVE/dsh-home ``` -These are the complete runtime commands used (all paths are explicit; there -are no source-path overrides): - -```sh -PATH=/tmp/guion-dsh-linux-final-heK1hs/install/node_modules/.bin:/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home /tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli/node_modules/.bin/web dsh sync -PATH=/tmp/guion-dsh-linux-final-heK1hs/install/node_modules/.bin:/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home /tmp/guion-dsh-linux-blocker-final-ss2sfY/webcli/node_modules/.bin/web dsh doctor -PATH=/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home node --expose-internals /tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js --profile web --dump-config -PATH=/run/current-system/sw/bin HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home DSH_HOME=/tmp/guion-dsh-linux-blocker-final-ss2sfY/home PROBE_OUT=/tmp/guion-dsh-linux-blocker-final-ss2sfY/probe-repro.json node --expose-internals /tmp/guion-dsh-linux-final-heK1hs/install/node_modules/@deepseek-ai/dsh/lib/bin.js --profile web --no-open --host 127.0.0.1 --port 0 -``` +The disposable profile linked the checkout's current `@guionai/dsh-web` +package. No live yuki profile, credentials, preset root, or running service was +read or changed. -Captured command output was: +Observed results: ```text -DSH managed presets created all four presets from @deepseek-ai/dsh-agent-presets@0.1.2-rc.1. -Preset root: /tmp/guion-dsh-linux-blocker-final-ss2sfY/home/.agent-presets +DSH compatible presets created all four presets from @deepseek-ai/dsh-agent-presets@0.1.2-rc.1. DSH doctor: OK - standard: ok - ptc: ok - cordis: ok - minimal: ok -dsh web: http://127.0.0.1:44554/?token=REDACTED -``` - -The probe's roster/composition summary was: - -```text -standard user rows=26 tool-web=[] -ptc user rows=27 tool-web=[] -minimal user rows=8 tool-web=[] -cordis user rows=27 tool-web=[] ``` -The probe's schema summary was: - -```text -web_fetch properties=url,mode,section_id,render,waitMs required=url -web_search properties=queries required=queries -``` +The same run then exercised the overwrite policy: -The dump contained `includeShippedRoot: false`, `includeUserRoot: true`, and -`default: standard`; the profile process was stopped after the probe completed. -Every home, profile, probe, and packed bundle path above was test-owned and -disposable, and no live DSH home, credentials, overrides, or services were -read or modified. +1. Replacing `standard` with the exact installed official preset was accepted + and converted without a prompt. +2. Adding a user edit to `ptc` made non-interactive sync exit with status 1: + `refusing to overwrite modified same-id preset ptc; rerun interactively or pass --yes`. +3. Re-running with `--yes` refreshed all four presets, after which doctor was + fully green again. Vitest emits the existing non-failing missing source-map warning from the DSH primitives package. -## LOC accounting - -Against fixed point `1bb01fd`, excluding the lockfile, generated artifacts, -ignored tracker files, and this report: - -| Category | Additions | Deletions | -| ---------------------- | --------: | --------: | -| Product code | 1,528 | 179 | -| Tests | 900 | 316 | -| Configuration and docs | 224 | 25 | -| **Total** | **2,652** | **520** | +## Size -The estimate was 1,070–1,820 changed lines. The material variance comes from -the complete filesystem-safe runtime discovery, source validation, staging, and -rollback path plus the required fixture and packed Loader coverage; no -compatibility migration, live-state fallback, or unrelated optional feature was -added. +Against `1bb01fd`, excluding the lockfile and this report, the final diff is +2,144 additions and 521 deletions (2,665 changed lines). The simplified sync +removed 901 lines and added 392 relative to the previously reviewed branch: +the marker protocol, global transaction, injected rename hooks, shim-text +parser, and packed fake-DSH smoke were deleted. ## Acceptance result -Tickets 01, 02, 03, and 04 and the complete spec are implemented and verified. -The implementation is committed as `3d437a6`, `3e3e95c`, and `e9ca3b8`; the report update -is committed separately. Code review and deployment were outside the -Implementation worker's scope. +The whole spec is implemented and verified. The final workflow uses content +comparison plus explicit confirmation, keeps upstream DSH unchanged, and uses +the NUC's real CLI for Linux integration verification. From 350f3b8fd5eb1f669fff6b4da8a3ac336c69e847 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 17:44:09 +0800 Subject: [PATCH 09/10] chore(dsh): require NUC-local validation build --- AGENTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ee5208..20585b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ exit status. Sync compares `standard`, `ptc`, `cordis`, and `minimal` with the official and compatible trees; modified same-id content requires interactive confirmation or `--yes`. It must not mutate shipped presets, unrelated user presets, credentials, or sessions. Tests use fixture source trees and -disposable `DSH_HOME` directories. End-to-end validation runs on the NUC with -its installed official CLI, a disposable `DSH_HOME`, and current packed Guion -artifacts, and records the paths and results in the implementation report. +disposable `DSH_HOME` directories. End-to-end validation runs from the existing +NUC checkout, built on the NUC, using its installed official DSH CLI and a +disposable `DSH_HOME`. Do not transfer or validate a Web CLI packed on macOS. +Record the paths and results in the implementation report. From 008fe28e86fa1aa695ee10ed1335d23580ff95e6 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 5 Sep 2026 18:19:11 +0800 Subject: [PATCH 10/10] chore(dsh): record Loader verification --- .../implementation-report.md | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.scratch/dsh-managed-presets/implementation-report.md b/.scratch/dsh-managed-presets/implementation-report.md index 2894f21..d66e182 100644 --- a/.scratch/dsh-managed-presets/implementation-report.md +++ b/.scratch/dsh-managed-presets/implementation-report.md @@ -83,8 +83,8 @@ with its installed pnpm, and the built Web CLI called the NUC's installed ``` The disposable profile linked the checkout's current `@guionai/dsh-web` -package. No live yuki profile, credentials, preset root, or running service was -read or changed. +package. No live yuki profile, credentials, preset root, or service was read or +changed. Observed results: @@ -106,6 +106,25 @@ The same run then exercised the overwrite policy: 3. Re-running with `--yes` refreshed all four presets, after which doctor was fully green again. +The same disposable profile was then composed by the installed official Loader +with a read-only probe bundle. The reconciled config contained +`includeShippedRoot: false`, `includeUserRoot: true`, and `default: standard`; +the installed `@guionai/dsh-web` link resolved exactly to the NUC checkout. +The Loader booted successfully and was stopped immediately after the probe: + +```text +standard user rows=26 broken=absent tool-web=0 +ptc user rows=27 broken=absent tool-web=0 +minimal user rows=8 broken=absent tool-web=0 +cordis user rows=27 broken=absent tool-web=0 +web_fetch properties=url,mode,section_id,render,waitMs required=url +web_search properties=queries required=queries +``` + +The probe output is `/tmp/guion-dsh-checkout.q3UxVE/probe.json`. The disposable +Loader process was confirmed stopped, and its token-bearing launch URL was not +recorded. + Vitest emits the existing non-failing missing source-map warning from the DSH primitives package.