From fbfa3bd620525be8a6d24d6d8ce9e8d4a4158eea Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Thu, 8 Oct 2026 07:56:16 -0600 Subject: [PATCH 1/3] fix(cli): allow remote extraction chosen in setup Setup wrote the memory_extraction task class with privacy restricted_remote for every provider. The router infers remote_ok for a remote openrouter or openai-compatible target and blocks it because remote_ok ranks below restricted_remote, so a fresh install with a remote extraction model could never run Dreaming. Choosing a remote provider in setup is the operator's decision to send memory to it. Record that the way dashboard onboarding and the Settings consent prompt already do, with privacy remote_ok on the task class. Local targets and ACPX keep restricted_remote, so the dashboard still asks before they are switched to a remote provider. Assisted-by: Claude-Code:claude-opus-5-5 --- .../cli/src/features/setup-pipeline.test.ts | 60 ++++++++++++++++++- surfaces/cli/src/features/setup-pipeline.ts | 20 +++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/surfaces/cli/src/features/setup-pipeline.test.ts b/surfaces/cli/src/features/setup-pipeline.test.ts index 323c6524c8..19a70912c8 100644 --- a/surfaces/cli/src/features/setup-pipeline.test.ts +++ b/surfaces/cli/src/features/setup-pipeline.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { MODEL_DEFAULTS } from "@signet/core"; +import { MODEL_DEFAULTS, allTargetRefs, parseRoutingConfig, resolveRoutingDecision } from "@signet/core"; import { applyAggregateRecallRoute, applySetupInferenceRoute, @@ -8,8 +8,21 @@ import { buildSetupPipeline, defaultAcpxModel, defaultExtractionModel, + type SetupInferenceConfig, } from "./setup-pipeline"; +function routeMemoryExtraction(inference: SetupInferenceConfig | undefined) { + const config = parseRoutingConfig({ inference }); + if (!config.ok) throw new Error(config.error.message); + const targets = Object.fromEntries( + allTargetRefs(config.value).map((ref) => [ + ref, + { available: true, health: "healthy" as const, circuitOpen: false, accountState: "ready" as const }, + ]), + ); + return resolveRoutingDecision(config.value, { operation: "memory_extraction" }, { targets }); +} + describe("defaultExtractionModel", () => { it("uses the checked Codex CLI model default", () => { expect(defaultExtractionModel("codex")).toBe(MODEL_DEFAULTS.codex); @@ -69,6 +82,40 @@ describe("buildSetupPipeline", () => { }); }); + it("records remote extraction consent on the task class for remote targets", () => { + expect(buildSetupInference("openrouter", "anthropic/claude-haiku")?.taskClasses.memory_extraction).toMatchObject({ + privacy: "remote_ok", + }); + expect( + buildSetupInference("openai-compatible", "glm-5.3-flash", [], [], undefined, "https://open.bigmodel.cn/api/v4") + ?.taskClasses.memory_extraction, + ).toMatchObject({ privacy: "remote_ok" }); + expect( + buildSetupInference("openai-compatible", "local-model", [], [], undefined, "http://127.0.0.1:1234/v1") + ?.taskClasses.memory_extraction, + ).toMatchObject({ privacy: "restricted_remote" }); + expect(buildSetupInference("ollama", "qwen3:4b")?.taskClasses.memory_extraction).toMatchObject({ + privacy: "restricted_remote", + }); + }); + + it("writes a memory extraction route the router can resolve for every provider", () => { + const routes = [ + buildSetupInference("openrouter", "anthropic/claude-haiku"), + buildSetupInference("openai-compatible", "glm-5.3-flash", [], [], undefined, "https://open.bigmodel.cn/api/v4"), + buildSetupInference("openai-compatible", "local-model", [], [], undefined, "http://localhost:1234/v1"), + buildSetupInference("ollama", "qwen3:4b"), + buildSetupInference("llama-cpp", "qwen3:4b"), + buildSetupInference("acpx", "haiku", ["claude-code"], ["acpx"], "/usr/local/bin/bunx"), + ]; + for (const route of routes) { + const decision = routeMemoryExtraction(route); + expect(decision.ok ? decision.value.targetRef : decision.error.details?.trace).toBe( + route?.defaultPolicy === "background-acpx" ? "background-acpx/default" : "background/default", + ); + } + }); + it("preserves the configured endpoint for OpenAI-compatible extraction", () => { expect( buildSetupInference("openai-compatible", "local-model", [], [], undefined, "http://gw:8000/v1")?.targets @@ -175,6 +222,17 @@ describe("buildSetupInference", () => { expect(config).not.toHaveProperty("inference"); }); + it("removes the generated remote task class when setup switches extraction off", () => { + const config: Record = { + inference: buildSetupInference("openrouter", "anthropic/claude-haiku"), + }; + + applySetupInferenceRoute(config, undefined); + + expect(config.inference).not.toHaveProperty("taskClasses"); + expect(config.inference).not.toHaveProperty("workloads"); + }); + it("preserves aggregate recall when removing generated direct routing", () => { const config: Record = { inference: { diff --git a/surfaces/cli/src/features/setup-pipeline.ts b/surfaces/cli/src/features/setup-pipeline.ts index 425a40e4c7..0acc45724a 100644 --- a/surfaces/cli/src/features/setup-pipeline.ts +++ b/surfaces/cli/src/features/setup-pipeline.ts @@ -1,4 +1,4 @@ -import { defaultPipelineModel } from "@signet/core"; +import { defaultPipelineModel, isLocalInferenceEndpoint } from "@signet/core"; import type { ExtractionProviderChoice, HarnessChoice } from "./setup-shared.js"; export const EXTRACTION_SAFETY_WARNING = @@ -161,14 +161,18 @@ export function buildSetupInference( models: { default: { model: resolved, reasoning: "medium" } }, }; let accounts: Record | undefined; + let remote = false; if (provider === "openai-compatible") { - target.endpoint = endpoint?.trim() || "http://127.0.0.1:1234/v1"; + const resolvedEndpoint = endpoint?.trim() || "http://127.0.0.1:1234/v1"; + target.endpoint = resolvedEndpoint; + remote = !isLocalInferenceEndpoint(resolvedEndpoint); } if (provider === "openrouter") { target.account = "extraction"; accounts = { extraction: { kind: "api", providerFamily: "openrouter", credentialRef: "OPENROUTER_API_KEY" }, }; + remote = true; } const targetRef = "background/default"; return { @@ -183,7 +187,11 @@ export function buildSetupInference( }, }, taskClasses: { - memory_extraction: { reasoning: "medium", toolsRequired: true, privacy: "restricted_remote" }, + memory_extraction: { + reasoning: "medium", + toolsRequired: true, + privacy: remote ? "remote_ok" : "restricted_remote", + }, }, workloads: { memoryExtraction: { target: targetRef, taskClass: "memory_extraction" }, @@ -236,7 +244,11 @@ export function applySetupInferenceRoute( function isGeneratedSetupTaskClass(value: unknown): boolean { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const record = value as { reasoning?: unknown; toolsRequired?: unknown; privacy?: unknown }; - return record.reasoning === "medium" && record.toolsRequired === true && record.privacy === "restricted_remote"; + return ( + record.reasoning === "medium" && + record.toolsRequired === true && + (record.privacy === "restricted_remote" || record.privacy === "remote_ok") + ); } function isGeneratedSetupWorkload(value: unknown, target: string): boolean { From 60cb4dc562047cc76a364ad6da1b4a373c40fb71 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Thu, 8 Oct 2026 07:56:42 -0600 Subject: [PATCH 2/3] fix(cli): report privacy-blocked extraction in doctor Installs set up before the previous commit keep a memory_extraction task class that their remote target can never satisfy. The daemon reports runtime healthy and account ready, and status only says to connect a provider, so nothing names the privacy gate. signet doctor now resolves the memory_extraction route from agent.yaml with every target treated as healthy. When the route fails on the privacy gate it warns with the blocked target and the two ways to fix it. It does not change the config: allowing remote extraction stays an explicit operator choice. Assisted-by: Claude-Code:claude-opus-5-5 --- surfaces/cli/src/features/health.test.ts | 73 +++++++++++++++++++ surfaces/cli/src/features/health.ts | 41 +++++++++++ web/docs/src/content/docs/benchmarking.md | 6 +- .../src/content/docs/cli/getting-started.md | 2 + 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/surfaces/cli/src/features/health.test.ts b/surfaces/cli/src/features/health.test.ts index 7d515c6468..e3a5e9b14a 100644 --- a/surfaces/cli/src/features/health.test.ts +++ b/surfaces/cli/src/features/health.test.ts @@ -1859,3 +1859,76 @@ describe("doctor unknown target", () => { } }); }); + +describe("doctor memory extraction privacy gate", () => { + const remoteRoute = (privacy: string) => + [ + "inference:", + " defaultPolicy: background", + " targets:", + " background:", + " executor: openai-compatible", + " endpoint: https://open.bigmodel.cn/api/coding/paas/v4", + " models:", + " default:", + " model: glm-5.3-flash", + " policies:", + " background:", + " mode: automatic", + " defaultTargets: [background/default]", + " fallbackTargets: [background/default]", + " taskClasses:", + " memory_extraction:", + " reasoning: medium", + " toolsRequired: true", + ` privacy: ${privacy}`, + " workloads:", + " memoryExtraction:", + " target: background/default", + " taskClass: memory_extraction", + "", + ].join("\n"); + + async function doctorFindings(agentYaml: string): Promise> { + const root = mkdtempSync(join(tmpdir(), "health-extraction-privacy-")); + const lines: string[] = []; + const oldLog = console.log; + try { + writeFileSync(join(root, "agent.yaml"), agentYaml); + console.log = (...args: unknown[]) => { + lines.push(args.join(" ")); + }; + await showDoctor( + { json: true }, + { + ...depsFor(root), + detectInstallations: () => ({ + target: { kind: "unsupported", executablePath: "/tmp/signet", reason: "test fixture" }, + installations: [], + inactive: [], + }), + }, + ); + return (JSON.parse(lines.join("\n")) as { findings: Array<{ code?: string; message?: string; fix?: string }> }) + .findings; + } finally { + console.log = oldLog; + rmSync(root, { recursive: true, force: true }); + } + } + + it("warns when a remote extraction target can never satisfy the task class privacy tier", async () => { + expect(await doctorFindings(remoteRoute("restricted_remote"))).toContainEqual( + expect.objectContaining({ + code: "memory_extraction_privacy_blocked", + message: expect.stringContaining("privacy gate (restricted_remote) for background/default"), + fix: expect.stringContaining("remote_ok"), + }), + ); + }); + + it("stays quiet once remote extraction is allowed", async () => { + const findings = await doctorFindings(remoteRoute("remote_ok")); + expect(findings.map((finding) => finding.code)).not.toContain("memory_extraction_privacy_blocked"); + }); +}); diff --git a/surfaces/cli/src/features/health.ts b/surfaces/cli/src/features/health.ts index c94d55cfc7..160d4dea03 100644 --- a/surfaces/cli/src/features/health.ts +++ b/surfaces/cli/src/features/health.ts @@ -7,10 +7,15 @@ import { detectSignetInstallations, getMissingIdentityFiles, hasValidIdentity, + allTargetRefs, inactivePackageManagerInstallations, loadIdentityMode, + parseRoutingConfig, + parseYamlDocument, + resolveRoutingDecision, resolveWorkspaceLayout, } from "@signet/core"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import chalk from "chalk"; import { daemonAccessLines } from "../lib/network.js"; @@ -818,6 +823,41 @@ function addReadinessFindings(report: StatusReport, findings: DoctorFinding[]): fix: "Inspect the queue in `signet status`; repair only identified jobs with `signet repair queue requeue --apply` or retire obsolete jobs with `signet repair queue cancel --apply`.", }); } +function addExtractionPrivacyFindings(basePath: string, findings: DoctorFinding[]): void { + const path = join(basePath, "agent.yaml"); + if (!existsSync(path)) return; + let parsed: ReturnType; + try { + parsed = parseRoutingConfig(parseYamlDocument(readFileSync(path, "utf-8"))); + } catch { + return; + } + if (!parsed.ok || !parsed.value.workloads?.memoryExtraction) return; + const config = parsed.value; + const targets = Object.fromEntries( + allTargetRefs(config).map((ref) => [ + ref, + { available: true, health: "healthy" as const, circuitOpen: false, accountState: "ready" as const }, + ]), + ); + const decision = resolveRoutingDecision(config, { operation: "memory_extraction" }, { targets }); + if (decision.ok) return; + const trace = decision.error.details?.trace as + | { readonly candidates?: readonly { readonly targetRef: string; readonly blockedBy: readonly string[] }[] } + | undefined; + const gated = (trace?.candidates ?? []).filter((candidate) => + candidate.blockedBy.some((reason) => reason.startsWith("privacy gate")), + ); + if (gated.length === 0) return; + const reason = gated[0]?.blockedBy.find((entry) => entry.startsWith("privacy gate")) ?? "privacy gate"; + findings.push({ + level: "warn", + code: "memory_extraction_privacy_blocked", + message: `Dreaming cannot run: memory extraction is blocked by the ${reason} for ${gated.map((candidate) => candidate.targetRef).join(", ")}.`, + fix: "To send memory and transcript text to this provider, choose Use remotely under Settings → Inference in the dashboard, or set inference.taskClasses.memory_extraction.privacy to remote_ok in agent.yaml. Otherwise point memory extraction at a local target.", + }); +} + function addDaemonLifecycleExitFindings( probe: NonNullable, findings: DoctorFinding[], @@ -1082,6 +1122,7 @@ function getDoctorFindings(report: StatusReport, installations: SignetInstallati addPhysicalMemoryFinding(report, findings); addReadinessFindings(report, findings); addQueueBacklogFindings(report, findings); + if (hasAgentYaml) addExtractionPrivacyFindings(report.basePath, findings); if (report.openclawWorkspaceUnprotected) { findings.push({ diff --git a/web/docs/src/content/docs/benchmarking.md b/web/docs/src/content/docs/benchmarking.md index 84f5753319..f9dde7fe38 100644 --- a/web/docs/src/content/docs/benchmarking.md +++ b/web/docs/src/content/docs/benchmarking.md @@ -263,9 +263,9 @@ a fresh non-interactive install cannot reach a remote model otherwise: - `account`: a reference to `SIGNET_BENCH_DREAMING_API_KEY`, which the wrapper fills from `ZAI_API_KEY`. Setup writes no credential for a remote endpoint. -- `privacy: restricted_remote`: setup's `memory_extraction` task class requires - it, and a remote target is otherwise inferred as `remote_ok`, which the - router blocks. +- `privacy: restricted_remote`: setup keeps the `memory_extraction` task class + at `restricted_remote` when it is given a local endpoint, and the replaced + executor is otherwise inferred as `remote_ok`, which the router blocks. - `executor`: set to the catalog provider family (default `zai-coding-cn`) with no endpoint, matching how an installed Z.ai target is configured. The generic OpenAI-compatible executor appends `/v1` to the Z.ai `/v4` base URL. diff --git a/web/docs/src/content/docs/cli/getting-started.md b/web/docs/src/content/docs/cli/getting-started.md index 5c43fad4f4..b7e22fa389 100644 --- a/web/docs/src/content/docs/cli/getting-started.md +++ b/web/docs/src/content/docs/cli/getting-started.md @@ -109,6 +109,8 @@ When an explicitly selected Ollama service or model is unavailable during non-in Validated extraction providers are `acpx`, `claude-code`, `codex`, `llama-cpp`, `ollama`, `opencode`, `openrouter`, `openai-compatible`, and `none`. Explicit provider flags override defaults inferred from `--deployment-type`. +Choosing `openrouter`, or `openai-compatible` with a non-loopback `--extraction-endpoint`, sends memory and transcript text to that provider. Setup records that choice as `privacy: remote_ok` on the `memory_extraction` task class. Local providers and ACPX keep `restricted_remote`, so the dashboard asks before memory extraction is moved to a remote provider. `signet doctor` warns when the configured extraction target can never pass that privacy tier. + Current `signet setup --help` lists `llama-cpp` as an embedding provider, but setup validation rejects it. Use the validated embedding values above until that discrepancy is fixed. ### Automation, safety, and data flags From 574b6501d66c0f60e6389ac4ea51ca70a04cb116 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Thu, 8 Oct 2026 09:19:22 -0600 Subject: [PATCH 3/3] docs(cli): note dashboard ACPX consent prompt Assisted-by: Claude-Code:claude-opus-5-5 --- web/docs/src/content/docs/cli/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/docs/src/content/docs/cli/getting-started.md b/web/docs/src/content/docs/cli/getting-started.md index b7e22fa389..cc4fcc046f 100644 --- a/web/docs/src/content/docs/cli/getting-started.md +++ b/web/docs/src/content/docs/cli/getting-started.md @@ -109,7 +109,7 @@ When an explicitly selected Ollama service or model is unavailable during non-in Validated extraction providers are `acpx`, `claude-code`, `codex`, `llama-cpp`, `ollama`, `opencode`, `openrouter`, `openai-compatible`, and `none`. Explicit provider flags override defaults inferred from `--deployment-type`. -Choosing `openrouter`, or `openai-compatible` with a non-loopback `--extraction-endpoint`, sends memory and transcript text to that provider. Setup records that choice as `privacy: remote_ok` on the `memory_extraction` task class. Local providers and ACPX keep `restricted_remote`, so the dashboard asks before memory extraction is moved to a remote provider. `signet doctor` warns when the configured extraction target can never pass that privacy tier. +Choosing `openrouter`, or `openai-compatible` with a non-loopback `--extraction-endpoint`, sends memory and transcript text to that provider. Setup records that choice as `privacy: remote_ok` on the `memory_extraction` task class. Local providers and ACPX keep `restricted_remote`, so Settings → Inference in the dashboard asks before memory extraction is moved to a remote provider. It also asks for an existing ACPX target, because the dashboard treats an ACPX harness as a remote executor. `signet doctor` warns when the configured extraction target can never pass that privacy tier. Current `signet setup --help` lists `llama-cpp` as an embedding provider, but setup validation rejects it. Use the validated embedding values above until that discrepancy is fixed.