From d83e880a4f439f5afddb60f53c2851ed4b955ae3 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 15:27:25 +0400 Subject: [PATCH 01/25] feat(setup): add safe resumable connector drafts --- CHANGELOG.md | 1 + README.md | 14 + docs/cli.md | 4 +- docs/console-api.md | 5 + src/cli/exit-codes.ts | 4 + src/cli/init.ts | 12 +- src/cli/main.ts | 7 +- src/cli/parse.ts | 26 +- src/cli/setup.ts | 244 +++++++--- src/console/console-application-service.ts | 7 + src/console/console-assets.ts | 128 +++++ .../console-dashboard-application-service.ts | 34 ++ src/console/console-server.ts | 65 +++ src/setup/setup-draft.ts | 436 ++++++++++++++++++ src/utils/errors.ts | 4 + tests/cli-exit-codes.test.ts | 4 + tests/cli-parse.test.ts | 17 + ...sole-dashboard-application-service.test.ts | 50 ++ tests/console-server.test.ts | 294 ++++++++++++ tests/readme-product-contract.test.ts | 10 + tests/setup-command.test.ts | 149 ++++++ tests/setup-draft.test.ts | 161 +++++++ 22 files changed, 1609 insertions(+), 67 deletions(-) create mode 100644 src/setup/setup-draft.ts create mode 100644 tests/setup-draft.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7846dbf..0fb66e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. The format It also adds guarded profile rename through the CLI and local Console. The operation atomically updates configuration-owned default, routing, routing-plugin, and profile-lock references with recovery backup and fail-closed audit records, while leaving credentials, provider caches, profile state, identity records, and active clients untouched. Native OAuth-bound profiles remain explicitly refused until configuration rename and OS-vault credential migration can be one atomic lifecycle. - [#204](https://github.com/mohanagy/miftah/issues/204) Added outcome-first source choices to the interactive CLI and Console: known connector or pinned package, remote HTTPS endpoint, local executable, remote MCP with browser sign-in, or one existing client entry. Each choice reuses the existing hardened setup path; generic remote setup remains network-free and authentication-free, local setup remains no-shell and does not launch the executable, and client settings remain manual and untouched. - [#204](https://github.com/mohanagy/miftah/issues/204) Added review-before-write setup planning: a complete noninteractive `miftah setup --plan` emits only validated non-secret structure without touching the filesystem or an upstream, and the first-run Console requires a fresh structural review before its separate create action. The review omits paths, endpoints, launch arguments, credential references, OAuth details, and secret values; changing the form invalidates it. +- [#204](https://github.com/mohanagy/miftah/issues/204) Added a private resumable connector-setup checkpoint shared by `miftah setup` and the first-run Console. It stores only a bounded configuration name, catalog preset, and setup stage with atomic compare-and-swap protection; URLs, paths, commands, arguments, environment references, client entries, browser state, OAuth state, credentials, and secrets must be entered again. A saved checkpoint can be resumed or explicitly discarded, survives cancellation or a pre-publication failure, and is cleared only after configuration publication succeeds. ### Changed diff --git a/README.md b/README.md index 42f99b7..d2845e9 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,20 @@ On a bare interactive `miftah setup`, Miftah starts with `What do you already ha Add `--plan` to a complete noninteractive `miftah setup` command to validate and print its non-secret structure before Miftah writes anything. The plan includes the output location you supplied plus configuration, profile, and upstream transport shape; it excludes the endpoint, executable, working directory, arguments, credential references, and secret values. The Console uses the same review-before-write boundary: **Review configuration** validates a candidate without writing it, and **Create reviewed configuration** is the separate explicit write. Changing the form clears the review. +If a bare connector setup is interrupted, continue it with: + +```bash +miftah setup --resume +``` + +Or remove that checkpoint without creating a configuration: + +```bash +miftah setup --discard-draft +``` + +Miftah saves only the configuration name and connector preset in a private local checkpoint. You re-enter every URL, executable, argument, path, environment reference, client entry, and browser or OAuth detail. The checkpoint stays after cancellation or a failed pre-publication write, and is removed only after a configuration is created or you explicitly discard it. This safe resume is intentionally limited to the bare `connector` path; import and browser-sign-in data are never persisted. In the first-run Console, **Save connector choice**, **Continue saved connector choice**, and **Discard saved choice** use that same checkpoint and never use browser storage. + If a selected client entry cannot be safely imported, Miftah writes no configuration from it. Miftah does not retain rejected arguments, headers, environment values, or credentials. The guided CLI keeps the non-secret configuration name and output location, then takes you to manual transport setup: re-enter a reviewed local executable with literal arguments, or a canonical HTTPS endpoint. In the Console, choose **Set up local executable manually** or **Set up remote HTTPS MCP manually**; it clears the pasted client JSON before opening the existing form. Configure authentication separately through the upstream's documented flow and Miftah secret references. When setup finishes, Miftah tells you whether it ran a reviewed check, skipped one, or had no declared safe check at all. Browser sign-in setup can finish with browser authorization still outstanding. It saves a discovered connection plan, not a credential; use the later connect action to start the provider's browser flow. A completed configuration is not the same as a working client. You still review and merge any client JSON yourself, then restart or reconnect the client. diff --git a/docs/cli.md b/docs/cli.md index 7679a5d..af1284f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ Documented command names, options, JSON success forms, and exit categories are c | `miftah validate --config ` | `--config` | `--config ` | Validates the JSON configuration without starting an upstream. Writes a JSON object with `ok`, `name`, and `profiles`. | | `miftah doctor --config ` | `--config` | `--config `, `--json` | Validates configuration and checks upstream readiness. Default output is a human-readable report; `--json` writes only the JSON report. A healthy or degraded report exits `0`; a failed report exits `1`. | | `miftah schema` | none | none | Writes the Miftah JSON Schema as pretty-printed JSON. | -| `miftah setup [name]` | none, or `--add-profile --config ` | The safe preset options accepted by `init`, except `--interactive`, plus `--native-oauth` and `--verify`; `--import-file ` with `--import-entry `; `--add-profile --config --profile --oauth-client-secrets-file ` for a reviewed provider-owned account; or `--add-profile --config --profile --credential-env ` for a simple local environment-backed account | Opens the guided first-configuration flow. With a bare TTY invocation, it asks what you already have: `connector`, `remote`, `local`, `browser sign-in`, or `import`. The connector path selects a known connector or pinned package; remote creates a strict generic HTTPS reference without calling it; local collects a literal executable and argument array; browser sign-in runs endpoint-first native OAuth discovery; and import requires one explicit absolute client JSON file, lists only entry names, and requires one selected entry. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For `local-stdio`, it collects literal argv elements, shows a no-secret review summary, and requires acknowledgement before saving; it never starts that generic local executable. For Google Search Console it can collect one or more named accounts and an explicit default. `--add-profile` either adds one later reviewed provider account or, only for a single simple local credential binding, a new environment-backed account. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile, or just the newly added account; it is unavailable for generic environment-backed addition and client-entry import because Miftah does not infer a safe probe. The explicit and guided import paths accept one selected local stdio or credential-free HTTPS remote entry and never edit a client configuration file. | +| `miftah setup [name]` | none, or `--add-profile --config ` | The safe preset options accepted by `init`, except `--interactive`, plus `--native-oauth` and `--verify`; `--import-file ` with `--import-entry `; `--add-profile --config --profile --oauth-client-secrets-file ` for a reviewed provider-owned account; `--add-profile --config --profile --credential-env ` for a simple local environment-backed account; or exactly one of `--resume` and `--discard-draft` for a private connector-choice checkpoint | Opens the guided first-configuration flow. With a bare TTY invocation, it asks what you already have: `connector`, `remote`, `local`, `browser sign-in`, or `import`. The connector path selects a known connector or pinned package; remote creates a strict generic HTTPS reference without calling it; local collects a literal executable and argument array; browser sign-in runs endpoint-first native OAuth discovery; and import requires one explicit absolute client JSON file, lists only entry names, and requires one selected entry. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For `local-stdio`, it collects literal argv elements, shows a no-secret review summary, and requires acknowledgement before saving; it never starts that generic local executable. For Google Search Console it can collect one or more named accounts and an explicit default. `--add-profile` either adds one later reviewed provider account or, only for a single simple local credential binding, a new environment-backed account. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile, or just the newly added account; it is unavailable for generic environment-backed addition and client-entry import because Miftah does not infer a safe probe. The explicit and guided import paths accept one selected local stdio or credential-free HTTPS remote entry and never edit a client configuration file. | | `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix `, `--oauth-client-secrets-file `, `--local-command `, repeated `--arg `, `--cwd `, `--accept-local-command` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | | `miftah migrate-config --config ` | `--config` | `--config `, `--write` | Plans a supported configuration-format migration and writes a safe JSON report. It is dry-run by default. `--write` validates the candidate, makes an exact exclusive `.bak`, then uses a same-directory non-overwriting publication for a changed regular non-symlink source; it never resolves secrets or starts an upstream. | | `miftah profile list --config ` | `--config` | `--config ` | Lists the durable default and fixed non-secret metadata for every configured account: name, optional description/tags/policy, and profile-level upstream override names. It only loads and validates configuration; it never resolves secret references, reads credentials, headers, OAuth vault entries, or provider token caches, starts an upstream, or changes configuration. | @@ -78,6 +78,8 @@ miftah validate --config "$HOME/Miftah configs/work wrapper.json" `miftah setup ... --plan` validates a complete noninteractive first-configuration candidate and prints a structural, non-secret JSON plan without creating a directory, writing a file, or contacting an upstream. It includes the supplied output location, but deliberately excludes the endpoint, executable, working directory, arguments, environment names, credential references, headers, OAuth details, and secret values. `--plan` is unavailable for interactive, import, profile-addition, and native-OAuth setup because those flows have their own review or discovery boundaries. +For a bare interactive `connector` journey, Miftah writes a private checkpoint after the configuration name and catalog preset are selected, before it asks for connection details. `miftah setup --resume` requires a TTY and can be combined with no setup-input option. It restores only that name and preset, then asks again for every URL, executable, argument, path, environment reference, credential-file choice, client selection, and OAuth/browser detail. It never creates a configuration until the normal final publication succeeds. `miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY. Both operations reject a malformed, stale, concurrent, unsafe, or unavailable private checkpoint rather than replacing or guessing from it. + `miftah setup --add-profile --config ` has two typed returning-user paths. For a reviewed provider-owned adapter, noninteractive use requires `--profile` and `--oauth-client-secrets-file`; `--description`, `--make-default`, and `--verify` are optional. Miftah proves that the entire selected configuration matches one adapter and that every existing account has a literal absolute credential-file reference plus a distinct canonical provider-state directory. It then atomically adds one new profile and isolated state directory through that adapter contract. Miftah never reads the credential file or the upstream token cache. Unsupported, modified, mixed, or shared-state configurations fail closed with `PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED`; an invalid credential-file path fails with `PROVIDER_ACCOUNT_INPUT_INVALID`. `--verify` checks the new account only. For a static local account, noninteractive use requires `--profile` and `--credential-env`; the latter is an environment-variable name, never a secret value. This path accepts only a configuration with exactly one unnamed `stdio` upstream, one direct `${ENV_NAME}` credential binding in every profile, one shared destination/policy, no provider adapter, no native OAuth, and no named upstreams or other profile overrides. It atomically adds one profile that points at a different environment variable, enforces profile-switch confirmation and explicit destructive selection, and never starts the upstream or reads the credential. Remote HTTP credentials use headers rather than profile environments, so remote configurations fail closed with `ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED`. Generic `--verify` is deliberately rejected because Miftah has no declared safe probe. The durable default changes only when `--make-default` is present, so existing MCP clients still need a restart or new connection. diff --git a/docs/console-api.md b/docs/console-api.md index 41d16bc..c950c49 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -37,9 +37,14 @@ Every request must use the exact listener `Host`. Browser mutations, including b `POST /api/v1/profiles/environment-account` accepts only a profile slug, optional description, an environment-variable name, and optional `makeDefault: true`. It is available only for the currently selected trusted configuration when every existing profile has one matching direct credential environment binding for a single local `stdio` upstream. It rejects remote HTTP, provider-adapter, OAuth, named-upstream, raw credential, duplicate source-variable, and arbitrary profile-override cases. The operation records intent and completion in the fail-closed Console journal, writes via the guarded configuration transaction, never starts the upstream, and returns no credential environment reference or configuration bytes. +The unconfigured first-run dashboard exposes **Save connector choice**, **Continue saved connector choice**, and **Discard saved choice** through `GET`, `PUT`, and `DELETE /api/v1/setup-draft`. The request and response contain only `source: "connector"`, a bounded configuration name, a catalog preset, stage, revision, and timestamp. `PUT` and `DELETE` require the existing exact-Origin CSRF proof; `DELETE` also requires the exact revision. The checkpoint is private, owner-restricted, versioned, and shared with `miftah setup --resume`; it is not stored in browser memory beyond the active page, localStorage, sessionStorage, configuration files, audit records, OAuth metadata, or client settings. It never accepts or returns a URL, local command, argument, working directory, credential reference or value, header, client-entry document, client-secret path, token/cache location, browser state, OAuth code, or OAuth token. It is available only while no configuration has been selected or created, and it is cleared after successful first-run publication or an explicit discard. + | Method and path | Purpose | | --- | --- | | `POST /api/v1/sessions` | Exchange the one-use bootstrap code for one browser session. | +| `GET /api/v1/setup-draft` | Return the current first-run safe connector checkpoint, or `null`. Requires a Console session and returns no connection details. | +| `PUT /api/v1/setup-draft` | Save one strict non-secret connector name/preset/stage checkpoint. Requires CSRF and optional exact expected revision. | +| `DELETE /api/v1/setup-draft` | Discard one exact-revision first-run checkpoint. Requires CSRF and never creates or changes a configuration. | | `POST /api/v1/onboarding/preset` | Exclusively create the first validated configuration from a reviewed preset and safe connector metadata. Requires CSRF; GSC accepts only structured account names/descriptions/client-secrets paths and rejects raw credential values. `local-stdio` additionally requires an explicit acknowledgement and stores only a literal executable/argument array; onboarding does not launch it. | | `POST /api/v1/onboarding/client-entry` | Exclusively create the first validated configuration from one explicitly selected local stdio or credential-free HTTPS remote client entry. Requires CSRF; the pasted document is in-memory only. Local imports use the shared static launch grammar; remote imports require a `url` plus explicit `type: "http"` or `"streamable-http"`, HTTPS without userinfo/query/fragment or opaque credential-shaped path segments, and never trigger OAuth discovery or an upstream call. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable, not bare runners or `.cmd`/`.bat` shims. `env`, headers, shell settings, unsupported remote transports, environment wrappers, inline code, opaque values or assignments, unpinned package references, and credential-shaped arguments are rejected. | | `POST /api/v1/onboarding/native-oauth` | Exclusively create the first validated v3 native-OAuth profile, upstream, and connection. Requires CSRF and refuses an existing file. | diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index d181b07..c024da3 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -115,6 +115,10 @@ export const ERROR_EXIT_CODES = { ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED: CLI_EXIT_CODES.config, ENVIRONMENT_PROFILE_INPUT_INVALID: CLI_EXIT_CODES.config, CLIENT_ENTRY_STATIC_LAUNCH_UNSUPPORTED: CLI_EXIT_CODES.config, + SETUP_DRAFT_INPUT_INVALID: CLI_EXIT_CODES.config, + SETUP_DRAFT_INVALID: CLI_EXIT_CODES.config, + SETUP_DRAFT_CONFLICT: CLI_EXIT_CODES.config, + SETUP_DRAFT_UNAVAILABLE: CLI_EXIT_CODES.operation, CONSOLE_LAUNCHER_UNAVAILABLE: CLI_EXIT_CODES.operation, CONSOLE_CONFIGURATION_NOT_FOUND: CLI_EXIT_CODES.config, CONSOLE_CONFIGURATION_SELECTION_REQUIRED: CLI_EXIT_CODES.config, diff --git a/src/cli/init.ts b/src/cli/init.ts index 7200365..cdf4804 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -17,6 +17,7 @@ import { type SetupConfigurationPreview } from "../setup/setup-configuration.js"; import type { SetupCompletionClientHandoff } from "../setup/setup-completion.js"; +import type { SetupDraftInput, SetupDraftStore } from "../setup/setup-draft.js"; import { CLIENT_NAMES, ClientSnippetError, @@ -60,6 +61,13 @@ export interface InitCommandContext { readonly launcher: ClientLauncher; /** Internal test/runtime seam for endpoint-first OAuth metadata discovery. */ readonly nativeOAuthFetch?: FetchLike; + /** Shared private checkpoint store used only by the guided `setup` command. */ + readonly setupDraftStore?: SetupDraftStore; + /** + * Internal guided-setup seam invoked after safe name/preset selection and before any + * connection, credential, path, client, or OAuth prompt is collected. + */ + readonly onSetupDraftIntent?: (intent: SetupDraftInput) => Promise; } interface InitValues extends PresetBuildOptions { @@ -410,6 +418,7 @@ async function collectInteractiveValues(options: InitCommandOptions, context: In const cancellation = createCancellation(line); try { const name = options.name ?? (await prompt(line, cancellation, "Name", "miftah-wrapper")); + if (name === undefined) usageError("Interactive init requires a name, preset, and output location."); const preset = requirePresetSelection(resolveGuidedPreset( options.preset ?? (await prompt( line, @@ -418,6 +427,7 @@ async function collectInteractiveValues(options: InitCommandOptions, context: In defaultPresetForPlatform() )) )); + await context.onSetupDraftIntent?.({ source: "connector", name, preset, stage: "connection" }); const presetOptions = await collectPresetOptions(line, cancellation, preset, options, context.output); const output = options.output ?? (await prompt(line, cancellation, "Output location", `${name}.miftah.json`)); const client = options.client ?? (await prompt( @@ -426,7 +436,7 @@ async function collectInteractiveValues(options: InitCommandOptions, context: In "Client (claude-desktop, claude-code, cursor, vscode, all; blank for config only)" )); - if (name === undefined || output === undefined) { + if (output === undefined) { usageError("Interactive init requires a name, preset, and output location."); } return { name, preset, output, client, ...presetOptions }; diff --git a/src/cli/main.ts b/src/cli/main.ts index 518dc55..203e0af 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -32,6 +32,7 @@ import { resolvePath } from "../config/path-resolve.js"; import { startConsoleServer } from "../console/console-server.js"; import { openSystemBrowser } from "../console/open-browser.js"; import { ConsoleDashboardApplicationService } from "../console/console-dashboard-application-service.js"; +import { FileSetupDraftStore } from "../setup/setup-draft.js"; function oauthSelector(args: { readonly connection?: string; readonly profile?: string; readonly upstream?: string }) { return { @@ -133,7 +134,8 @@ async function dashboardServe( ? new ConsoleDashboardApplicationService({ defaultConfigPath: configPath, configDirectory: dirname(resolvePath(configPath)), - launcher + launcher, + setupDraftStore: new FileSetupDraftStore() }) : undefined; const server = await startConsoleServer(configPath, { @@ -197,7 +199,8 @@ async function main(argv = process.argv.slice(2)): Promise { launcher: { command: process.execPath, args: [fileURLToPath(import.meta.url), "serve"] - } + }, + setupDraftStore: new FileSetupDraftStore() }); if (result.exitCode !== 0) process.exitCode = result.exitCode; return; diff --git a/src/cli/parse.ts b/src/cli/parse.ts index c738fb2..c0b9bae 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -41,7 +41,9 @@ type BooleanOptionName = | "addProfile" | "makeDefault" | "clearDescription" - | "acceptLocalCommand"; + | "acceptLocalCommand" + | "resume" + | "discardDraft"; type CliOptionName = ValueOptionName | BooleanOptionName; export interface CliOptions { @@ -101,6 +103,10 @@ export interface CliOptions { readonly makeDefault?: true; /** Explicitly removes a non-secret profile description. */ readonly clearDescription?: true; + /** Resumes only a private, non-secret connector intent and re-prompts connection details. */ + readonly resume?: true; + /** Deletes the private, non-secret connector setup intent without creating a configuration. */ + readonly discardDraft?: true; } interface CliCommandMetadata { @@ -185,7 +191,9 @@ export const CLI_COMMANDS = { "addProfile", "makeDefault", "plan", - "verify" + "verify", + "resume", + "discardDraft" ] }, "profile set-default": { @@ -540,6 +548,18 @@ const OPTION_DEFINITIONS: Record = { usage: "--clear-description", description: "Explicitly remove the selected profile's non-secret description." }, + resume: { + name: "resume", + takesValue: false, + usage: "--resume", + description: "Resume a private connector choice and re-enter all connection details." + }, + discardDraft: { + name: "discardDraft", + takesValue: false, + usage: "--discard-draft", + description: "Discard the private connector choice without writing a configuration." + }, acceptLocalCommand: { name: "acceptLocalCommand", takesValue: false, @@ -590,6 +610,8 @@ const FLAG_DEFINITIONS: Record "--add-profile": OPTION_DEFINITIONS.addProfile, "--make-default": OPTION_DEFINITIONS.makeDefault, "--clear-description": OPTION_DEFINITIONS.clearDescription, + "--resume": OPTION_DEFINITIONS.resume, + "--discard-draft": OPTION_DEFINITIONS.discardDraft, "--accept-local-command": OPTION_DEFINITIONS.acceptLocalCommand, "--help": "help", "-h": "help", diff --git a/src/cli/setup.ts b/src/cli/setup.ts index a3642e1..4377666 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -15,6 +15,7 @@ import { previewInitCommand, runInitCommand, type InitCommandContext, + type InitCommandResult, type InitCommandOptions } from "./init.js"; import { @@ -30,11 +31,24 @@ import { type SetupCompletionClientHandoff, type SetupCompletionVerification } from "../setup/setup-completion.js"; +import type { SetupDraft, SetupDraftStore } from "../setup/setup-draft.js"; /** `init` remains network-free; only guided `setup --verify` may run the reviewed provider probe. */ export type SetupCommandOptions = InitCommandOptions & Pick< CliOptions, - "config" | "description" | "makeDefault" | "upstream" | "plan" | "verify" | "importFile" | "importEntry" | "nativeOAuth" | "addProfile" | "profile" + | "config" + | "description" + | "makeDefault" + | "upstream" + | "plan" + | "verify" + | "importFile" + | "importEntry" + | "nativeOAuth" + | "addProfile" + | "profile" + | "resume" + | "discardDraft" >; export interface SetupCommandResult { @@ -262,12 +276,155 @@ function writeCliSetupCompletion( } } +async function finishCreatedSetup( + options: Pick, + context: InitCommandContext, + created: InitCommandResult +): Promise { + if ( + created.config.version === "3" && + created.config.upstream?.transport === "streamable-http" && + created.config.oauth === undefined + ) { + context.output.write( + "Generic remote setup did not discover authentication or call the endpoint. If this MCP needs browser sign-in, start 'miftah setup' again and choose 'browser-sign-in'.\n" + ); + } + if (created.providerAdapter?.diagnostics.safeReadProbe === undefined) { + writeCliSetupCompletion(context, { + verification: "not-declared", + clientHandoff: created.clientHandoff ?? "not-generated", + configPath: created.output + }); + return { verification: "not-applicable", exitCode: 0, reports: [] }; + } + const decision = options.verify === true ? "verify" : await confirmReadiness(context, "every account now"); + if (decision === "skip") { + context.output.write("First-success verification was skipped; the configuration was created but has not been tested with the provider.\n"); + writeCliSetupCompletion(context, { + verification: "skipped", + clientHandoff: created.clientHandoff ?? "not-generated", + profile: created.config.defaultProfile, + configPath: created.output + }); + return { verification: "skipped", exitCode: 0, reports: [] }; + } + if (decision === "cancelled") { + context.output.write("First-success verification was cancelled after configuration creation; the configuration remains available.\n"); + writeCliSetupCompletion(context, { + verification: "incomplete", + clientHandoff: created.clientHandoff ?? "not-generated", + profile: created.config.defaultProfile, + configPath: created.output + }); + return { verification: "incomplete", exitCode: 1, reports: [] }; + } + + const reports: ProfileReadinessReport[] = []; + let incomplete = false; + for (const profile of Object.keys(created.config.profiles).sort()) { + try { + const report = await runProfileReadiness(created.output, { profile }); + reports.push(report); + writeReadinessReport(context, report); + if (report.status !== "ready") incomplete = true; + } catch (error) { + incomplete = true; + const code = error instanceof MiftahError ? error.code : "UPSTREAM_CALL_FAILED"; + context.output.write(`Profile '${profile}': readiness did not complete (${code}).\n`); + } + } + writeCliSetupCompletion(context, { + verification: incomplete ? "incomplete" : "complete", + clientHandoff: created.clientHandoff ?? "not-generated", + ...(incomplete ? { profile: created.config.defaultProfile } : {}), + configPath: created.output + }); + return { verification: incomplete ? "incomplete" : "complete", exitCode: incomplete ? 1 : 0, reports }; +} + +function setupDraftIncompatibleOption(options: SetupCommandOptions): string | undefined { + return [ + "interactive", + "config", + "description", + "makeDefault", + "upstream", + "plan", + "verify", + "importFile", + "importEntry", + "nativeOAuth", + "addProfile", + "profile", + "name", + "preset", + "output", + "client", + "credentialEnv", + "npmPackage", + "dockerImage", + "url", + "headerName", + "headerPrefix", + "oauthClientSecretsFile", + "localCommand", + "args", + "cwd", + "acceptLocalCommand", + "googleSearchConsoleProfiles", + "defaultProfile" + ].find((name) => options[name as keyof SetupCommandOptions] !== undefined); +} + +function setupDraftStore(context: InitCommandContext): SetupDraftStore { + if (context.setupDraftStore === undefined) { + throw new CliUsageError("Resumable setup is unavailable in this embedding."); + } + return context.setupDraftStore; +} + /** * Starts the human-first setup journey while retaining `init` for scripts and * existing automation. Both entry points deliberately use the same planner, * validation, config writer, and client-handoff implementation. */ export async function runSetupCommand(options: SetupCommandOptions, context: InitCommandContext): Promise { + if (options.resume === true && options.discardDraft === true) { + throw new CliUsageError("Choose either '--resume' or '--discard-draft', not both."); + } + if (options.resume === true || options.discardDraft === true) { + const incompatible = setupDraftIncompatibleOption(options); + const option = options.resume === true ? "resume" : "discard-draft"; + if (incompatible !== undefined) { + throw new CliUsageError(`Option '--${option}' cannot be combined with '--${flagName(incompatible)}'.`); + } + const store = setupDraftStore(context); + const draft = await store.load(); + if (options.discardDraft === true) { + if (draft === undefined) { + context.output.write("No saved connector setup is available to discard.\n"); + } else { + await store.discard(draft.revision); + context.output.write("Discarded the saved connector setup choice.\n"); + } + return { verification: "not-applicable", exitCode: 0, reports: [] }; + } + if (!isTty(context)) { + throw new CliUsageError("Option '--resume' requires TTY input and output so connection details can be re-entered."); + } + if (draft === undefined) { + throw new CliUsageError("No saved connector setup is available to resume. Start 'miftah setup' and choose a connector first."); + } + context.output.write("Resuming the saved connector choice. Re-enter all connection details before Miftah creates a configuration.\n"); + const created = await runInitCommand({ + interactive: true, + name: draft.name, + preset: draft.preset + }, context); + await store.discard(draft.revision); + return finishCreatedSetup(options, context, created); + } if (options.plan === true) { const incompatible = [ "interactive", @@ -436,6 +593,8 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini } let guidedPreset: "streamable-http" | "local-stdio" | undefined; let recoveredManualConfiguration: Awaited> | undefined; + let checkpointedDraft: SetupDraft | undefined; + let checkpointConnectorIntent = false; if (isTty(context) && !hasExplicitNewConfigurationInput(options)) { const startingPoint = await chooseGuidedSetupStartingPoint(context); if (startingPoint === "import") { @@ -465,72 +624,35 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini if (recoveredManualConfiguration === undefined && (startingPoint === "remote" || startingPoint === "local")) { guidedPreset = startingPoint === "remote" ? "streamable-http" : "local-stdio"; } + checkpointConnectorIntent = startingPoint === "connector"; } + const initContext = checkpointConnectorIntent && context.setupDraftStore !== undefined + ? { + ...context, + onSetupDraftIntent: async (intent: Parameters>[0]) => { + try { + checkpointedDraft = await context.setupDraftStore!.save(intent); + } catch (error) { + if (error instanceof MiftahError && error.code === "SETUP_DRAFT_INPUT_INVALID") { + context.output.write( + "This connector name or preset cannot be safely saved as a resumable setup choice; continuing without a resume point.\n" + ); + return; + } + throw error; + } + } + } + : context; const created = recoveredManualConfiguration ?? await runInitCommand({ ...options, interactive: true, ...(guidedPreset === undefined ? {} : { preset: guidedPreset }) - }, context); - if ( - created.config.version === "3" && - created.config.upstream?.transport === "streamable-http" && - created.config.oauth === undefined - ) { - context.output.write( - "Generic remote setup did not discover authentication or call the endpoint. If this MCP needs browser sign-in, start 'miftah setup' again and choose 'browser-sign-in'.\n" - ); + }, initContext); + if (checkpointedDraft !== undefined) { + await context.setupDraftStore!.discard(checkpointedDraft.revision); } - if (created.providerAdapter?.diagnostics.safeReadProbe === undefined) { - writeCliSetupCompletion(context, { - verification: "not-declared", - clientHandoff: created.clientHandoff ?? "not-generated", - configPath: created.output - }); - return { verification: "not-applicable", exitCode: 0, reports: [] }; - } - const decision = options.verify === true ? "verify" : await confirmReadiness(context, "every account now"); - if (decision === "skip") { - context.output.write("First-success verification was skipped; the configuration was created but has not been tested with the provider.\n"); - writeCliSetupCompletion(context, { - verification: "skipped", - clientHandoff: created.clientHandoff ?? "not-generated", - profile: created.config.defaultProfile, - configPath: created.output - }); - return { verification: "skipped", exitCode: 0, reports: [] }; - } - if (decision === "cancelled") { - context.output.write("First-success verification was cancelled after configuration creation; the configuration remains available.\n"); - writeCliSetupCompletion(context, { - verification: "incomplete", - clientHandoff: created.clientHandoff ?? "not-generated", - profile: created.config.defaultProfile, - configPath: created.output - }); - return { verification: "incomplete", exitCode: 1, reports: [] }; - } - - const reports: ProfileReadinessReport[] = []; - let incomplete = false; - for (const profile of Object.keys(created.config.profiles).sort()) { - try { - const report = await runProfileReadiness(created.output, { profile }); - reports.push(report); - writeReadinessReport(context, report); - if (report.status !== "ready") incomplete = true; - } catch (error) { - incomplete = true; - const code = error instanceof MiftahError ? error.code : "UPSTREAM_CALL_FAILED"; - context.output.write(`Profile '${profile}': readiness did not complete (${code}).\n`); - } - } - writeCliSetupCompletion(context, { - verification: incomplete ? "incomplete" : "complete", - clientHandoff: created.clientHandoff ?? "not-generated", - ...(incomplete ? { profile: created.config.defaultProfile } : {}), - configPath: created.output - }); - return { verification: incomplete ? "incomplete" : "complete", exitCode: incomplete ? 1 : 0, reports }; + return finishCreatedSetup(options, context, created); } async function confirmReadiness(context: InitCommandContext, target: string): Promise { diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index a1f3f96..33e7ff0 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -53,6 +53,7 @@ import { runProfileReadinessFromLoadedConfig, type ProfileReadinessReport } from "../setup/profile-readiness.js"; +import type { SetupDraft, SetupDraftInput } from "../setup/setup-draft.js"; import { runProviderAccountAddition, type ProviderAccountAdditionAuditSink, @@ -307,6 +308,12 @@ export interface ConsoleControlApplication { previewPreset?(request: ConsolePresetOnboardingRequest): Promise; /** Available only when the embedding supports first-run known-connector setup. */ onboardPreset?(request: ConsolePresetOnboardingRequest): Promise; + /** Available only when the embedding supports a private, first-run connector setup checkpoint. */ + loadSetupDraft?(): Promise; + /** Available only when the embedding supports a private, first-run connector setup checkpoint. */ + saveSetupDraft?(input: SetupDraftInput, expectedRevision?: number): Promise; + /** Available only when the embedding supports a private, first-run connector setup checkpoint. */ + discardSetupDraft?(expectedRevision: number): Promise; /** Available only when the embedding supports first-run selected local stdio entry import. */ onboardClientEntry?(request: ConsoleClientEntryOnboardingRequest): Promise; /** Available only when the embedding supports endpoint-first native OAuth discovery. */ diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index 17075a4..8c008ba 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -127,6 +127,10 @@ const page = ` +
+

Save your configuration name and connector choice to continue later. Miftah does not save a URL, executable, arguments, path, client entry, environment variable, secret, OAuth state, or browser sign-in details. You will enter those again when you continue.

+
+
@@ -549,6 +553,9 @@ const script = `(() => { const presetReviewDetails = byId("preset-review-details"); const presetCreateReviewed = byId("preset-create-reviewed"); const presetReviewEdit = byId("preset-review-edit"); + const saveSetupDraft = byId("save-setup-draft"); + const resumeSetupDraft = byId("resume-setup-draft"); + const discardSetupDraft = byId("discard-setup-draft"); const presetInputNames = Object.freeze({ generic: ["credentialEnv"], "github": [], @@ -566,6 +573,7 @@ const script = `(() => { let pendingPresetRequest = undefined; let presetReviewGeneration = 0; let presetCreateInFlight = false; + let activeSetupDraft = undefined; function message(text) { if (status) status.textContent = text; @@ -578,6 +586,8 @@ const script = `(() => { function restoreUnlock() { csrfToken = ""; setupCompletion = undefined; + activeSetupDraft = undefined; + renderSetupDraftControls(); clearPresetReview(); if (setupCompletionView) setupCompletionView.hidden = true; if (dashboardView) dashboardView.hidden = true; @@ -1189,6 +1199,79 @@ const script = `(() => { if (preset === "google-search-console") ensureGoogleSearchConsoleAccountRow(); } + function renderSetupDraftControls() { + if (discardSetupDraft instanceof HTMLButtonElement) { + discardSetupDraft.hidden = activeSetupDraft === undefined; + discardSetupDraft.disabled = activeSetupDraft === undefined; + } + } + + function setupDraftIntent() { + const form = byId("preset-onboarding-form"); + const selection = byId("preset-selection"); + const name = form instanceof HTMLFormElement ? form.querySelector("input[name='name']") : undefined; + if (!(name instanceof HTMLInputElement) || !(selection instanceof HTMLSelectElement)) { + throw new Error("The connector setup form is unavailable."); + } + const configurationName = name.value.trim(); + const preset = selection.value; + if (!/^[a-z0-9](?:[a-z0-9._-]{0,63})?$/u.test(configurationName)) { + throw new Error("Use a lowercase configuration name of up to 64 letters, numbers, dots, underscores, or hyphens before saving."); + } + if (!Object.prototype.hasOwnProperty.call(presetInputNames, preset)) { + throw new Error("Choose a supported connector before saving."); + } + return { + source: "connector", + name: configurationName, + preset, + stage: "connection", + expectedRevision: activeSetupDraft === undefined ? 0 : activeSetupDraft.revision + }; + } + + function restoreSetupDraft(value) { + const draft = record(value); + const allowedFields = new Set(["schemaVersion", "revision", "source", "name", "preset", "stage", "savedAt"]); + if ( + Object.keys(draft).length !== allowedFields.size || + Object.keys(draft).some((field) => !allowedFields.has(field)) || + draft.schemaVersion !== 1 || + !Number.isSafeInteger(draft.revision) || draft.revision < 1 || + draft.source !== "connector" || + typeof draft.name !== "string" || !/^[a-z0-9](?:[a-z0-9._-]{0,63})?$/u.test(draft.name) || + typeof draft.preset !== "string" || !Object.prototype.hasOwnProperty.call(presetInputNames, draft.preset) || + draft.stage !== "connection" || + typeof draft.savedAt !== "string" + ) { + throw new Error("Miftah did not return a safe saved connector choice."); + } + const form = byId("preset-onboarding-form"); + const selection = byId("preset-selection"); + const name = form instanceof HTMLFormElement ? form.querySelector("input[name='name']") : undefined; + if (!(form instanceof HTMLFormElement) || !(selection instanceof HTMLSelectElement) || !(name instanceof HTMLInputElement)) { + throw new Error("The connector setup form is unavailable."); + } + form.reset(); + const accounts = byId("gsc-account-list"); + if (accounts instanceof HTMLElement) accounts.replaceChildren(); + activeSetupDraft = { + schemaVersion: 1, + revision: draft.revision, + source: "connector", + name: draft.name, + preset: draft.preset, + stage: "connection", + savedAt: draft.savedAt + }; + name.value = activeSetupDraft.name; + selection.value = activeSetupDraft.preset; + updateSetupSourceChoice("connector"); + updatePresetFields(); + clearPresetReview(); + renderSetupDraftControls(); + } + function setPresetReviewActionsDisabled(disabled) { if (presetCreateReviewed instanceof HTMLButtonElement) presetCreateReviewed.disabled = disabled; if (presetReviewEdit instanceof HTMLButtonElement) presetReviewEdit.disabled = disabled; @@ -1549,6 +1632,49 @@ const script = `(() => { }); } + if (saveSetupDraft instanceof HTMLButtonElement) { + saveSetupDraft.addEventListener("click", async () => { + message("Saving only the connector choice…"); + try { + const draft = await api("/api/v1/setup-draft", { method: "PUT", body: setupDraftIntent() }); + restoreSetupDraft(draft); + message("Saved the configuration name and connector choice. Re-enter every connection detail when you continue."); + } catch (error) { message(errorMessage(error)); } + }); + } + + if (resumeSetupDraft instanceof HTMLButtonElement) { + resumeSetupDraft.addEventListener("click", async () => { + message("Loading the saved connector choice…"); + try { + const draft = await api("/api/v1/setup-draft"); + if (draft === undefined || draft === null) { + activeSetupDraft = undefined; + renderSetupDraftControls(); + message("No saved connector choice exists. Start with a configuration name and connector above."); + return; + } + restoreSetupDraft(draft); + message("Restored only the configuration name and connector choice. Re-enter every connection value before reviewing."); + } catch (error) { message(errorMessage(error)); } + }); + } + + if (discardSetupDraft instanceof HTMLButtonElement) { + discardSetupDraft.addEventListener("click", async () => { + if (activeSetupDraft === undefined) return; + message("Discarding the saved connector choice…"); + try { + await api("/api/v1/setup-draft", { method: "DELETE", body: { revision: activeSetupDraft.revision } }); + activeSetupDraft = undefined; + renderSetupDraftControls(); + message("Discarded the saved connector choice. The current form stays open and is not saved."); + } catch (error) { message(errorMessage(error)); } + }); + } + + renderSetupDraftControls(); + if (presetCreateReviewed instanceof HTMLButtonElement) { presetCreateReviewed.addEventListener("click", async () => { const request = pendingPresetRequest; @@ -1565,6 +1691,8 @@ const script = `(() => { setupCompletion = record(result.completion); renderSetupCompletion(setupCompletion); clearPresetReview(); + activeSetupDraft = undefined; + renderSetupDraftControls(); if (presetOnboardingForm instanceof HTMLFormElement) { presetOnboardingForm.reset(); updatePresetFields(); diff --git a/src/console/console-dashboard-application-service.ts b/src/console/console-dashboard-application-service.ts index 59288e3..f0035d4 100644 --- a/src/console/console-dashboard-application-service.ts +++ b/src/console/console-dashboard-application-service.ts @@ -42,6 +42,7 @@ import { } from "./console-config-catalog.js"; import type { ConsoleConfigCatalog, ConsoleConfigMetadata } from "./console-config-metadata.js"; import type { ConsoleTrustedConfiguration } from "./console-trusted-configuration.js"; +import type { SetupDraft, SetupDraftInput, SetupDraftStore } from "../setup/setup-draft.js"; export interface ConsoleDashboardApplicationServiceOptions { /** Destination used only for a genuine first native OAuth configuration. */ @@ -51,6 +52,8 @@ export interface ConsoleDashboardApplicationServiceOptions { readonly launcher?: ClientLauncher; /** Internal test/runtime seam for guarded endpoint-first OAuth discovery. */ readonly nativeOAuthFetch?: FetchLike; + /** Shared private setup checkpoint store; omitted embeddings expose no draft controls. */ + readonly setupDraftStore?: SetupDraftStore; } interface ActiveConsoleConfiguration { @@ -85,11 +88,31 @@ function withCatalog(metadata: ConsoleConfigMetadata, catalog: ConsoleConfigCata */ export class ConsoleDashboardApplicationService implements ConsoleControlApplication { private readonly firstRunApplication: ConsoleApplicationService; + private readonly setupDraftStore: SetupDraftStore | undefined; + readonly loadSetupDraft: (() => Promise) | undefined; + readonly saveSetupDraft: ((input: SetupDraftInput, expectedRevision?: number) => Promise) | undefined; + readonly discardSetupDraft: ((expectedRevision: number) => Promise) | undefined; private active: ActiveConsoleConfiguration | undefined; private discoveryInFlight: Promise | undefined; constructor(private readonly options: ConsoleDashboardApplicationServiceOptions) { this.firstRunApplication = this.applicationFor(options.defaultConfigPath); + this.setupDraftStore = options.setupDraftStore; + const store = this.setupDraftStore; + if (store !== undefined) { + this.loadSetupDraft = async () => { + await this.assertFirstRunAvailable(); + return store.load(); + }; + this.saveSetupDraft = async (input, expectedRevision) => { + await this.assertFirstRunAvailable(); + return store.save(input, expectedRevision); + }; + this.discardSetupDraft = async (expectedRevision) => { + await this.assertFirstRunAvailable(); + await store.discard(expectedRevision); + }; + } } async configMetadata(): Promise { @@ -140,6 +163,7 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica ): Promise { await this.assertFirstRunAvailable(); const result = await this.firstRunApplication.onboardNativeOAuth(request); + await this.clearSetupDraftAfterFirstRunPublication(); await this.confirmCreatedFirstRunConfiguration(); return result; } @@ -149,6 +173,7 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica ): Promise { await this.assertFirstRunAvailable(); const result = await this.firstRunApplication.onboardDiscoveredNativeOAuth(request); + await this.clearSetupDraftAfterFirstRunPublication(); await this.confirmCreatedFirstRunConfiguration(); return result; } @@ -156,6 +181,7 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica async onboardPreset(request: ConsolePresetOnboardingRequest): Promise { await this.assertFirstRunAvailable(); const result = await this.firstRunApplication.onboardPreset(request); + await this.clearSetupDraftAfterFirstRunPublication(); await this.confirmCreatedFirstRunConfiguration(); return result; } @@ -168,6 +194,7 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica async onboardClientEntry(request: ConsoleClientEntryOnboardingRequest): Promise { await this.assertFirstRunAvailable(); const result = await this.firstRunApplication.onboardClientEntry(request); + await this.clearSetupDraftAfterFirstRunPublication(); await this.confirmCreatedFirstRunConfiguration(); return result; } @@ -355,6 +382,13 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica } } + private async clearSetupDraftAfterFirstRunPublication(): Promise { + const store = this.setupDraftStore; + if (store === undefined) return; + const draft = await store.load(); + if (draft !== undefined) await store.discard(draft.revision); + } + private async confirmCreatedFirstRunConfiguration(): Promise { const refreshed = await this.discover(); const configuredPath = resolvePath(this.options.defaultConfigPath); diff --git a/src/console/console-server.ts b/src/console/console-server.ts index be5d493..3174cbc 100644 --- a/src/console/console-server.ts +++ b/src/console/console-server.ts @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { Socket } from "node:net"; import { z } from "zod"; import { loadConfig } from "../config/load-config.js"; +import { PRESET_CATALOG } from "../config/presets.js"; import { MiftahError } from "../utils/errors.js"; import { CLIENT_NAMES, type ClientLauncher, type ClientSelection } from "../cli/client-snippets.js"; import { consoleAsset, type ConsoleAsset } from "./console-assets.js"; @@ -153,6 +154,16 @@ const presetOnboardingSchema = z.object({ }); } }); +const setupDraftSchema = z.object({ + source: z.literal("connector"), + name: z.string().regex(/^[a-z0-9](?:[a-z0-9._-]{0,63})?$/u), + preset: z.string().max(128).refine((value) => Object.hasOwn(PRESET_CATALOG.presets, value)), + stage: z.literal("connection"), + expectedRevision: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional() +}).strict(); +const setupDraftDiscardSchema = z.object({ + revision: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER) +}).strict(); const clientEntryOnboardingSchema = z.object({ name: z.string().min(1).max(256), entry: z.string().min(1).max(256), @@ -600,6 +611,60 @@ class LocalConsoleServer implements ConsoleServer { } return; } + if (request.url === "/api/v1/setup-draft") { + if (request.method === "GET") { + if (this.application.loadSetupDraft === undefined) { + throw new ConsoleHttpError(404, "not_found", "The requested resource does not exist."); + } + try { + const draft = await this.application.loadSetupDraft(); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: draft ?? null }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + if (request.method === "PUT") { + this.requireCsrf(request, session); + const parsed = setupDraftSchema.safeParse(await readJsonBody(request, this.options.maximumRequestBytes)); + if (!parsed.success) throw new ConsoleHttpError(422, "validation_error", "The request body is invalid."); + if (this.application.saveSetupDraft === undefined) { + throw new ConsoleHttpError(404, "not_found", "The requested resource does not exist."); + } + try { + const draft = await this.application.saveSetupDraft({ + source: parsed.data.source, + name: parsed.data.name, + preset: parsed.data.preset, + stage: parsed.data.stage + }, parsed.data.expectedRevision); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: draft }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + if (request.method === "DELETE") { + this.requireCsrf(request, session); + const parsed = setupDraftDiscardSchema.safeParse(await readJsonBody(request, this.options.maximumRequestBytes)); + if (!parsed.success) throw new ConsoleHttpError(422, "validation_error", "The request body is invalid."); + if (this.application.discardSetupDraft === undefined) { + throw new ConsoleHttpError(404, "not_found", "The requested resource does not exist."); + } + try { + await this.application.discardSetupDraft(parsed.data.revision); + session.lastUsedAt = this.options.now(); + response.writeHead(204, { "cache-control": "no-store" }); + response.end(); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET, PUT, DELETE" }); + } if (request.url === "/api/v1/configurations") { if (request.method !== "GET") { throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET" }); diff --git a/src/setup/setup-draft.ts b/src/setup/setup-draft.ts new file mode 100644 index 0000000..4fe2a04 --- /dev/null +++ b/src/setup/setup-draft.ts @@ -0,0 +1,436 @@ +import { randomUUID } from "node:crypto"; +import { constants, type BigIntStats } from "node:fs"; +import { chmod, lstat, mkdir, open, rename, rm } from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { + createWindowsPrivateDirectory, + secureWindowsConfigFile, + verifyWindowsConfigPathSecurity +} from "../cli/windows-config-acl.js"; +import { PRESET_CATALOG, type PresetCatalogName } from "../config/presets.js"; +import { OAuthLocalLockUnavailableError, withOAuthLocalLock } from "../oauth/local-lock.js"; +import { MiftahError } from "../utils/errors.js"; + +const maximumDraftBytes = 4 * 1024; +const draftLockWaitMilliseconds = 5_000; +const draftFileName = "setup-draft-v1.json"; +const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; +const readOnlyFlags = constants.O_RDONLY | noFollowFlag; +const allowedStoredFields = new Set([ + "schemaVersion", + "revision", + "source", + "name", + "preset", + "stage", + "savedAt" +]); +const configurationNamePattern = /^[a-z0-9](?:[a-z0-9._-]{0,63})?$/u; + +/** + * The only user intent persisted by the first resumable setup slice. Connection details, + * output paths, client handoff content, credentials, and OAuth state must be re-entered. + */ +export interface SetupDraftInput { + readonly source: "connector"; + readonly name: string; + /** Untrusted input is runtime-checked against the versioned preset catalog before persistence. */ + readonly preset: string; + readonly stage: "connection"; +} + +/** A versioned, non-secret setup checkpoint shared by the CLI and Console. */ +export interface SetupDraft extends SetupDraftInput { + readonly schemaVersion: 1; + readonly revision: number; + readonly savedAt: string; +} + +/** Minimal storage contract so the CLI and Console can use one safe checkpoint boundary. */ +export interface SetupDraftStore { + load(): Promise; + save(input: SetupDraftInput, expectedRevision?: number): Promise; + discard(expectedRevision: number): Promise; +} + +export interface FileSetupDraftStoreOptions { + /** Overrides the private product state directory for tests or an explicitly managed install. */ + readonly directory?: string; + /** Injected clock for deterministic callers and tests. */ + readonly now?: () => string; +} + +function setupDraftInputInvalid(): never { + throw new MiftahError( + "SETUP_DRAFT_INPUT_INVALID", + "SETUP_DRAFT_INPUT_INVALID: setup draft intent must contain only a bounded connector name, catalog preset, and stage" + ); +} + +function setupDraftInvalid(): never { + throw new MiftahError( + "SETUP_DRAFT_INVALID", + "SETUP_DRAFT_INVALID: persisted setup draft is malformed or contains excluded data" + ); +} + +function setupDraftConflict(): never { + throw new MiftahError( + "SETUP_DRAFT_CONFLICT", + "SETUP_DRAFT_CONFLICT: setup draft changed in another CLI or Console session" + ); +} + +function setupDraftUnavailable(): never { + throw new MiftahError( + "SETUP_DRAFT_UNAVAILABLE", + "SETUP_DRAFT_UNAVAILABLE: private setup draft storage is unavailable" + ); +} + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +function ownedByCurrentUser(metadata: Pick | { readonly uid: number }): boolean { + if (process.platform === "win32" || typeof process.getuid !== "function") return true; + const owner = metadata.uid; + const current = process.getuid(); + return typeof owner === "bigint" ? owner === BigInt(current) : owner === current; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value; +} + +function validRevision(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 1; +} + +function validExpectedRevision(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function validConfigurationName(value: unknown): value is string { + return typeof value === "string" && configurationNamePattern.test(value); +} + +function validPreset(value: unknown): value is PresetCatalogName { + return typeof value === "string" && Object.hasOwn(PRESET_CATALOG.presets, value); +} + +function normalizeInput(value: unknown): SetupDraftInput { + if ( + !isRecord(value) || + Object.keys(value).some((field) => !["source", "name", "preset", "stage"].includes(field)) || + value.source !== "connector" || + !validConfigurationName(value.name) || + !validPreset(value.preset) || + value.stage !== "connection" + ) { + setupDraftInputInvalid(); + } + return { + source: "connector", + name: value.name, + preset: value.preset, + stage: "connection" + }; +} + +function normalizeStoredDraft(value: unknown): SetupDraft { + if ( + !isRecord(value) || + Object.keys(value).length !== allowedStoredFields.size || + Object.keys(value).some((field) => !allowedStoredFields.has(field)) || + value.schemaVersion !== 1 || + !validRevision(value.revision) || + value.source !== "connector" || + !validConfigurationName(value.name) || + !validPreset(value.preset) || + value.stage !== "connection" || + !validTimestamp(value.savedAt) + ) { + setupDraftInvalid(); + } + return Object.freeze({ + schemaVersion: 1, + revision: value.revision, + source: "connector", + name: value.name, + preset: value.preset, + stage: "connection", + savedAt: value.savedAt + }); +} + +function globalStateRoot(): string { + if (platform() === "win32") { + const configured = process.env.LOCALAPPDATA; + return configured !== undefined && isAbsolute(configured) + ? resolve(configured) + : join(homedir(), "AppData", "Local"); + } + if (platform() === "darwin") return join(homedir(), "Library", "Application Support"); + const configured = process.env.XDG_STATE_HOME; + return configured !== undefined && isAbsolute(configured) + ? resolve(configured) + : join(homedir(), ".local", "state"); +} + +function defaultSetupDraftDirectory(): string { + const productDirectory = platform() === "win32" || platform() === "darwin" ? "Miftah" : "miftah"; + return join(globalStateRoot(), productDirectory, "setup"); +} + +/** Returns the single private, non-secret draft path shared across setup surfaces. */ +export function resolveSetupDraftPath(directory: string = defaultSetupDraftDirectory()): string { + return join(resolve(directory), draftFileName); +} + +async function setRestrictiveMode(path: string, mode: number): Promise { + try { + await chmod(path, mode); + } catch (error) { + const code = errorCode(error); + if (code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw error; + } +} + +async function isPrivatePosixPath(path: string, kind: "file" | "directory"): Promise { + try { + const metadata = await lstat(path); + if (metadata.isSymbolicLink()) return false; + if (kind === "file" ? !metadata.isFile() : !metadata.isDirectory()) return false; + return ownedByCurrentUser(metadata) && (metadata.mode & 0o077) === 0; + } catch { + return false; + } +} + +async function ensurePrivateDirectory(directory: string): Promise { + try { + if (process.platform !== "win32") { + await mkdir(directory, { recursive: true, mode: 0o700 }); + await setRestrictiveMode(directory, 0o700); + if (!(await isPrivatePosixPath(directory, "directory"))) setupDraftUnavailable(); + return; + } + const parent = dirname(directory); + await mkdir(parent, { recursive: true, mode: 0o700 }); + if (!(await verifyWindowsConfigPathSecurity(parent, "directory"))) setupDraftUnavailable(); + if (!(await createWindowsPrivateDirectory(directory))) setupDraftUnavailable(); + if (!(await verifyWindowsConfigPathSecurity(directory, "directory"))) setupDraftUnavailable(); + } catch (error) { + if (error instanceof MiftahError) throw error; + setupDraftUnavailable(); + } +} + +async function verifyPrivateFile(path: string): Promise { + if (await privateFileMetadata(path) === undefined) setupDraftUnavailable(); +} + +function sameFileIdentity(left: Pick, right: Pick): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +/** Validates the private draft directory without following a final symlink. */ +async function privateDirectoryMetadata(path: string): Promise { + let metadata: BigIntStats; + try { + metadata = await lstat(path, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") return undefined; + setupDraftUnavailable(); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink() || !ownedByCurrentUser(metadata)) setupDraftUnavailable(); + if (process.platform === "win32") { + if (!(await verifyWindowsConfigPathSecurity(path, "directory"))) setupDraftUnavailable(); + } else if ((Number(metadata.mode) & 0o077) !== 0) { + setupDraftUnavailable(); + } + return metadata; +} + +/** Returns the current secure file identity without following symlinks. */ +async function privateFileMetadata(path: string): Promise { + let metadata: BigIntStats; + try { + metadata = await lstat(path, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") return undefined; + setupDraftUnavailable(); + } + if (!metadata.isFile() || metadata.isSymbolicLink() || !ownedByCurrentUser(metadata)) setupDraftUnavailable(); + if (process.platform === "win32") { + if (!(await verifyWindowsConfigPathSecurity(path, "file"))) setupDraftUnavailable(); + } else if ((Number(metadata.mode) & 0o077) !== 0) { + setupDraftUnavailable(); + } + return metadata; +} + +/** Restrictive, atomic, compare-and-swap persistence for a single non-secret setup intent. */ +export class FileSetupDraftStore implements SetupDraftStore { + private readonly directory: string; + private readonly path: string; + private readonly now: () => string; + + constructor(options: FileSetupDraftStoreOptions = {}) { + this.directory = resolve(options.directory ?? defaultSetupDraftDirectory()); + this.path = resolveSetupDraftPath(this.directory); + this.now = options.now ?? (() => new Date().toISOString()); + } + + async load(): Promise { + return this.loadUnchecked(); + } + + async save(input: SetupDraftInput, expectedRevision?: number): Promise { + const normalized = normalizeInput(input); + if (expectedRevision !== undefined && !validExpectedRevision(expectedRevision)) setupDraftInputInvalid(); + return this.withLock(async () => { + const existing = await this.loadUnchecked(); + if (existing === undefined) { + if (expectedRevision !== undefined && expectedRevision !== 0) setupDraftConflict(); + } else if (expectedRevision !== existing.revision) { + setupDraftConflict(); + } + const revision = existing === undefined ? 1 : existing.revision + 1; + if (!Number.isSafeInteger(revision)) setupDraftUnavailable(); + const draft = normalizeStoredDraft({ + schemaVersion: 1, + revision, + ...normalized, + savedAt: this.now() + }); + await this.persist(draft); + return draft; + }); + } + + async discard(expectedRevision: number): Promise { + if (!validRevision(expectedRevision)) setupDraftInputInvalid(); + await this.withLock(async () => { + const existing = await this.loadUnchecked(); + if (existing === undefined) return; + if (existing.revision !== expectedRevision) setupDraftConflict(); + try { + await rm(this.path, { force: true }); + } catch { + setupDraftUnavailable(); + } + }); + } + + private async withLock(operation: () => Promise): Promise { + try { + await ensurePrivateDirectory(this.directory); + return await withOAuthLocalLock("setup-draft", this.path, draftLockWaitMilliseconds, operation); + } catch (error) { + if (error instanceof MiftahError) throw error; + if (error instanceof OAuthLocalLockUnavailableError) setupDraftUnavailable(); + setupDraftUnavailable(); + } + } + + private async loadUnchecked(): Promise { + if (await privateDirectoryMetadata(this.directory) === undefined) return undefined; + const observed = await privateFileMetadata(this.path); + if (observed === undefined) return undefined; + let handle: Awaited> | undefined; + let content: string | undefined; + let failure: unknown; + try { + handle = await open(this.path, readOnlyFlags); + const opened = await handle.stat({ bigint: true }); + const afterOpen = await privateFileMetadata(this.path); + if ( + afterOpen === undefined || + !sameFileIdentity(observed, opened) || + !sameFileIdentity(opened, afterOpen) + ) { + setupDraftUnavailable(); + } + if (opened.size > BigInt(maximumDraftBytes)) setupDraftInvalid(); + const boundedBytes = Buffer.alloc(maximumDraftBytes + 1); + const { bytesRead } = await handle.read(boundedBytes, 0, boundedBytes.byteLength, 0); + if (bytesRead > maximumDraftBytes) setupDraftInvalid(); + const bytes = boundedBytes.subarray(0, bytesRead); + const afterRead = await handle.stat({ bigint: true }); + const afterReadPath = await privateFileMetadata(this.path); + if ( + afterReadPath === undefined || + !sameFileIdentity(opened, afterRead) || + !sameFileIdentity(afterRead, afterReadPath) || + afterRead.size !== opened.size + ) { + setupDraftUnavailable(); + } + if (bytes.byteLength > maximumDraftBytes) setupDraftInvalid(); + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + setupDraftInvalid(); + } + } catch (error) { + failure = error; + } + try { + await handle?.close(); + } catch (error) { + if (failure === undefined) failure = error; + } + if (failure instanceof MiftahError) throw failure; + if (failure !== undefined || content === undefined) setupDraftUnavailable(); + let value: unknown; + try { + value = JSON.parse(content); + } catch { + setupDraftInvalid(); + } + return normalizeStoredDraft(value); + } + + private async persist(draft: SetupDraft): Promise { + const content = JSON.stringify(draft); + if (Buffer.byteLength(content, "utf8") > maximumDraftBytes) setupDraftUnavailable(); + const temporaryPath = join(this.directory, `.${basename(this.path)}.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + try { + handle = await open(temporaryPath, "wx", 0o600); + if (!(await secureWindowsConfigFile(temporaryPath))) setupDraftUnavailable(); + await handle.writeFile(content, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await setRestrictiveMode(temporaryPath, 0o600); + await verifyPrivateFile(temporaryPath); + await rename(temporaryPath, this.path); + await verifyPrivateFile(this.path); + } catch (error) { + try { + await handle?.close(); + } catch { + // Preserve the stable storage error below. + } + try { + await rm(temporaryPath, { force: true }); + } catch { + // The temporary file contains only the bounded public intent fields above. + } + if (error instanceof MiftahError) throw error; + setupDraftUnavailable(); + } + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 36ecd1d..bc9c6fe 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -120,6 +120,10 @@ export type MiftahErrorCode = | "ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED" | "ENVIRONMENT_PROFILE_INPUT_INVALID" | "CLIENT_ENTRY_STATIC_LAUNCH_UNSUPPORTED" + | "SETUP_DRAFT_INPUT_INVALID" + | "SETUP_DRAFT_INVALID" + | "SETUP_DRAFT_CONFLICT" + | "SETUP_DRAFT_UNAVAILABLE" | "CONSOLE_LAUNCHER_UNAVAILABLE" | "CONSOLE_CONFIGURATION_NOT_FOUND" | "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" diff --git a/tests/cli-exit-codes.test.ts b/tests/cli-exit-codes.test.ts index fc90d25..9f5643b 100644 --- a/tests/cli-exit-codes.test.ts +++ b/tests/cli-exit-codes.test.ts @@ -109,6 +109,10 @@ const expectedErrorExitCodes: Record = { ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED: CLI_EXIT_CODES.config, ENVIRONMENT_PROFILE_INPUT_INVALID: CLI_EXIT_CODES.config, CLIENT_ENTRY_STATIC_LAUNCH_UNSUPPORTED: CLI_EXIT_CODES.config, + SETUP_DRAFT_INPUT_INVALID: CLI_EXIT_CODES.config, + SETUP_DRAFT_INVALID: CLI_EXIT_CODES.config, + SETUP_DRAFT_CONFLICT: CLI_EXIT_CODES.config, + SETUP_DRAFT_UNAVAILABLE: CLI_EXIT_CODES.operation, CONSOLE_LAUNCHER_UNAVAILABLE: CLI_EXIT_CODES.operation, CONSOLE_CONFIGURATION_NOT_FOUND: CLI_EXIT_CODES.config, CONSOLE_CONFIGURATION_SELECTION_REQUIRED: CLI_EXIT_CODES.config, diff --git a/tests/cli-parse.test.ts b/tests/cli-parse.test.ts index 5bd6829..5ef5282 100644 --- a/tests/cli-parse.test.ts +++ b/tests/cli-parse.test.ts @@ -212,6 +212,23 @@ describe("CLI parser", () => { expectUsageError(["init", "--plan"]); }); + it("offers private connector-draft controls only from guided setup", () => { + expect(parseCli(["setup", "--resume"])).toEqual({ + kind: "run", + command: "setup", + options: { resume: true } + }); + expect(parseCli(["setup", "--discard-draft"])).toEqual({ + kind: "run", + command: "setup", + options: { discardDraft: true } + }); + expect(renderCommandHelp("setup")).toContain("--resume"); + expect(renderCommandHelp("setup")).toContain("--discard-draft"); + expectUsageError(["init", "--resume"]); + expectUsageError(["init", "--discard-draft"]); + }); + it("offers endpoint-first native OAuth only from guided setup", () => { expect(parseCli([ "setup", diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 172f774..a7e7ccd 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -11,6 +11,7 @@ import { import { ConsoleDashboardApplicationService } from "../src/console/console-dashboard-application-service.js"; import { buildPresetConfig } from "../src/config/presets.js"; import { verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; +import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; import { createPrivateConsoleDirectory, writePrivateConsoleFile @@ -401,6 +402,55 @@ describe("Console dashboard application service", () => { await expect(service.health()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); }); + it("shares only a safe connector draft during first run and clears it after configuration publication", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-")); + temporaryDirectories.push(root); + const privateParent = await createPrivateConsoleDirectory(root); + const directory = join(privateParent, "miftah"); + const configPath = join(directory, "miftah.json"); + const draftStore = new FileSetupDraftStore({ directory: join(root, "setup-draft") }); + const service = new ConsoleDashboardApplicationService({ + defaultConfigPath: configPath, + configDirectory: directory, + launcher: { command: process.execPath, args: ["serve"] }, + setupDraftStore: draftStore + }); + const { preset, ...presetOptions } = supportedKnownConnectorOptions(); + const saveSetupDraft = service.saveSetupDraft; + const loadSetupDraft = service.loadSetupDraft; + if (saveSetupDraft === undefined || loadSetupDraft === undefined) { + throw new Error("Expected the first-run service to expose the configured setup-draft capability."); + } + + const draft = await saveSetupDraft({ + source: "connector", + name: "support-tools", + preset, + stage: "connection" + }); + await expect(loadSetupDraft()).resolves.toEqual(draft); + + await expect(service.onboardPreset({ + name: "support-tools", + preset, + ...presetOptions + })).resolves.toMatchObject({ name: "support-tools" }); + await expect(draftStore.load()).resolves.toBeUndefined(); + await expect(loadSetupDraft()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); + }); + + it("does not expose connector-draft operations when its embedding has no draft store", () => { + const service = new ConsoleDashboardApplicationService({ + defaultConfigPath: join(tmpdir(), "miftah-console-dashboard-no-draft-store.json"), + configDirectory: tmpdir(), + launcher: { command: process.execPath, args: ["serve"] } + }); + + expect(service.loadSetupDraft).toBeUndefined(); + expect(service.saveSetupDraft).toBeUndefined(); + expect(service.discardSetupDraft).toBeUndefined(); + }); + it("previews a first known connector without selecting or creating a configuration", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-preset-preview-")); temporaryDirectories.push(root); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index 54d32b2..ce85011 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -12,6 +12,7 @@ import { ConsoleDashboardApplicationService } from "../src/console/console-dashb import { ConsoleApplicationService } from "../src/console/console-application-service.js"; import { buildPresetConfig } from "../src/config/presets.js"; import { MiftahError } from "../src/utils/errors.js"; +import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; import { createPrivateConsoleDirectory, writePrivateConsoleFile @@ -613,6 +614,166 @@ function triggerClientEntryManualRecoveryAction( return { selected, documentCleared: documentInput.value === "" }; } +async function resumeMissingSetupDraft(javascript: string): Promise<{ + readonly restored: boolean; + readonly controlsUpdated: number; + readonly message: string; +}> { + const start = javascript.indexOf("if (resumeSetupDraft instanceof HTMLButtonElement)"); + const end = javascript.indexOf("\n\n if (discardSetupDraft instanceof HTMLButtonElement)", start); + if (start < 0 || end < 0) throw new Error("Expected the saved setup-draft resume action."); + + class FakeButton { + readonly listeners = new Map void | Promise>(); + + addEventListener(name: string, listener: () => void | Promise): void { + this.listeners.set(name, listener); + } + } + + const button = new FakeButton(); + let restored = false; + let controlsUpdated = 0; + let status = ""; + runInNewContext(`let activeSetupDraft = { revision: 1 };\n${javascript.slice(start, end)}`, { + resumeSetupDraft: button, + HTMLButtonElement: FakeButton, + api: async () => null, + restoreSetupDraft(): void { + restored = true; + throw new Error("A missing setup draft must not be restored."); + }, + renderSetupDraftControls(): void { + controlsUpdated += 1; + }, + message(value: string): void { + status = value; + }, + errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "unexpected"; + } + }); + const listener = button.listeners.get("click"); + if (listener === undefined) throw new Error("Expected the saved setup-draft resume listener."); + await listener(); + return { restored, controlsUpdated, message: status }; +} + +async function resumeSetupDraftWithoutConnectionValues(javascript: string): Promise<{ + readonly name: string; + readonly preset: string; + readonly credential: string; + readonly resetCount: number; + readonly accountsCleared: number; + readonly discardVisible: boolean; + readonly discardEnabled: boolean; +}> { + type Listener = () => void | Promise; + class FakeElement { + readonly listeners = new Map(); + textContent = ""; + + addEventListener(name: string, listener: Listener): void { + this.listeners.set(name, listener); + } + + querySelectorAll(): readonly unknown[] { + return []; + } + } + class FakeInput extends FakeElement { + constructor(public value = "") { + super(); + } + } + class FakeSelect extends FakeElement { + constructor(public value = "generic") { + super(); + } + } + class FakeButton extends FakeElement { + disabled = false; + hidden = false; + } + const name = new FakeInput("unsafe-name"); + const credential = new FakeInput("UNSAFE_CONNECTION_VALUE"); + const selection = new FakeSelect("generic"); + class FakeForm extends FakeElement { + resetCount = 0; + + reset(): void { + this.resetCount += 1; + name.value = ""; + credential.value = ""; + selection.value = "generic"; + } + + querySelector(selector: string): unknown { + return selector === "input[name='name']" ? name : undefined; + } + } + class FakeAccountList extends FakeElement { + cleared = 0; + + replaceChildren(): void { + this.cleared += 1; + } + } + + const form = new FakeForm(); + const accounts = new FakeAccountList(); + const resume = new FakeButton(); + const discard = new FakeButton(); + const status = new FakeElement(); + runInNewContext(javascript, { + document: { + getElementById(id: string): unknown { + if (id === "status") return status; + if (id === "preset-onboarding-form") return form; + if (id === "preset-selection") return selection; + if (id === "gsc-account-list") return accounts; + if (id === "resume-setup-draft") return resume; + if (id === "discard-setup-draft") return discard; + return undefined; + } + }, + HTMLFormElement: FakeForm, + HTMLSelectElement: FakeSelect, + HTMLElement: FakeElement, + HTMLInputElement: FakeInput, + HTMLButtonElement: FakeButton, + HTMLTextAreaElement: class {}, + Element: FakeElement, + fetch: async () => ({ + ok: true, + status: 200, + json: async () => ({ + data: { + schemaVersion: 1, + revision: 2, + source: "connector", + name: "saved-connector", + preset: "generic-npx", + stage: "connection", + savedAt: "2026-07-25T12:00:00.000Z" + } + }) + }) + }); + const listener = resume.listeners.get("click"); + if (listener === undefined) throw new Error("Expected the saved setup-draft resume listener."); + await listener(); + return { + name: name.value, + preset: selection.value, + credential: credential.value, + resetCount: form.resetCount, + accountsCleared: accounts.cleared, + discardVisible: discard.hidden === false, + discardEnabled: discard.disabled === false + }; +} + function clearProfileReadinessResultOnTargetChange(javascript: string): { readonly afterProfileChange: string; readonly afterUpstreamChange: string; @@ -1225,6 +1386,9 @@ describe("local Console control server", () => { expect(html).toContain("Advanced manual OAuth registration"); expect(html).toContain("acceptLocalCommand"); expect(html).toContain('id="preset-onboarding-view"'); + expect(html).toContain('id="save-setup-draft"'); + expect(html).toContain('id="resume-setup-draft"'); + expect(html).toContain('id="discard-setup-draft"'); expect(html).toContain('id="preset-review-view"'); expect(html).toContain('id="preset-create-reviewed"'); expect(html).toContain("Review configuration"); @@ -1298,6 +1462,24 @@ describe("local Console control server", () => { expect(javascript).toContain("/api/v1/profiles/environment-account"); expect(javascript).toContain("/api/v1/onboarding/preset/preview"); expect(javascript).toContain("/api/v1/onboarding/preset"); + expect(javascript).toContain("/api/v1/setup-draft"); + expect(javascript).toContain("function restoreSetupDraft"); + expect(javascript).not.toContain("localStorage"); + expect(javascript).not.toContain("sessionStorage"); + await expect(resumeMissingSetupDraft(javascript)).resolves.toEqual({ + restored: false, + controlsUpdated: 1, + message: "No saved connector choice exists. Start with a configuration name and connector above." + }); + await expect(resumeSetupDraftWithoutConnectionValues(javascript)).resolves.toEqual({ + name: "saved-connector", + preset: "generic-npx", + credential: "", + resetCount: 1, + accountsCleared: 1, + discardVisible: true, + discardEnabled: true + }); expect(javascript).toContain("renderSetupCompletion"); expect(javascript).toContain("function selectSetupSource(source)"); expect(javascript).toContain("setup-source-choice"); @@ -1540,6 +1722,118 @@ describe("local Console control server", () => { } }); + it("serves a CSRF-protected first-run connector draft without accepting connection data", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-draft-http-")); + temporaryDirectories.push(root); + const privateParent = await createPrivateConsoleDirectory(root); + const directory = join(privateParent, "miftah"); + const configPath = join(directory, "miftah.json"); + const draftStore = new FileSetupDraftStore({ directory: join(root, "setup-draft") }); + const server = await startConsoleServer(configPath, { + bootstrapCredential: "test-only-bootstrap-credential", + allowMissingConfig: true, + deferConfigValidation: true, + application: new ConsoleDashboardApplicationService({ + defaultConfigPath: configPath, + configDirectory: directory, + launcher: { command: process.execPath, args: [join(process.cwd(), "dist", "cli", "main.js"), "serve"] }, + setupDraftStore: draftStore + }) + }); + + try { + const session = await bootstrapSession(server); + const endpoint = new URL("/api/v1/setup-draft", server.url); + const unauthorized = await fetch(endpoint, { headers: { origin: server.url.origin } }); + expect(unauthorized.status).toBe(401); + + const missingCsrf = await fetch(endpoint, { + method: "PUT", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "content-type": "application/json" + }, + body: JSON.stringify({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + }) + }); + expect(missingCsrf.status).toBe(403); + + const unsafe = await fetch(endpoint, { + method: "PUT", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection", + credentialEnv: "SUPPORT_TOKEN" + }) + }); + expect(unsafe.status).toBe(422); + + const saved = await fetch(endpoint, { + method: "PUT", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection", + expectedRevision: 0 + }) + }); + expect(saved.status).toBe(200); + expect(await saved.json()).toMatchObject({ + data: { + schemaVersion: 1, + revision: 1, + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + } + }); + + const loaded = await fetch(endpoint, { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(loaded.status).toBe(200); + const loadedText = await loaded.text(); + expect(loadedText).toContain("support-tools"); + expect(loadedText).not.toContain("SUPPORT_TOKEN"); + + const deleted = await fetch(endpoint, { + method: "DELETE", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ revision: 1 }) + }); + expect(deleted.status).toBe(204); + await expect(draftStore.load()).resolves.toBeUndefined(); + } finally { + await server.close(); + } + }); + describe("first-run native OAuth HTTP flow", () => { let configPath: string; diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index ca0d12b..90e5997 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -8,6 +8,7 @@ import { MANAGEMENT_TOOL_NAMES } from "../src/mcp/server/management-tools.js"; const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8"); const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8"); const cliDocs = readFileSync(new URL("../docs/cli.md", import.meta.url), "utf8"); +const consoleDocs = readFileSync(new URL("../docs/console-api.md", import.meta.url), "utf8"); function headingSlugs(markdown: string): Set { const slugs = new Set(); @@ -87,6 +88,11 @@ describe("product README", () => { expect(readme).toContain("Keep `miftah setup --native-oauth` for scripted or repeatable setup"); expect(readme).toContain("Add `--plan` to a complete noninteractive `miftah setup` command to validate and print its non-secret structure before Miftah writes anything."); expect(readme).toContain("The Console uses the same review-before-write boundary: **Review configuration** validates a candidate without writing it, and **Create reviewed configuration** is the separate explicit write."); + expect(readme).toContain("If a bare connector setup is interrupted, continue it with:"); + expect(readme).toContain("miftah setup --resume"); + expect(readme).toContain("miftah setup --discard-draft"); + expect(readme).toContain("Miftah saves only the configuration name and connector preset"); + expect(readme).toContain("You re-enter every URL, executable, argument, path, environment reference, client entry, and browser or OAuth detail."); expect(readme).toContain("Remote MCP with browser sign-in"); expect(readme).toContain("never asks for a token, password, or browser cookie"); expect(readme).toContain("## First setup: GitHub with Claude Desktop"); @@ -112,6 +118,10 @@ describe("product README", () => { expect(cliDocs).toContain("The completion handoff prints the actual configuration path in any later `miftah profile test` command."); expect(cliDocs).toContain("Browser sign-in setup can finish with browser authorization still outstanding."); expect(cliDocs).toContain("`miftah setup ... --plan` validates a complete noninteractive first-configuration candidate and prints a structural, non-secret JSON plan without creating a directory, writing a file, or contacting an upstream."); + expect(cliDocs).toContain("`miftah setup --resume` requires a TTY and can be combined with no setup-input option."); + expect(cliDocs).toContain("`miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY."); + expect(consoleDocs).toContain("`GET`, `PUT`, and `DELETE /api/v1/setup-draft`"); + expect(consoleDocs).toContain('The request and response contain only `source: "connector"`, a bounded configuration name, a catalog preset, stage, revision, and timestamp.'); expect(cliDocs).not.toContain("it first offers `new` or `import`"); }); diff --git a/tests/setup-command.test.ts b/tests/setup-command.test.ts index 5412cce..a5c57a5 100644 --- a/tests/setup-command.test.ts +++ b/tests/setup-command.test.ts @@ -16,6 +16,7 @@ import { runNativeOAuthSetup } from "../src/cli/setup-native-oauth.js"; import { runProviderAccountSetup } from "../src/cli/setup-provider-account.js"; import { buildPresetConfig } from "../src/config/presets.js"; import { validateConfig } from "../src/config/validate-config.js"; +import { FileSetupDraftStore, resolveSetupDraftPath } from "../src/setup/setup-draft.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { startOAuthCompatibilityProbe } from "./helpers/fake-remote-upstream.js"; @@ -92,6 +93,154 @@ describe("setup command", () => { expect(renderCommandHelp("setup")).toContain("guided MCP setup flow"); }); + it("checkpoints only a bare connector intent before asking for connection details", async () => { + const draftDirectory = resolve(outputRoot, "setup-draft"); + const draftStore = new FileSetupDraftStore({ + directory: draftDirectory, + now: () => "2026-07-25T12:00:00.000Z" + }); + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + }, + setupDraftStore: draftStore + }); + + try { + await answer(streams, guidedSourcePrompt, "connector"); + await answer(streams, "Name [miftah-wrapper]", "posthog-work"); + await answer(streams, "What do you want to set up? (connector name, remote, or local)", "generic-docker"); + await streams.transcript.waitFor("Docker image (digest-pinned)"); + + await expect(draftStore.load()).resolves.toEqual({ + schemaVersion: 1, + revision: 1, + source: "connector", + name: "posthog-work", + preset: "generic-docker", + stage: "connection", + savedAt: "2026-07-25T12:00:00.000Z" + }); + await expect(readFile(resolveSetupDraftPath(draftDirectory), "utf8")).resolves.not.toContain("Docker image"); + await expect(readFile(resolveSetupDraftPath(draftDirectory), "utf8")).resolves.not.toContain("credentialEnv"); + } finally { + streams.input.end(); + await command.catch(() => undefined); + } + }); + + it("resumes a connector choice, re-prompts connection data, and clears the draft only after publication", async () => { + const draftStore = new FileSetupDraftStore({ directory: resolve(outputRoot, "setup-draft") }); + const draft = await draftStore.save({ + source: "connector", + name: "posthog-work", + preset: "generic-docker", + stage: "connection" + }); + const streams = createStreams(); + const command = runSetupCommand({ resume: true }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + }, + setupDraftStore: draftStore + }); + + try { + await vi.waitFor(() => expect(streams.transcript.contents).toContain("Docker image (digest-pinned)")); + expect(streams.transcript.contents).not.toContain(guidedSourcePrompt); + expect(streams.transcript.contents).not.toContain("Name [miftah-wrapper]"); + expect(streams.transcript.contents).not.toContain("What do you want to set up? (connector name, remote, or local)"); + + await answer( + streams, + "Docker image (digest-pinned)", + "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + await answer(streams, "Output location [posthog-work.miftah.json]", "resumed.json"); + await answer(streams, "Client", ""); + + await expect(command).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + await expect(draftStore.load()).resolves.toBeUndefined(); + await expect(readFile(resolve(outputRoot, "resumed.json"), "utf8")).resolves.toContain("ghcr.io/acme/server@sha256"); + expect(draft.revision).toBe(1); + } finally { + streams.input.end(); + await command.catch(() => undefined); + } + }); + + it("retains a resumed draft when publication fails and permits an explicit discard without a TTY", async () => { + const draftStore = new FileSetupDraftStore({ directory: resolve(outputRoot, "setup-draft") }); + const draft = await draftStore.save({ + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection" + }); + const existingOutput = resolve(outputRoot, "existing.json"); + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(existingOutput, "{}", { mode: 0o600 }); + const streams = createStreams(); + const command = runSetupCommand({ resume: true }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + }, + setupDraftStore: draftStore + }); + + try { + await answer(streams, "Output location [posthog-work.miftah.json]", "existing.json"); + await answer(streams, "Client", ""); + await expect(command).rejects.toThrow("Output"); + await expect(draftStore.load()).resolves.toEqual(draft); + } finally { + streams.input.end(); + await command.catch(() => undefined); + } + + const input = Object.assign(new PassThrough(), { isTTY: false }); + const output = Object.assign(new PassThrough(), { isTTY: false }); + await expect(runSetupCommand({ discardDraft: true }, { + input, + output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + }, + setupDraftStore: draftStore + })).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + await expect(draftStore.load()).resolves.toBeUndefined(); + }); + + it("rejects every setup input when resuming a private connector intent", async () => { + const streams = createStreams(); + await expect(runSetupCommand({ resume: true, output: "unsafe.json" }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + }, + setupDraftStore: new FileSetupDraftStore({ directory: resolve(outputRoot, "setup-draft") }) + })).rejects.toThrow("cannot be combined with '--output'"); + streams.input.end(); + }); + it("prints a validated non-secret setup plan without writing or contacting a remote MCP", async () => { const outputPath = resolve(outputRoot, "remote-tools.json"); const input = Object.assign(new PassThrough(), { isTTY: false }); diff --git a/tests/setup-draft.test.ts b/tests/setup-draft.test.ts new file mode 100644 index 0000000..f899592 --- /dev/null +++ b/tests/setup-draft.test.ts @@ -0,0 +1,161 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + FileSetupDraftStore, + resolveSetupDraftPath +} from "../src/setup/setup-draft.js"; +import { MiftahError } from "../src/utils/errors.js"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function createRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "miftah-setup-draft-")); + roots.push(root); + return root; +} + +describe("setup drafts", () => { + it("persists only a bounded connector intent, then resumes and discards it", async () => { + const directory = await createRoot(); + const store = new FileSetupDraftStore({ + directory, + now: () => "2026-07-25T12:00:00.000Z" + }); + + const saved = await store.save({ + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection" + }); + + expect(saved).toEqual({ + schemaVersion: 1, + revision: 1, + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection", + savedAt: "2026-07-25T12:00:00.000Z" + }); + await expect(store.load()).resolves.toEqual(saved); + await expect(readFile(resolveSetupDraftPath(directory), "utf8")).resolves.toContain('"preset":"generic"'); + if (process.platform !== "win32") { + expect((await stat(directory)).mode & 0o777).toBe(0o700); + expect((await stat(resolveSetupDraftPath(directory))).mode & 0o777).toBe(0o600); + if (typeof process.getuid === "function") { + expect((await stat(resolveSetupDraftPath(directory))).uid).toBe(process.getuid()); + } + } + + await store.discard(saved.revision); + await expect(store.load()).resolves.toBeUndefined(); + }); + + it("rejects stale draft updates instead of overwriting another setup surface", async () => { + const directory = await createRoot(); + const first = new FileSetupDraftStore({ directory }); + const second = new FileSetupDraftStore({ directory }); + const saved = await first.save({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + }); + + await expect(second.save({ + source: "connector", + name: "support-tools", + preset: "sentry", + stage: "connection" + }, saved.revision - 1)).rejects.toMatchObject({ code: "SETUP_DRAFT_CONFLICT" }); + await expect(first.load()).resolves.toEqual(saved); + }); + + it("fails closed when a draft contains an excluded connection or credential field", async () => { + const directory = await createRoot(); + await writeFile(resolveSetupDraftPath(directory), JSON.stringify({ + schemaVersion: 1, + revision: 1, + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection", + savedAt: "2026-07-25T12:00:00.000Z", + credentialEnv: "POSTHOG_PERSONAL_API_KEY" + }), { mode: 0o600 }); + await chmod(directory, 0o700); + + await expect(new FileSetupDraftStore({ directory }).load()).rejects.toMatchObject({ + code: "SETUP_DRAFT_INVALID" + } satisfies Partial); + }); + + it("rejects an oversized persisted draft as invalid without accepting arbitrary content", async () => { + const directory = await createRoot(); + await writeFile(resolveSetupDraftPath(directory), "x".repeat(4 * 1024 + 1), { mode: 0o600 }); + await chmod(directory, 0o700); + + await expect(new FileSetupDraftStore({ directory }).load()).rejects.toMatchObject({ + code: "SETUP_DRAFT_INVALID" + } satisfies Partial); + }); + + it("refuses a draft symlink before treating its target as persisted setup state", async () => { + const directory = await createRoot(); + const target = join(directory, "untrusted-target.json"); + await writeFile(target, "x".repeat(5 * 1024), { mode: 0o600 }); + await chmod(directory, 0o700); + await symlink(target, resolveSetupDraftPath(directory)); + + await expect(new FileSetupDraftStore({ directory }).load()).rejects.toMatchObject({ + code: "SETUP_DRAFT_UNAVAILABLE" + } satisfies Partial); + }); + + it("refuses a symlinked draft directory instead of resuming from another location", async () => { + const root = await createRoot(); + const directory = join(root, "setup"); + const target = join(root, "other-setup"); + await mkdir(target, { mode: 0o700 }); + await writeFile(join(target, "setup-draft-v1.json"), JSON.stringify({ + schemaVersion: 1, + revision: 1, + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection", + savedAt: "2026-07-25T12:00:00.000Z" + }), { mode: 0o600 }); + await symlink(target, directory); + + await expect(new FileSetupDraftStore({ directory }).load()).rejects.toMatchObject({ + code: "SETUP_DRAFT_UNAVAILABLE" + } satisfies Partial); + }); + + it("refuses unbounded configuration labels and unknown catalog presets before writing a draft", async () => { + const directory = await createRoot(); + const store = new FileSetupDraftStore({ directory }); + + await expect(store.save({ + source: "connector", + name: "this label has whitespace", + preset: "generic", + stage: "connection" + })).rejects.toMatchObject({ code: "SETUP_DRAFT_INPUT_INVALID" }); + await expect(store.save({ + source: "connector", + name: "posthog-work", + preset: "unknown-connector", + stage: "connection" + })).rejects.toMatchObject({ code: "SETUP_DRAFT_INPUT_INVALID" }); + await expect(store.load()).resolves.toBeUndefined(); + }); +}); From 4c3a86d4add2078d04618ab6143f91badefe283a Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 15:39:02 +0400 Subject: [PATCH 02/25] test(console): use private draft parents on Windows --- tests/console-dashboard-application-service.test.ts | 2 +- tests/console-server.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index a7e7ccd..d1ae73b 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -408,7 +408,7 @@ describe("Console dashboard application service", () => { const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); const configPath = join(directory, "miftah.json"); - const draftStore = new FileSetupDraftStore({ directory: join(root, "setup-draft") }); + const draftStore = new FileSetupDraftStore({ directory: join(privateParent, "setup-draft") }); const service = new ConsoleDashboardApplicationService({ defaultConfigPath: configPath, configDirectory: directory, diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index ce85011..b6e2389 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -1728,7 +1728,7 @@ describe("local Console control server", () => { const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); const configPath = join(directory, "miftah.json"); - const draftStore = new FileSetupDraftStore({ directory: join(root, "setup-draft") }); + const draftStore = new FileSetupDraftStore({ directory: join(privateParent, "setup-draft") }); const server = await startConsoleServer(configPath, { bootstrapCredential: "test-only-bootstrap-credential", allowMissingConfig: true, From 5ed2f9d9952cf133fc62f7146b2a454a2c88ab39 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 16:15:03 +0400 Subject: [PATCH 03/25] fix(setup): harden resumable draft lifecycle --- README.md | 2 +- docs/cli.md | 2 +- docs/console-api.md | 2 +- src/cli/init.ts | 3 +- src/cli/setup.ts | 26 +++++-- src/console/console-assets.ts | 10 ++- .../console-dashboard-application-service.ts | 12 ++- src/setup/setup-draft.ts | 23 +++--- ...sole-dashboard-application-service.test.ts | 52 +++++++++++++ tests/console-server.test.ts | 60 +++++++++++++++ tests/console-windows-first-run.test.ts | 35 ++++++++- tests/init-command.test.ts | 21 ++++++ tests/readme-product-contract.test.ts | 10 ++- tests/setup-command.test.ts | 75 +++++++++++++++++++ tests/setup-draft.test.ts | 44 +++++++++++ 15 files changed, 348 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d2845e9..538b574 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Or remove that checkpoint without creating a configuration: miftah setup --discard-draft ``` -Miftah saves only the configuration name and connector preset in a private local checkpoint. You re-enter every URL, executable, argument, path, environment reference, client entry, and browser or OAuth detail. The checkpoint stays after cancellation or a failed pre-publication write, and is removed only after a configuration is created or you explicitly discard it. This safe resume is intentionally limited to the bare `connector` path; import and browser-sign-in data are never persisted. In the first-run Console, **Save connector choice**, **Continue saved connector choice**, and **Discard saved choice** use that same checkpoint and never use browser storage. +Miftah saves only the connector source, configuration name, catalog preset, and setup stage in a private local checkpoint. You re-enter every URL, executable, argument, path, environment reference, client entry, and browser or OAuth detail. The checkpoint stays after cancellation or a failed pre-publication write, and is removed only after a configuration is created or you explicitly discard it. This safe resume is intentionally limited to the bare `connector` path; import and browser-sign-in data are never persisted. In the first-run Console, **Save connector choice**, **Continue saved connector choice**, and **Discard saved choice** use that same checkpoint and never use browser storage. If a selected client entry cannot be safely imported, Miftah writes no configuration from it. Miftah does not retain rejected arguments, headers, environment values, or credentials. The guided CLI keeps the non-secret configuration name and output location, then takes you to manual transport setup: re-enter a reviewed local executable with literal arguments, or a canonical HTTPS endpoint. In the Console, choose **Set up local executable manually** or **Set up remote HTTPS MCP manually**; it clears the pasted client JSON before opening the existing form. Configure authentication separately through the upstream's documented flow and Miftah secret references. diff --git a/docs/cli.md b/docs/cli.md index af1284f..6d23c72 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -78,7 +78,7 @@ miftah validate --config "$HOME/Miftah configs/work wrapper.json" `miftah setup ... --plan` validates a complete noninteractive first-configuration candidate and prints a structural, non-secret JSON plan without creating a directory, writing a file, or contacting an upstream. It includes the supplied output location, but deliberately excludes the endpoint, executable, working directory, arguments, environment names, credential references, headers, OAuth details, and secret values. `--plan` is unavailable for interactive, import, profile-addition, and native-OAuth setup because those flows have their own review or discovery boundaries. -For a bare interactive `connector` journey, Miftah writes a private checkpoint after the configuration name and catalog preset are selected, before it asks for connection details. `miftah setup --resume` requires a TTY and can be combined with no setup-input option. It restores only that name and preset, then asks again for every URL, executable, argument, path, environment reference, credential-file choice, client selection, and OAuth/browser detail. It never creates a configuration until the normal final publication succeeds. `miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY. Both operations reject a malformed, stale, concurrent, unsafe, or unavailable private checkpoint rather than replacing or guessing from it. +For a bare interactive `connector` journey, Miftah writes a private checkpoint after the configuration name and catalog preset are selected, before it asks for connection details. `miftah setup --resume` requires a TTY and cannot be combined with any setup-input option. It restores only that name and preset, then asks again for every URL, executable, argument, path, environment reference, credential-file choice, client selection, and OAuth/browser detail. It never creates a configuration until the normal final publication succeeds. `miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY. Both operations reject a malformed, stale, concurrent, unsafe, or unavailable private checkpoint rather than replacing or guessing from it. `miftah setup --add-profile --config ` has two typed returning-user paths. For a reviewed provider-owned adapter, noninteractive use requires `--profile` and `--oauth-client-secrets-file`; `--description`, `--make-default`, and `--verify` are optional. Miftah proves that the entire selected configuration matches one adapter and that every existing account has a literal absolute credential-file reference plus a distinct canonical provider-state directory. It then atomically adds one new profile and isolated state directory through that adapter contract. Miftah never reads the credential file or the upstream token cache. Unsupported, modified, mixed, or shared-state configurations fail closed with `PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED`; an invalid credential-file path fails with `PROVIDER_ACCOUNT_INPUT_INVALID`. `--verify` checks the new account only. diff --git a/docs/console-api.md b/docs/console-api.md index c950c49..81ebc00 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -37,7 +37,7 @@ Every request must use the exact listener `Host`. Browser mutations, including b `POST /api/v1/profiles/environment-account` accepts only a profile slug, optional description, an environment-variable name, and optional `makeDefault: true`. It is available only for the currently selected trusted configuration when every existing profile has one matching direct credential environment binding for a single local `stdio` upstream. It rejects remote HTTP, provider-adapter, OAuth, named-upstream, raw credential, duplicate source-variable, and arbitrary profile-override cases. The operation records intent and completion in the fail-closed Console journal, writes via the guarded configuration transaction, never starts the upstream, and returns no credential environment reference or configuration bytes. -The unconfigured first-run dashboard exposes **Save connector choice**, **Continue saved connector choice**, and **Discard saved choice** through `GET`, `PUT`, and `DELETE /api/v1/setup-draft`. The request and response contain only `source: "connector"`, a bounded configuration name, a catalog preset, stage, revision, and timestamp. `PUT` and `DELETE` require the existing exact-Origin CSRF proof; `DELETE` also requires the exact revision. The checkpoint is private, owner-restricted, versioned, and shared with `miftah setup --resume`; it is not stored in browser memory beyond the active page, localStorage, sessionStorage, configuration files, audit records, OAuth metadata, or client settings. It never accepts or returns a URL, local command, argument, working directory, credential reference or value, header, client-entry document, client-secret path, token/cache location, browser state, OAuth code, or OAuth token. It is available only while no configuration has been selected or created, and it is cleared after successful first-run publication or an explicit discard. +The unconfigured first-run dashboard exposes **Save connector choice**, **Continue saved connector choice**, and **Discard saved choice** through the private setup-draft endpoint. `GET /api/v1/setup-draft` returns the current safe draft or `null`. `PUT /api/v1/setup-draft` accepts only the safe connector intent (`source: "connector"`, bounded configuration name, catalog preset, and stage) plus an optional `expectedRevision` compare-and-swap value, then returns the saved draft with its revision and timestamp. `DELETE /api/v1/setup-draft` requires `{ revision: number }` and returns `204 No Content`. `PUT` and `DELETE` require the existing exact-Origin CSRF proof. The checkpoint is private, owner-restricted, versioned, and shared with `miftah setup --resume`; it is not stored in browser memory beyond the active page, localStorage, sessionStorage, configuration files, audit records, OAuth metadata, or client settings. It never accepts or returns a URL, local command, argument, working directory, credential reference or value, header, client-entry document, client-secret path, token/cache location, browser state, OAuth code, or OAuth token. It is available only while no configuration has been selected or created, and it is cleared after successful first-run publication or an explicit discard. | Method and path | Purpose | | --- | --- | diff --git a/src/cli/init.ts b/src/cli/init.ts index cdf4804..88e94b9 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -33,6 +33,7 @@ import type { } from "./client-snippets.js"; import { CliUsageError } from "./parse.js"; import type { CliOptions } from "./parse.js"; +import { MiftahError } from "../utils/errors.js"; export type InitCommandOptions = Pick< CliOptions, @@ -441,7 +442,7 @@ async function collectInteractiveValues(options: InitCommandOptions, context: In } return { name, preset, output, client, ...presetOptions }; } catch (error) { - if (error instanceof CliUsageError) throw error; + if (error instanceof CliUsageError || error instanceof MiftahError) throw error; throw new CliUsageError("Interactive init was cancelled."); } finally { cancellation.dispose(); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 4377666..4c4e9a6 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -384,6 +384,21 @@ function setupDraftStore(context: InitCommandContext): SetupDraftStore { return context.setupDraftStore; } +async function discardPublishedSetupDraft( + store: SetupDraftStore, + revision: number, + context: InitCommandContext +): Promise { + try { + await store.discard(revision); + } catch (error) { + const code = error instanceof MiftahError ? error.code : "SETUP_DRAFT_UNAVAILABLE"; + context.output.write( + `Configuration was created, but Miftah could not clear the saved connector choice (${code}). Run 'miftah setup --discard-draft' to remove it later.\n` + ); + } +} + /** * Starts the human-first setup journey while retaining `init` for scripts and * existing automation. Both entry points deliberately use the same planner, @@ -422,7 +437,7 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini name: draft.name, preset: draft.preset }, context); - await store.discard(draft.revision); + await discardPublishedSetupDraft(store, draft.revision, context); return finishCreatedSetup(options, context, created); } if (options.plan === true) { @@ -626,12 +641,13 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini } checkpointConnectorIntent = startingPoint === "connector"; } - const initContext = checkpointConnectorIntent && context.setupDraftStore !== undefined + const checkpointStore = context.setupDraftStore; + const initContext = checkpointConnectorIntent && checkpointStore !== undefined ? { ...context, onSetupDraftIntent: async (intent: Parameters>[0]) => { try { - checkpointedDraft = await context.setupDraftStore!.save(intent); + checkpointedDraft = await checkpointStore.save(intent); } catch (error) { if (error instanceof MiftahError && error.code === "SETUP_DRAFT_INPUT_INVALID") { context.output.write( @@ -649,8 +665,8 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini interactive: true, ...(guidedPreset === undefined ? {} : { preset: guidedPreset }) }, initContext); - if (checkpointedDraft !== undefined) { - await context.setupDraftStore!.discard(checkpointedDraft.revision); + if (checkpointedDraft !== undefined && checkpointStore !== undefined) { + await discardPublishedSetupDraft(checkpointStore, checkpointedDraft.revision, context); } return finishCreatedSetup(options, context, created); } diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index 8c008ba..8241fd3 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -1634,17 +1634,22 @@ const script = `(() => { if (saveSetupDraft instanceof HTMLButtonElement) { saveSetupDraft.addEventListener("click", async () => { + if (saveSetupDraft.disabled) return; + saveSetupDraft.disabled = true; message("Saving only the connector choice…"); try { const draft = await api("/api/v1/setup-draft", { method: "PUT", body: setupDraftIntent() }); restoreSetupDraft(draft); message("Saved the configuration name and connector choice. Re-enter every connection detail when you continue."); } catch (error) { message(errorMessage(error)); } + finally { saveSetupDraft.disabled = false; } }); } if (resumeSetupDraft instanceof HTMLButtonElement) { resumeSetupDraft.addEventListener("click", async () => { + if (resumeSetupDraft.disabled) return; + resumeSetupDraft.disabled = true; message("Loading the saved connector choice…"); try { const draft = await api("/api/v1/setup-draft"); @@ -1657,12 +1662,14 @@ const script = `(() => { restoreSetupDraft(draft); message("Restored only the configuration name and connector choice. Re-enter every connection value before reviewing."); } catch (error) { message(errorMessage(error)); } + finally { resumeSetupDraft.disabled = false; } }); } if (discardSetupDraft instanceof HTMLButtonElement) { discardSetupDraft.addEventListener("click", async () => { - if (activeSetupDraft === undefined) return; + if (activeSetupDraft === undefined || discardSetupDraft.disabled) return; + discardSetupDraft.disabled = true; message("Discarding the saved connector choice…"); try { await api("/api/v1/setup-draft", { method: "DELETE", body: { revision: activeSetupDraft.revision } }); @@ -1670,6 +1677,7 @@ const script = `(() => { renderSetupDraftControls(); message("Discarded the saved connector choice. The current form stays open and is not saved."); } catch (error) { message(errorMessage(error)); } + finally { discardSetupDraft.disabled = false; } }); } diff --git a/src/console/console-dashboard-application-service.ts b/src/console/console-dashboard-application-service.ts index f0035d4..c9d64bf 100644 --- a/src/console/console-dashboard-application-service.ts +++ b/src/console/console-dashboard-application-service.ts @@ -385,8 +385,16 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica private async clearSetupDraftAfterFirstRunPublication(): Promise { const store = this.setupDraftStore; if (store === undefined) return; - const draft = await store.load(); - if (draft !== undefined) await store.discard(draft.revision); + try { + const draft = await store.load(); + if (draft !== undefined) await store.discard(draft.revision); + } catch (error) { + const code = error instanceof MiftahError ? error.code : "SETUP_DRAFT_UNAVAILABLE"; + process.emitWarning( + `Configuration was created, but Miftah could not clear the saved connector choice (${code}). Run 'miftah setup --discard-draft' to remove it later.`, + { code: "MIFTAH_SETUP_DRAFT_CLEANUP_FAILED" } + ); + } } private async confirmCreatedFirstRunConfiguration(): Promise { diff --git a/src/setup/setup-draft.ts b/src/setup/setup-draft.ts index 4fe2a04..2d63105 100644 --- a/src/setup/setup-draft.ts +++ b/src/setup/setup-draft.ts @@ -9,7 +9,7 @@ import { verifyWindowsConfigPathSecurity } from "../cli/windows-config-acl.js"; import { PRESET_CATALOG, type PresetCatalogName } from "../config/presets.js"; -import { OAuthLocalLockUnavailableError, withOAuthLocalLock } from "../oauth/local-lock.js"; +import { withOAuthLocalLock } from "../oauth/local-lock.js"; import { MiftahError } from "../utils/errors.js"; const maximumDraftBytes = 4 * 1024; @@ -82,10 +82,11 @@ function setupDraftConflict(): never { ); } -function setupDraftUnavailable(): never { +function setupDraftUnavailable(cause?: unknown): never { throw new MiftahError( "SETUP_DRAFT_UNAVAILABLE", - "SETUP_DRAFT_UNAVAILABLE: private setup draft storage is unavailable" + "SETUP_DRAFT_UNAVAILABLE: private setup draft storage is unavailable", + cause === undefined ? undefined : { cause } ); } @@ -228,11 +229,14 @@ async function ensurePrivateDirectory(directory: string): Promise { const parent = dirname(directory); await mkdir(parent, { recursive: true, mode: 0o700 }); if (!(await verifyWindowsConfigPathSecurity(parent, "directory"))) setupDraftUnavailable(); - if (!(await createWindowsPrivateDirectory(directory))) setupDraftUnavailable(); - if (!(await verifyWindowsConfigPathSecurity(directory, "directory"))) setupDraftUnavailable(); + if (await privateDirectoryMetadata(directory) !== undefined) return; + // Exclusive creation may lose a race to another Miftah process. Validate the + // resulting path rather than treating that safe race as a storage failure. + await createWindowsPrivateDirectory(directory); + if (await privateDirectoryMetadata(directory) === undefined) setupDraftUnavailable(); } catch (error) { if (error instanceof MiftahError) throw error; - setupDraftUnavailable(); + setupDraftUnavailable(error); } } @@ -301,9 +305,7 @@ export class FileSetupDraftStore implements SetupDraftStore { if (expectedRevision !== undefined && !validExpectedRevision(expectedRevision)) setupDraftInputInvalid(); return this.withLock(async () => { const existing = await this.loadUnchecked(); - if (existing === undefined) { - if (expectedRevision !== undefined && expectedRevision !== 0) setupDraftConflict(); - } else if (expectedRevision !== existing.revision) { + if (expectedRevision !== undefined && expectedRevision !== (existing?.revision ?? 0)) { setupDraftConflict(); } const revision = existing === undefined ? 1 : existing.revision + 1; @@ -339,8 +341,7 @@ export class FileSetupDraftStore implements SetupDraftStore { return await withOAuthLocalLock("setup-draft", this.path, draftLockWaitMilliseconds, operation); } catch (error) { if (error instanceof MiftahError) throw error; - if (error instanceof OAuthLocalLockUnavailableError) setupDraftUnavailable(); - setupDraftUnavailable(); + setupDraftUnavailable(error); } } diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index d1ae73b..30fcb6b 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -12,6 +12,7 @@ import { ConsoleDashboardApplicationService } from "../src/console/console-dashb import { buildPresetConfig } from "../src/config/presets.js"; import { verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; +import { MiftahError } from "../src/utils/errors.js"; import { createPrivateConsoleDirectory, writePrivateConsoleFile @@ -439,6 +440,57 @@ describe("Console dashboard application service", () => { await expect(loadSetupDraft()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); }); + it("keeps a published first-run configuration when private draft cleanup conflicts", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-cleanup-")); + temporaryDirectories.push(root); + const privateParent = await createPrivateConsoleDirectory(root); + const directory = join(privateParent, "miftah"); + const configPath = join(directory, "miftah.json"); + const draft = { + schemaVersion: 1 as const, + revision: 1, + source: "connector" as const, + name: "support-tools", + preset: "generic", + stage: "connection" as const, + savedAt: "2026-07-25T12:00:00.000Z" + }; + const draftStore = { + load: vi.fn(async () => draft), + save: vi.fn(async () => draft), + discard: vi.fn(async () => { + throw new MiftahError( + "SETUP_DRAFT_CONFLICT", + "SETUP_DRAFT_CONFLICT: setup draft changed in another CLI or Console session" + ); + }) + }; + const warning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); + const service = new ConsoleDashboardApplicationService({ + defaultConfigPath: configPath, + configDirectory: directory, + launcher: { command: process.execPath, args: ["serve"] }, + setupDraftStore: draftStore + }); + const { preset, ...presetOptions } = supportedKnownConnectorOptions(); + + try { + await expect(service.onboardPreset({ + name: "support-tools", + preset, + ...presetOptions + })).resolves.toMatchObject({ name: "support-tools" }); + await expect(readFile(configPath, "utf8")).resolves.toContain('"name": "support-tools"'); + expect(draftStore.discard).toHaveBeenCalledWith(1); + expect(warning).toHaveBeenCalledWith( + "Configuration was created, but Miftah could not clear the saved connector choice (SETUP_DRAFT_CONFLICT). Run 'miftah setup --discard-draft' to remove it later.", + { code: "MIFTAH_SETUP_DRAFT_CLEANUP_FAILED" } + ); + } finally { + warning.mockRestore(); + } + }); + it("does not expose connector-draft operations when its embedding has no draft store", () => { const service = new ConsoleDashboardApplicationService({ defaultConfigPath: join(tmpdir(), "miftah-console-dashboard-no-draft-store.json"), diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index b6e2389..a434731 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -659,6 +659,55 @@ async function resumeMissingSetupDraft(javascript: string): Promise<{ return { restored, controlsUpdated, message: status }; } +async function saveSetupDraftOnlyOnce(javascript: string): Promise<{ + readonly requests: number; + readonly disabledWhilePending: boolean; + readonly enabledAfterCompletion: boolean; +}> { + const start = javascript.indexOf("if (saveSetupDraft instanceof HTMLButtonElement)"); + const end = javascript.indexOf("\n\n if (resumeSetupDraft instanceof HTMLButtonElement)", start); + if (start < 0 || end < 0) throw new Error("Expected the saved setup-draft save action."); + + type Listener = () => void | Promise; + class FakeButton { + disabled = false; + readonly listeners = new Map(); + + addEventListener(name: string, listener: Listener): void { + this.listeners.set(name, listener); + } + } + + const button = new FakeButton(); + let requests = 0; + let releaseRequest: () => void = () => undefined; + const request = new Promise((resolve) => { releaseRequest = resolve; }); + runInNewContext(javascript.slice(start, end), { + saveSetupDraft: button, + HTMLButtonElement: FakeButton, + message(): void {}, + setupDraftIntent(): Record { + return { source: "connector", name: "saved-connector", preset: "generic", stage: "connection" }; + }, + api: async () => { + requests += 1; + await request; + return { revision: 1 }; + }, + restoreSetupDraft(): void {}, + errorMessage(): string { return "request failed"; } + }); + const listener = button.listeners.get("click"); + if (listener === undefined) throw new Error("Expected the saved setup-draft save listener."); + const first = listener(); + const second = listener(); + await new Promise((resolve) => setImmediate(resolve)); + const disabledWhilePending = button.disabled; + releaseRequest(); + await Promise.all([first, second]); + return { requests, disabledWhilePending, enabledAfterCompletion: button.disabled === false }; +} + async function resumeSetupDraftWithoutConnectionValues(javascript: string): Promise<{ readonly name: string; readonly preset: string; @@ -1480,6 +1529,17 @@ describe("local Console control server", () => { discardVisible: true, discardEnabled: true }); + await expect(saveSetupDraftOnlyOnce(javascript)).resolves.toEqual({ + requests: 1, + disabledWhilePending: true, + enabledAfterCompletion: true + }); + expect(javascript).toContain("if (resumeSetupDraft.disabled) return;"); + expect(javascript).toContain("resumeSetupDraft.disabled = true;"); + expect(javascript).toContain("finally { resumeSetupDraft.disabled = false; }"); + expect(javascript).toContain("if (activeSetupDraft === undefined || discardSetupDraft.disabled) return;"); + expect(javascript).toContain("discardSetupDraft.disabled = true;"); + expect(javascript).toContain("finally { discardSetupDraft.disabled = false; }"); expect(javascript).toContain("renderSetupCompletion"); expect(javascript).toContain("function selectSetupSource(source)"); expect(javascript).toContain("setup-source-choice"); diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index bd30bd7..0569a88 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -15,7 +15,18 @@ vi.mock("../src/cli/windows-config-acl.js", () => ({ verifyWindowsConfigPathSecurity: aclMocks.verifyPath })); +vi.mock("../src/oauth/local-lock.js", () => ({ + OAuthLocalLockUnavailableError: class OAuthLocalLockUnavailableError extends Error {}, + withOAuthLocalLock: async ( + _scope: string, + _value: string, + _waitMilliseconds: number, + operation: () => Promise + ) => operation() +})); + import { ConsoleDashboardApplicationService } from "../src/console/console-dashboard-application-service.js"; +import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; const temporaryDirectories: string[] = []; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -25,8 +36,12 @@ beforeEach(() => { aclMocks.verifyPath.mockResolvedValue(true); aclMocks.secureFile.mockResolvedValue(true); aclMocks.createPrivateDirectory.mockImplementation(async (directory) => { - await mkdir(directory, { recursive: true }); - return true; + try { + await mkdir(directory); + return true; + } catch { + return false; + } }); }); @@ -81,4 +96,20 @@ describe("Console Windows first-run boundary", () => { await expect(readFile(configPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("reuses an existing verified setup-draft directory for a later mutation", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-setup-draft-windows-")); + temporaryDirectories.push(parent); + const store = new FileSetupDraftStore({ directory: join(parent, "setup-draft") }); + + const draft = await store.save({ + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection" + }); + + await expect(store.discard(draft.revision)).resolves.toBeUndefined(); + expect(aclMocks.createPrivateDirectory).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/init-command.test.ts b/tests/init-command.test.ts index 6d3d18d..5205526 100644 --- a/tests/init-command.test.ts +++ b/tests/init-command.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { validateConfig } from "../src/config/validate-config.js"; import { runInitCommand } from "../src/cli/init.js"; import { CliUsageError } from "../src/cli/parse.js"; +import { MiftahError } from "../src/utils/errors.js"; const outputRoot = resolve(process.cwd(), ".init-command-test-output"); const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -339,6 +340,26 @@ describe("init command", () => { await expectNoPath(resolve(outputRoot, "cancel")); }); + it("preserves a setup-draft failure instead of presenting it as interactive cancellation", async () => { + const streams = createStreams(); + const failure = new MiftahError( + "SETUP_DRAFT_CONFLICT", + "SETUP_DRAFT_CONFLICT: setup draft changed in another CLI or Console session" + ); + + await expect(runInitCommand({ + interactive: true, + name: "draft-conflict", + preset: "generic-docker", + output: "draft-conflict.json", + client: "" + }, { + ...commandContext(streams), + onSetupDraftIntent: async () => { throw failure; } + })).rejects.toBe(failure); + streams.input.end(); + }); + it("does not emit an unhandled rejection when fully supplied wizard input closes", async () => { const streams = createStreams(); const unhandledRejections: unknown[] = []; diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index 90e5997..8cf0cd6 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -91,7 +91,7 @@ describe("product README", () => { expect(readme).toContain("If a bare connector setup is interrupted, continue it with:"); expect(readme).toContain("miftah setup --resume"); expect(readme).toContain("miftah setup --discard-draft"); - expect(readme).toContain("Miftah saves only the configuration name and connector preset"); + expect(readme).toContain("Miftah saves only the connector source, configuration name, catalog preset, and setup stage"); expect(readme).toContain("You re-enter every URL, executable, argument, path, environment reference, client entry, and browser or OAuth detail."); expect(readme).toContain("Remote MCP with browser sign-in"); expect(readme).toContain("never asks for a token, password, or browser cookie"); @@ -118,10 +118,12 @@ describe("product README", () => { expect(cliDocs).toContain("The completion handoff prints the actual configuration path in any later `miftah profile test` command."); expect(cliDocs).toContain("Browser sign-in setup can finish with browser authorization still outstanding."); expect(cliDocs).toContain("`miftah setup ... --plan` validates a complete noninteractive first-configuration candidate and prints a structural, non-secret JSON plan without creating a directory, writing a file, or contacting an upstream."); - expect(cliDocs).toContain("`miftah setup --resume` requires a TTY and can be combined with no setup-input option."); + expect(cliDocs).toContain("`miftah setup --resume` requires a TTY and cannot be combined with any setup-input option."); expect(cliDocs).toContain("`miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY."); - expect(consoleDocs).toContain("`GET`, `PUT`, and `DELETE /api/v1/setup-draft`"); - expect(consoleDocs).toContain('The request and response contain only `source: "connector"`, a bounded configuration name, a catalog preset, stage, revision, and timestamp.'); + expect(consoleDocs).toContain("`GET /api/v1/setup-draft` returns the current safe draft or `null`."); + expect(consoleDocs).toContain("`PUT /api/v1/setup-draft` accepts only the safe connector intent"); + expect(consoleDocs).toContain("optional `expectedRevision` compare-and-swap value"); + expect(consoleDocs).toContain("`DELETE /api/v1/setup-draft` requires `{ revision: number }` and returns `204 No Content`."); expect(cliDocs).not.toContain("it first offers `new` or `import`"); }); diff --git a/tests/setup-command.test.ts b/tests/setup-command.test.ts index a5c57a5..5129d71 100644 --- a/tests/setup-command.test.ts +++ b/tests/setup-command.test.ts @@ -17,6 +17,7 @@ import { runProviderAccountSetup } from "../src/cli/setup-provider-account.js"; import { buildPresetConfig } from "../src/config/presets.js"; import { validateConfig } from "../src/config/validate-config.js"; import { FileSetupDraftStore, resolveSetupDraftPath } from "../src/setup/setup-draft.js"; +import { MiftahError } from "../src/utils/errors.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { startOAuthCompatibilityProbe } from "./helpers/fake-remote-upstream.js"; @@ -178,6 +179,56 @@ describe("setup command", () => { } }); + it("finishes a published resumed setup when its private draft cleanup conflicts", async () => { + const persistedStore = new FileSetupDraftStore({ directory: resolve(outputRoot, "setup-draft") }); + const draft = await persistedStore.save({ + source: "connector", + name: "posthog-work", + preset: "generic-docker", + stage: "connection" + }); + const draftStore = { + load: persistedStore.load.bind(persistedStore), + save: persistedStore.save.bind(persistedStore), + discard: async () => { + throw new MiftahError( + "SETUP_DRAFT_CONFLICT", + "SETUP_DRAFT_CONFLICT: setup draft changed in another CLI or Console session" + ); + } + }; + const streams = createStreams(); + const command = runSetupCommand({ resume: true }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + }, + setupDraftStore: draftStore + }); + + try { + await answer( + streams, + "Docker image (digest-pinned)", + "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + await answer(streams, "Output location [posthog-work.miftah.json]", "resumed-cleanup-conflict.json"); + await answer(streams, "Client", ""); + + await expect(command).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + expect(streams.transcript.contents).toContain( + "Configuration was created, but Miftah could not clear the saved connector choice (SETUP_DRAFT_CONFLICT)." + ); + await expect(persistedStore.load()).resolves.toEqual(draft); + } finally { + streams.input.end(); + await command.catch(() => undefined); + } + }); + it("retains a resumed draft when publication fails and permits an explicit discard without a TTY", async () => { const draftStore = new FileSetupDraftStore({ directory: resolve(outputRoot, "setup-draft") }); const draft = await draftStore.save({ @@ -241,6 +292,30 @@ describe("setup command", () => { streams.input.end(); }); + it("rejects mutually exclusive and noninteractive resume modes", async () => { + const streams = createStreams(); + const draftStore = new FileSetupDraftStore({ directory: resolve(outputRoot, "setup-draft") }); + await expect(runSetupCommand({ resume: true, discardDraft: true }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { command: process.execPath, args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] }, + setupDraftStore: draftStore + })).rejects.toThrow("Choose either '--resume' or '--discard-draft', not both."); + streams.input.end(); + + const input = Object.assign(new PassThrough(), { isTTY: false }); + const output = Object.assign(new PassThrough(), { isTTY: false }); + await expect(runSetupCommand({ resume: true }, { + input, + output, + cwd: outputRoot, + launcher: { command: process.execPath, args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] }, + setupDraftStore: draftStore + })).rejects.toThrow("Option '--resume' requires TTY input and output"); + input.end(); + }); + it("prints a validated non-secret setup plan without writing or contacting a remote MCP", async () => { const outputPath = resolve(outputRoot, "remote-tools.json"); const input = Object.assign(new PassThrough(), { isTTY: false }); diff --git a/tests/setup-draft.test.ts b/tests/setup-draft.test.ts index f899592..ee0efdc 100644 --- a/tests/setup-draft.test.ts +++ b/tests/setup-draft.test.ts @@ -78,6 +78,27 @@ describe("setup drafts", () => { await expect(first.load()).resolves.toEqual(saved); }); + it("replaces a saved connector intent when no expected revision is supplied", async () => { + const directory = await createRoot(); + const store = new FileSetupDraftStore({ directory }); + await store.save({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + }); + + const replacement = await store.save({ + source: "connector", + name: "support-tools", + preset: "sentry", + stage: "connection" + }); + + expect(replacement).toMatchObject({ revision: 2, preset: "sentry" }); + await expect(store.load()).resolves.toEqual(replacement); + }); + it("fails closed when a draft contains an excluded connection or credential field", async () => { const directory = await createRoot(); await writeFile(resolveSetupDraftPath(directory), JSON.stringify({ @@ -158,4 +179,27 @@ describe("setup drafts", () => { })).rejects.toMatchObject({ code: "SETUP_DRAFT_INPUT_INVALID" }); await expect(store.load()).resolves.toBeUndefined(); }); + + it("retains the underlying storage cause in unavailable-draft diagnostics", async () => { + const root = await createRoot(); + const fileParent = join(root, "not-a-directory"); + await writeFile(fileParent, "not a directory", { mode: 0o600 }); + const store = new FileSetupDraftStore({ directory: join(fileParent, "setup") }); + let failure: unknown; + + try { + await store.save({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(MiftahError); + expect(failure).toMatchObject({ code: "SETUP_DRAFT_UNAVAILABLE" }); + expect((failure as MiftahError).details?.cause).toMatchObject({ code: expect.any(String) }); + }); }); From a48585814fac9fd5a4e5cf1c6585fda663b9169e Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 16:29:40 +0400 Subject: [PATCH 04/25] fix(setup): avoid redundant Windows draft ACL probes --- src/setup/setup-draft.ts | 26 +++++++++++++++---------- tests/console-windows-first-run.test.ts | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/setup/setup-draft.ts b/src/setup/setup-draft.ts index 2d63105..1660fe1 100644 --- a/src/setup/setup-draft.ts +++ b/src/setup/setup-draft.ts @@ -249,7 +249,10 @@ function sameFileIdentity(left: Pick, right: Pick { +async function privateDirectoryMetadata( + path: string, + verifyWindowsAcl: boolean = true +): Promise { let metadata: BigIntStats; try { metadata = await lstat(path, { bigint: true }); @@ -259,7 +262,7 @@ async function privateDirectoryMetadata(path: string): Promise { +async function privateFileMetadata(path: string, verifyWindowsAcl: boolean = true): Promise { let metadata: BigIntStats; try { metadata = await lstat(path, { bigint: true }); @@ -277,7 +280,7 @@ async function privateFileMetadata(path: string): Promise { - const existing = await this.loadUnchecked(); + const existing = await this.loadUnchecked(true); if (expectedRevision !== undefined && expectedRevision !== (existing?.revision ?? 0)) { setupDraftConflict(); } @@ -324,7 +327,7 @@ export class FileSetupDraftStore implements SetupDraftStore { async discard(expectedRevision: number): Promise { if (!validRevision(expectedRevision)) setupDraftInputInvalid(); await this.withLock(async () => { - const existing = await this.loadUnchecked(); + const existing = await this.loadUnchecked(true); if (existing === undefined) return; if (existing.revision !== expectedRevision) setupDraftConflict(); try { @@ -345,8 +348,8 @@ export class FileSetupDraftStore implements SetupDraftStore { } } - private async loadUnchecked(): Promise { - if (await privateDirectoryMetadata(this.directory) === undefined) return undefined; + private async loadUnchecked(directoryAlreadyVerified: boolean = false): Promise { + if (await privateDirectoryMetadata(this.directory, !directoryAlreadyVerified) === undefined) return undefined; const observed = await privateFileMetadata(this.path); if (observed === undefined) return undefined; let handle: Awaited> | undefined; @@ -355,7 +358,10 @@ export class FileSetupDraftStore implements SetupDraftStore { try { handle = await open(this.path, readOnlyFlags); const opened = await handle.stat({ bigint: true }); - const afterOpen = await privateFileMetadata(this.path); + // The initial full ACL check establishes that untrusted principals cannot + // replace this file. Retain the lstat identity checks below to catch any + // pathname swap without spawning another Windows verifier per read step. + const afterOpen = await privateFileMetadata(this.path, false); if ( afterOpen === undefined || !sameFileIdentity(observed, opened) || @@ -369,7 +375,7 @@ export class FileSetupDraftStore implements SetupDraftStore { if (bytesRead > maximumDraftBytes) setupDraftInvalid(); const bytes = boundedBytes.subarray(0, bytesRead); const afterRead = await handle.stat({ bigint: true }); - const afterReadPath = await privateFileMetadata(this.path); + const afterReadPath = await privateFileMetadata(this.path, false); if ( afterReadPath === undefined || !sameFileIdentity(opened, afterRead) || diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index 0569a88..93f0d31 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -112,4 +112,24 @@ describe("Console Windows first-run boundary", () => { await expect(store.discard(draft.revision)).resolves.toBeUndefined(); expect(aclMocks.createPrivateDirectory).toHaveBeenCalledTimes(1); }); + + it("checks each stable setup-draft path ACL once before retaining its opened identity", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-setup-draft-windows-read-")); + temporaryDirectories.push(parent); + const directory = join(parent, "setup-draft"); + const store = new FileSetupDraftStore({ directory }); + const draft = await store.save({ + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection" + }); + aclMocks.verifyPath.mockClear(); + + await expect(store.load()).resolves.toEqual(draft); + + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, directory, "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/setup-draft-v1\.json$/u), "file"); + }); }); From c1ff104cd0735f8e123b2833523ba5a043bfae18 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 16:44:31 +0400 Subject: [PATCH 05/25] fix(windows): reuse verified setup draft ACL state --- src/cli/windows-config-acl.ts | 3 ++- src/setup/setup-draft.ts | 20 +++++++++++++++----- tests/console-windows-first-run.test.ts | 19 +++++++++++++++++++ tests/windows-config-acl.test.ts | 15 +++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index 7e1d4bb..d5e12ed 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -64,7 +64,7 @@ export async function copyWindowsConfigSecurityDescriptors( return runWindowsAclRequest({ operation: "copy-file-security-batch", source, targets }); } -/** Creates a current-user-only directory with its DACL applied at creation time. */ +/** Creates and verifies a current-user-only directory with its DACL applied at creation time. */ export async function createWindowsPrivateDirectory(directory: string): Promise { return runWindowsAclRequest({ operation: "create-private-directory", directory }); } @@ -364,6 +364,7 @@ try { $actual = $directory.GetAccessControl() if (-not $actual.AreAccessRulesProtected) { exit 1 } if ($actual.GetSecurityDescriptorSddlForm($directorySections) -ne $expected) { exit 1 } + if (-not (Test-MiftahPrivatePath $directory 'directory')) { exit 1 } exit 0 } diff --git a/src/setup/setup-draft.ts b/src/setup/setup-draft.ts index 1660fe1..85bf138 100644 --- a/src/setup/setup-draft.ts +++ b/src/setup/setup-draft.ts @@ -232,7 +232,15 @@ async function ensurePrivateDirectory(directory: string): Promise { if (await privateDirectoryMetadata(directory) !== undefined) return; // Exclusive creation may lose a race to another Miftah process. Validate the // resulting path rather than treating that safe race as a storage failure. - await createWindowsPrivateDirectory(directory); + const created = await createWindowsPrivateDirectory(directory); + if (created) { + // The trusted creator verified the directory's owner, DACL, and reparse + // state. Retain the Node metadata check without launching it again. + if (await privateDirectoryMetadata(directory, false) === undefined) setupDraftUnavailable(); + return; + } + // A failed exclusive create may be a safe race with another Miftah process. + // Re-establish the full boundary before accepting that directory. if (await privateDirectoryMetadata(directory) === undefined) setupDraftUnavailable(); } catch (error) { if (error instanceof MiftahError) throw error; @@ -240,8 +248,8 @@ async function ensurePrivateDirectory(directory: string): Promise { } } -async function verifyPrivateFile(path: string): Promise { - if (await privateFileMetadata(path) === undefined) setupDraftUnavailable(); +async function verifyPrivateFile(path: string, verifyWindowsAcl: boolean = true): Promise { + if (await privateFileMetadata(path, verifyWindowsAcl) === undefined) setupDraftUnavailable(); } function sameFileIdentity(left: Pick, right: Pick): boolean { @@ -422,9 +430,11 @@ export class FileSetupDraftStore implements SetupDraftStore { await handle.close(); handle = undefined; await setRestrictiveMode(temporaryPath, 0o600); - await verifyPrivateFile(temporaryPath); + // secureWindowsConfigFile verifies the temporary file's owner, DACL, and + // reparse state. A same-directory rename preserves that verified object. + await verifyPrivateFile(temporaryPath, process.platform !== "win32"); await rename(temporaryPath, this.path); - await verifyPrivateFile(this.path); + await verifyPrivateFile(this.path, process.platform !== "win32"); } catch (error) { try { await handle?.close(); diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index 93f0d31..f64b728 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -132,4 +132,23 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, directory, "directory"); expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/setup-draft-v1\.json$/u), "file"); }); + + it("relies on completed Windows ACL helper verification when publishing a new draft", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-setup-draft-windows-write-")); + temporaryDirectories.push(parent); + const directory = join(parent, "setup-draft"); + const store = new FileSetupDraftStore({ directory }); + + await expect(store.save({ + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection" + })).resolves.toMatchObject({ revision: 1 }); + + expect(aclMocks.createPrivateDirectory).toHaveBeenCalledWith(directory); + expect(aclMocks.secureFile).toHaveBeenCalledOnce(); + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(1); + expect(aclMocks.verifyPath).toHaveBeenCalledWith(parent, "directory"); + }); }); diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index ffa186a..d77b675 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -121,6 +121,21 @@ describe("Windows migration ACL boundary", () => { expect(command).toContain("Test-MiftahPrivatePath $path 'file'"); }); + it("creates and verifies a current-user-only private directory before returning", async () => { + windowsAclMocks.spawn.mockImplementation(() => { + const child = createChild(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await expect(createWindowsPrivateMigrationDirectory("C:\\config\\.miftah-private")).resolves.toBe(true); + + const [, args] = windowsAclMocks.spawn.mock.calls[0] ?? []; + const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); + expect(command).toContain("create-private-directory"); + expect(command).toContain("Test-MiftahPrivatePath $directory 'directory'"); + }); + it("copies a non-null binary descriptor and verifies the persisted access rules", async () => { windowsAclMocks.spawn.mockImplementation(() => { const child = createChild(); From f1d0d12e1b5021164c3fd9c19aa865ef54dc1c66 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 17:03:11 +0400 Subject: [PATCH 06/25] fix(windows): avoid duplicate first-run ACL probe --- src/console/console-application-service.ts | 8 +++-- tests/console-windows-first-run.test.ts | 37 ++++++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index 33e7ff0..cefb59a 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -426,8 +426,12 @@ async function ensureFirstRunConfigDirectory(configPath: string): Promise if (!(await verifyWindowsConfigPathSecurity(parent, "directory"))) { throw new Error("unsafe configuration parent directory"); } - await createWindowsPrivateDirectory(directory); - if (!(await verifyWindowsConfigPathSecurity(directory, "directory"))) { + const created = await createWindowsPrivateDirectory(directory); + // The trusted creator applies and verifies the private owner/DACL/reparse + // boundary before it reports success. A failed exclusive create may be a + // safe concurrent Miftah creation, so only that path needs an independent + // verifier before it can be accepted. + if (!created && !(await verifyWindowsConfigPathSecurity(directory, "directory"))) { throw new Error("unsafe configuration directory"); } } catch (error) { diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index f64b728..78a227f 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -54,7 +54,7 @@ afterEach(async () => { }); describe("Console Windows first-run boundary", () => { - it("creates and verifies the standard config directory before writing the first configuration", async () => { + it("reuses the trusted creator verification for a newly created config directory", async () => { const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-")); temporaryDirectories.push(parent); const configDirectory = join(parent, "miftah"); @@ -71,9 +71,40 @@ describe("Console Windows first-run boundary", () => { })).resolves.toMatchObject({ changed: true, write: true }); expect(aclMocks.createPrivateDirectory).toHaveBeenCalledWith(configDirectory); - expect(aclMocks.verifyPath).toHaveBeenCalledWith(configDirectory, "directory"); + // Catalog discovery independently verifies the created configuration after + // publication. The creator must not add a second directory probe before it. + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(3); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, parent, "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah$/u), "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(3, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); expect(aclMocks.secureFile).toHaveBeenCalledWith(configPath); - expect(aclMocks.verifyPath).toHaveBeenCalledWith(expect.stringMatching(/[/\\]miftah\.json$/u), "file"); + }); + + it("fully verifies a first-run config directory created by another Windows process", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-race-")); + temporaryDirectories.push(parent); + const configDirectory = join(parent, "miftah"); + const configPath = join(configDirectory, "miftah.json"); + aclMocks.createPrivateDirectory.mockImplementationOnce(async (directory) => { + await mkdir(directory); + return false; + }); + const service = new ConsoleDashboardApplicationService({ configDirectory, defaultConfigPath: configPath }); + + await expect(service.onboardNativeOAuth({ + name: "first-run", + profile: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid"] + })).resolves.toMatchObject({ changed: true, write: true }); + + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(4); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, parent, "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, configDirectory, "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(3, expect.stringMatching(/[/\\]miftah$/u), "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(4, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); }); it("fails closed before audit or config creation when the standard directory cannot be verified", async () => { From 4c41130d50720f0cef8fae7a90e29ba520150640 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 17:16:53 +0400 Subject: [PATCH 07/25] fix(windows): avoid redundant private ACL reads --- src/cli/windows-config-acl.ts | 18 +++++++----------- tests/windows-config-acl.test.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index d5e12ed..1b77032 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -200,7 +200,9 @@ $verifySections = [System.Security.AccessControl.AccessControlSections]::Access function Test-MiftahPrivatePath { param( [string]$path, - [string]$kind + [string]$kind, + [string]$expectedSddl = $null, + [bool]$requireProtected = $false ) if ($kind -ne 'file' -and $kind -ne 'directory') { return $false } @@ -223,6 +225,8 @@ function Test-MiftahPrivatePath { if ($null -eq $owner -or $owner.Value -cne $identity.Value) { return $false } $raw = [System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0) if ($null -eq $raw.DiscretionaryAcl -or -not $acl.AreAccessRulesCanonical) { return $false } + if ($requireProtected -and -not $acl.AreAccessRulesProtected) { return $false } + if ($expectedSddl.Length -gt 0 -and $acl.GetSecurityDescriptorSddlForm($directorySections) -ne $expectedSddl) { return $false } if (([int]$entry.Attributes -band $reparsePoint) -ne 0) { return $false } # OWNER RIGHTS applies only to the owner. CREATOR OWNER/GROUP are safe only # on inherit-only ACEs, where they have no access to this entry itself. @@ -333,11 +337,7 @@ try { ) $security.SetAccessRule($rule) [System.IO.File]::SetAccessControl($path, $security) - $entry.Refresh() - if (([int]$entry.Attributes -band $reparsePoint) -ne 0) { exit 1 } - $actual = [System.IO.File]::GetAccessControl($path, $verifySections) - if (-not $actual.AreAccessRulesProtected) { exit 1 } - if (-not (Test-MiftahPrivatePath $path 'file')) { exit 1 } + if (-not (Test-MiftahPrivatePath $path 'file' $null $true)) { exit 1 } exit 0 } @@ -360,11 +360,7 @@ try { $security.SetAccessRule($rule) $expected = $security.GetSecurityDescriptorSddlForm($directorySections) $directory.Create($security) - $directory.Refresh() - $actual = $directory.GetAccessControl() - if (-not $actual.AreAccessRulesProtected) { exit 1 } - if ($actual.GetSecurityDescriptorSddlForm($directorySections) -ne $expected) { exit 1 } - if (-not (Test-MiftahPrivatePath $directory 'directory')) { exit 1 } + if (-not (Test-MiftahPrivatePath $directory 'directory' $expected $true)) { exit 1 } exit 0 } diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index d77b675..30ad4c8 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -118,7 +118,8 @@ describe("Windows migration ACL boundary", () => { expect(command).toContain("FileSystemRights]::FullControl"); expect(command).toContain("File]::SetAccessControl"); expect(command).toContain("AreAccessRulesProtected"); - expect(command).toContain("Test-MiftahPrivatePath $path 'file'"); + expect(command).toContain("Test-MiftahPrivatePath $path 'file' $null $true"); + expect(command).not.toContain("$actual = [System.IO.File]::GetAccessControl($path, $verifySections)"); }); it("creates and verifies a current-user-only private directory before returning", async () => { @@ -133,7 +134,11 @@ describe("Windows migration ACL boundary", () => { const [, args] = windowsAclMocks.spawn.mock.calls[0] ?? []; const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); expect(command).toContain("create-private-directory"); - expect(command).toContain("Test-MiftahPrivatePath $directory 'directory'"); + expect(command).toContain("Test-MiftahPrivatePath $directory 'directory' $expected $true"); + expect(command).not.toContain("$actual = $directory.GetAccessControl()"); + expect(command).toContain("[string]$expectedSddl = $null"); + expect(command).toContain("[bool]$requireProtected = $false"); + expect(command).toContain("$expectedSddl.Length -gt 0"); }); it("copies a non-null binary descriptor and verifies the persisted access rules", async () => { From 2ae03a69e9f02c57f5158d5ab5b6a526a827466d Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 17:34:36 +0400 Subject: [PATCH 08/25] fix(console): reuse known draft revision for cleanup --- .../console-dashboard-application-service.ts | 24 +++++++++-- ...sole-dashboard-application-service.test.ts | 13 +++++- tests/console-windows-first-run.test.ts | 40 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/console/console-dashboard-application-service.ts b/src/console/console-dashboard-application-service.ts index c9d64bf..164a8ac 100644 --- a/src/console/console-dashboard-application-service.ts +++ b/src/console/console-dashboard-application-service.ts @@ -93,6 +93,10 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica readonly saveSetupDraft: ((input: SetupDraftInput, expectedRevision?: number) => Promise) | undefined; readonly discardSetupDraft: ((expectedRevision: number) => Promise) | undefined; private active: ActiveConsoleConfiguration | undefined; + // Only a revision returned by this dashboard instance is retained. The + // store still revalidates it under its lock before deletion, preserving a + // concurrent writer's conflict rather than deleting a newer draft. + private knownSetupDraftRevision: number | undefined; private discoveryInFlight: Promise | undefined; constructor(private readonly options: ConsoleDashboardApplicationServiceOptions) { @@ -102,15 +106,20 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica if (store !== undefined) { this.loadSetupDraft = async () => { await this.assertFirstRunAvailable(); - return store.load(); + const draft = await store.load(); + this.knownSetupDraftRevision = draft?.revision; + return draft; }; this.saveSetupDraft = async (input, expectedRevision) => { await this.assertFirstRunAvailable(); - return store.save(input, expectedRevision); + const draft = await store.save(input, expectedRevision); + this.knownSetupDraftRevision = draft.revision; + return draft; }; this.discardSetupDraft = async (expectedRevision) => { await this.assertFirstRunAvailable(); await store.discard(expectedRevision); + if (this.knownSetupDraftRevision === expectedRevision) this.knownSetupDraftRevision = undefined; }; } } @@ -386,8 +395,17 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica const store = this.setupDraftStore; if (store === undefined) return; try { + const knownRevision = this.knownSetupDraftRevision; + if (knownRevision !== undefined) { + await store.discard(knownRevision); + this.knownSetupDraftRevision = undefined; + return; + } const draft = await store.load(); - if (draft !== undefined) await store.discard(draft.revision); + if (draft !== undefined) { + await store.discard(draft.revision); + this.knownSetupDraftRevision = undefined; + } } catch (error) { const code = error instanceof MiftahError ? error.code : "SETUP_DRAFT_UNAVAILABLE"; process.emitWarning( diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 30fcb6b..37aa4b3 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -440,7 +440,7 @@ describe("Console dashboard application service", () => { await expect(loadSetupDraft()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); }); - it("keeps a published first-run configuration when private draft cleanup conflicts", async () => { + it("keeps a published first-run configuration when cached draft cleanup conflicts", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-cleanup-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); @@ -473,14 +473,25 @@ describe("Console dashboard application service", () => { setupDraftStore: draftStore }); const { preset, ...presetOptions } = supportedKnownConnectorOptions(); + const saveSetupDraft = service.saveSetupDraft; + if (saveSetupDraft === undefined) { + throw new Error("Expected the first-run service to expose the configured setup-draft capability."); + } try { + await expect(saveSetupDraft({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + })).resolves.toEqual(draft); await expect(service.onboardPreset({ name: "support-tools", preset, ...presetOptions })).resolves.toMatchObject({ name: "support-tools" }); await expect(readFile(configPath, "utf8")).resolves.toContain('"name": "support-tools"'); + expect(draftStore.load).not.toHaveBeenCalled(); expect(draftStore.discard).toHaveBeenCalledWith(1); expect(warning).toHaveBeenCalledWith( "Configuration was created, but Miftah could not clear the saved connector choice (SETUP_DRAFT_CONFLICT). Run 'miftah setup --discard-draft' to remove it later.", diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index 78a227f..5406b2f 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -144,6 +144,46 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.createPrivateDirectory).toHaveBeenCalledTimes(1); }); + it("does not reverify a known draft during first-run cleanup", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-draft-lifecycle-")); + temporaryDirectories.push(parent); + const configDirectory = join(parent, "miftah"); + const configPath = join(configDirectory, "miftah.json"); + const draftStore = new FileSetupDraftStore({ directory: join(parent, "setup-draft") }); + const service = new ConsoleDashboardApplicationService({ + configDirectory, + defaultConfigPath: configPath, + setupDraftStore: draftStore + }); + const saveSetupDraft = service.saveSetupDraft; + const loadSetupDraft = service.loadSetupDraft; + if (saveSetupDraft === undefined || loadSetupDraft === undefined) { + throw new Error("Expected the first-run service to expose the configured setup-draft capability."); + } + + const draft = await saveSetupDraft({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + }); + await expect(loadSetupDraft()).resolves.toEqual(draft); + await expect(service.onboardNativeOAuth({ + name: "first-run", + profile: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid"] + })).resolves.toMatchObject({ changed: true, write: true }); + await expect(draftStore.load()).resolves.toBeUndefined(); + await expect(loadSetupDraft()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); + + expect(aclMocks.createPrivateDirectory).toHaveBeenCalledTimes(2); + expect(aclMocks.secureFile).toHaveBeenCalledTimes(2); + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(12); + }); + it("checks each stable setup-draft path ACL once before retaining its opened identity", async () => { const parent = await mkdtemp(join(tmpdir(), "miftah-setup-draft-windows-read-")); temporaryDirectories.push(parent); From 4402bdff9986e345f2d645e97e607ab58cf0bccf Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 18:01:03 +0400 Subject: [PATCH 09/25] fix(windows): batch first-run ACL checks --- src/cli/windows-config-acl.ts | 117 +++++++++++++++++- src/console/console-application-service.ts | 22 ++-- src/setup/setup-draft.ts | 66 +++++++++-- tests/console-windows-first-run.test.ts | 131 +++++++++++++++++---- tests/windows-config-acl.test.ts | 71 ++++++++++- 5 files changed, 357 insertions(+), 50 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index 1b77032..fb25d12 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -27,6 +27,12 @@ interface CreatePrivateDirectoryRequest { readonly directory: string; } +interface CreatePrivateDirectoryInPrivateParentRequest { + readonly operation: "create-private-directory-in-private-parent"; + readonly parent: string; + readonly directory: string; +} + interface SecurePrivateFileRequest { readonly operation: "secure-private-file"; readonly path: string; @@ -38,12 +44,24 @@ interface VerifyPrivatePathRequest { readonly path: string; } +export interface WindowsPrivatePath { + readonly kind: "file" | "directory"; + readonly path: string; +} + +interface VerifyPrivatePathsRequest { + readonly operation: "verify-private-paths"; + readonly paths: readonly WindowsPrivatePath[]; +} + type WindowsConfigAclRequest = | CopyFileSecurityRequest | CopyFileSecurityBatchRequest | CreatePrivateDirectoryRequest + | CreatePrivateDirectoryInPrivateParentRequest | SecurePrivateFileRequest - | VerifyPrivatePathRequest; + | VerifyPrivatePathRequest + | VerifyPrivatePathsRequest; /** * Copies the source file's non-null owner, group, and DACL, then verifies its @@ -69,6 +87,18 @@ export async function createWindowsPrivateDirectory(directory: string): Promise< return runWindowsAclRequest({ operation: "create-private-directory", directory }); } +/** + * Verifies an immediate private parent and creates the child under its own + * current-user-only descriptor in one trusted helper invocation. The helper + * rejects a non-child path, reparse point, unsafe parent, or failed creation. + */ +export async function createWindowsPrivateDirectoryInPrivateParent( + parent: string, + directory: string +): Promise { + return runWindowsAclRequest({ operation: "create-private-directory-in-private-parent", parent, directory }); +} + /** Applies and verifies a current-user-only DACL to an already exclusively created file. */ export async function secureWindowsConfigFile(path: string): Promise { return runWindowsAclRequest({ operation: "secure-private-file", path }); @@ -91,6 +121,27 @@ export async function verifyWindowsConfigPathSecurity( return runWindowsAclRequest({ operation: "verify-private-path", path, kind }); } +/** + * Verifies a bounded set of already-stable private paths in one trusted helper + * process. Each path receives the same owner, DACL, and reparse-point checks + * as verifyWindowsConfigPathSecurity; callers retain their own identity checks. + */ +export async function verifyWindowsConfigPathsSecurity(paths: readonly WindowsPrivatePath[]): Promise { + if ( + paths.length === 0 || + paths.length > 8 || + paths.some((candidate) => ( + typeof candidate !== "object" || + candidate === null || + (candidate.kind !== "file" && candidate.kind !== "directory") || + typeof candidate.path !== "string" + )) + ) { + return false; + } + return runWindowsAclRequest({ operation: "verify-private-paths", paths }); +} + async function runWindowsAclRequest(request: WindowsConfigAclRequest): Promise { if (process.platform !== "win32") return true; const launcher = trustedPowerShellExecutable(); @@ -107,6 +158,10 @@ function requestHasNul(request: WindowsConfigAclRequest): boolean { if (request.operation === "copy-file-security-batch") { return request.source.includes("\u0000") || request.targets.some((target) => target.includes("\u0000")); } + if (request.operation === "create-private-directory-in-private-parent") { + return request.parent.includes("\u0000") || request.directory.includes("\u0000"); + } + if (request.operation === "verify-private-paths") return request.paths.some((path) => path.path.includes("\u0000")); if (request.operation === "create-private-directory") return request.directory.includes("\u0000"); return request.path.includes("\u0000"); } @@ -116,11 +171,15 @@ function encodeRequest(request: WindowsConfigAclRequest): string | undefined { ? [request.operation, request.source, request.target] : request.operation === "copy-file-security-batch" ? [request.operation, request.source, ...request.targets] - : request.operation === "create-private-directory" - ? [request.operation, request.directory] - : request.operation === "secure-private-file" - ? [request.operation, request.path] - : [request.operation, request.kind, request.path]; + : request.operation === "create-private-directory" + ? [request.operation, request.directory] + : request.operation === "create-private-directory-in-private-parent" + ? [request.operation, request.parent, request.directory] + : request.operation === "secure-private-file" + ? [request.operation, request.path] + : request.operation === "verify-private-paths" + ? [request.operation, ...request.paths.flatMap((path) => [path.kind, path.path])] + : [request.operation, request.kind, request.path]; const payload = fields.join("\u0000"); const bytes = Buffer.from(payload, "utf8"); return bytes.byteLength <= maximumRequestBytes && bytes.toString("utf8") === payload @@ -319,6 +378,20 @@ try { exit 0 } + if ( + $fields.Count -ge 3 -and + $fields.Count -le 17 -and + $fields[0] -eq 'verify-private-paths' -and + (($fields.Count - 1) % 2 -eq 0) + ) { + for ($index = 1; $index -lt $fields.Count; $index += 2) { + $kind = $fields[$index] + $path = $fields[$index + 1] + if (-not (Test-MiftahPrivatePath $path $kind)) { exit 1 } + } + exit 0 + } + if ($fields.Count -eq 2 -and $fields[0] -eq 'secure-private-file') { $path = $fields[1] $entry = [System.IO.FileInfo]::new($path) @@ -364,6 +437,38 @@ try { exit 0 } + if ($fields.Count -eq 3 -and $fields[0] -eq 'create-private-directory-in-private-parent') { + $parent = [System.IO.DirectoryInfo]::new($fields[1]) + $directory = [System.IO.DirectoryInfo]::new($fields[2]) + $trimCharacters = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $parentPath = [System.IO.Path]::GetFullPath($parent.FullName).TrimEnd($trimCharacters) + $directoryPath = [System.IO.Path]::GetFullPath($directory.FullName) + $directoryParent = [System.IO.Path]::GetDirectoryName($directoryPath) + if ([string]::IsNullOrEmpty($directoryParent)) { exit 1 } + $directoryParent = $directoryParent.TrimEnd($trimCharacters) + if (-not [string]::Equals($parentPath, $directoryParent, [System.StringComparison]::OrdinalIgnoreCase)) { exit 1 } + if (-not (Test-MiftahPrivatePath $parent 'directory')) { exit 1 } + if ($directory.Exists) { exit 1 } + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + if ($null -eq $identity) { exit 1 } + $security = [System.Security.AccessControl.DirectorySecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $security.SetOwner($identity) + $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.InheritanceFlags]$inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $security.SetAccessRule($rule) + $expected = $security.GetSecurityDescriptorSddlForm($directorySections) + $directory.Create($security) + if (-not (Test-MiftahPrivatePath $directory 'directory' $expected $true)) { exit 1 } + exit 0 + } + exit 1 } catch { exit 1 diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index cefb59a..9f51a07 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -86,8 +86,8 @@ import { } from "../setup/profile-rename-onboarding.js"; import type { OAuthProfileRenameDependencies } from "../oauth/profile-rename-transaction.js"; import { - createWindowsPrivateDirectory, - verifyWindowsConfigPathSecurity + createWindowsPrivateDirectoryInPrivateParent, + verifyWindowsConfigPathsSecurity } from "../cli/windows-config-acl.js"; import { renderClientSnippets, @@ -423,16 +423,16 @@ async function ensureFirstRunConfigDirectory(configPath: string): Promise } const parent = dirname(directory); await mkdir(parent, { recursive: true, mode: 0o700 }); - if (!(await verifyWindowsConfigPathSecurity(parent, "directory"))) { - throw new Error("unsafe configuration parent directory"); - } - const created = await createWindowsPrivateDirectory(directory); + const created = await createWindowsPrivateDirectoryInPrivateParent(parent, directory); // The trusted creator applies and verifies the private owner/DACL/reparse - // boundary before it reports success. A failed exclusive create may be a - // safe concurrent Miftah creation, so only that path needs an independent - // verifier before it can be accepted. - if (!created && !(await verifyWindowsConfigPathSecurity(directory, "directory"))) { - throw new Error("unsafe configuration directory"); + // boundary, including its parent, before it reports success. A failed + // exclusive create may be a safe concurrent Miftah creation, so both path + // components need an independent verifier before that race is accepted. + if (!created && !(await verifyWindowsConfigPathsSecurity([ + { path: parent, kind: "directory" }, + { path: directory, kind: "directory" } + ]))) { + throw new Error("unsafe configuration directory or parent"); } } catch (error) { if (error instanceof MiftahError) throw error; diff --git a/src/setup/setup-draft.ts b/src/setup/setup-draft.ts index 85bf138..152ca5d 100644 --- a/src/setup/setup-draft.ts +++ b/src/setup/setup-draft.ts @@ -4,9 +4,10 @@ import { chmod, lstat, mkdir, open, rename, rm } from "node:fs/promises"; import { homedir, platform } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { - createWindowsPrivateDirectory, + createWindowsPrivateDirectoryInPrivateParent, secureWindowsConfigFile, - verifyWindowsConfigPathSecurity + verifyWindowsConfigPathSecurity, + verifyWindowsConfigPathsSecurity } from "../cli/windows-config-acl.js"; import { PRESET_CATALOG, type PresetCatalogName } from "../config/presets.js"; import { withOAuthLocalLock } from "../oauth/local-lock.js"; @@ -228,11 +229,10 @@ async function ensurePrivateDirectory(directory: string): Promise { } const parent = dirname(directory); await mkdir(parent, { recursive: true, mode: 0o700 }); - if (!(await verifyWindowsConfigPathSecurity(parent, "directory"))) setupDraftUnavailable(); - if (await privateDirectoryMetadata(directory) !== undefined) return; + if (await verifyExistingPrivateDirectory(parent, directory)) return; // Exclusive creation may lose a race to another Miftah process. Validate the // resulting path rather than treating that safe race as a storage failure. - const created = await createWindowsPrivateDirectory(directory); + const created = await createWindowsPrivateDirectoryInPrivateParent(parent, directory); if (created) { // The trusted creator verified the directory's owner, DACL, and reparse // state. Retain the Node metadata check without launching it again. @@ -241,13 +241,28 @@ async function ensurePrivateDirectory(directory: string): Promise { } // A failed exclusive create may be a safe race with another Miftah process. // Re-establish the full boundary before accepting that directory. - if (await privateDirectoryMetadata(directory) === undefined) setupDraftUnavailable(); + if (!(await verifyExistingPrivateDirectory(parent, directory))) setupDraftUnavailable(); } catch (error) { if (error instanceof MiftahError) throw error; setupDraftUnavailable(error); } } +/** Validates a stable child directory and the parent that protects it from replacement. */ +async function verifyExistingPrivateDirectory(parent: string, directory: string): Promise { + const before = await privateDirectoryMetadata(directory, false); + if (before === undefined) return false; + if (!(await verifyWindowsConfigPathsSecurity([ + { path: parent, kind: "directory" }, + { path: directory, kind: "directory" } + ]))) { + setupDraftUnavailable(); + } + const after = await privateDirectoryMetadata(directory, false); + if (after === undefined || !sameFileIdentity(before, after)) setupDraftUnavailable(); + return true; +} + async function verifyPrivateFile(path: string, verifyWindowsAcl: boolean = true): Promise { if (await privateFileMetadata(path, verifyWindowsAcl) === undefined) setupDraftUnavailable(); } @@ -357,8 +372,7 @@ export class FileSetupDraftStore implements SetupDraftStore { } private async loadUnchecked(directoryAlreadyVerified: boolean = false): Promise { - if (await privateDirectoryMetadata(this.directory, !directoryAlreadyVerified) === undefined) return undefined; - const observed = await privateFileMetadata(this.path); + const observed = await this.observedDraftFileForRead(directoryAlreadyVerified); if (observed === undefined) return undefined; let handle: Awaited> | undefined; let content: string | undefined; @@ -417,6 +431,42 @@ export class FileSetupDraftStore implements SetupDraftStore { return normalizeStoredDraft(value); } + private async observedDraftFileForRead(directoryAlreadyVerified: boolean): Promise { + if (process.platform !== "win32" || directoryAlreadyVerified) { + if (await privateDirectoryMetadata(this.directory, !directoryAlreadyVerified) === undefined) return undefined; + return privateFileMetadata(this.path); + } + const directory = await privateDirectoryMetadata(this.directory, false); + if (directory === undefined) return undefined; + const observed = await privateFileMetadata(this.path, false); + if (observed === undefined) { + // Returning an absent draft still influences which setup path the caller + // may take. Keep the directory's original fail-closed verification even + // though there is no file to include in a batch yet. + if (!(await verifyWindowsConfigPathSecurity(this.directory, "directory"))) setupDraftUnavailable(); + const verifiedDirectory = await privateDirectoryMetadata(this.directory, false); + if (verifiedDirectory === undefined || !sameFileIdentity(directory, verifiedDirectory)) setupDraftUnavailable(); + return undefined; + } + if (!(await verifyWindowsConfigPathsSecurity([ + { path: this.directory, kind: "directory" }, + { path: this.path, kind: "file" } + ]))) { + setupDraftUnavailable(); + } + const verifiedDirectory = await privateDirectoryMetadata(this.directory, false); + const verifiedFile = await privateFileMetadata(this.path, false); + if ( + verifiedDirectory === undefined || + verifiedFile === undefined || + !sameFileIdentity(directory, verifiedDirectory) || + !sameFileIdentity(observed, verifiedFile) + ) { + setupDraftUnavailable(); + } + return verifiedFile; + } + private async persist(draft: SetupDraft): Promise { const content = JSON.stringify(draft); if (Buffer.byteLength(content, "utf8") > maximumDraftBytes) setupDraftUnavailable(); diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index 5406b2f..dbca099 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -5,14 +5,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const aclMocks = vi.hoisted(() => ({ createPrivateDirectory: vi.fn<(directory: string) => Promise>(), + createPrivateDirectoryInParent: vi.fn<(parent: string, directory: string) => Promise>(), secureFile: vi.fn<(path: string) => Promise>(), - verifyPath: vi.fn<(path: string, kind: "file" | "directory") => Promise>() + verifyPath: vi.fn<(path: string, kind: "file" | "directory") => Promise>(), + verifyPaths: vi.fn<(paths: readonly { readonly path: string; readonly kind: "file" | "directory" }[]) => Promise>() })); vi.mock("../src/cli/windows-config-acl.js", () => ({ createWindowsPrivateDirectory: aclMocks.createPrivateDirectory, + createWindowsPrivateDirectoryInPrivateParent: aclMocks.createPrivateDirectoryInParent, secureWindowsConfigFile: aclMocks.secureFile, - verifyWindowsConfigPathSecurity: aclMocks.verifyPath + verifyWindowsConfigPathSecurity: aclMocks.verifyPath, + verifyWindowsConfigPathsSecurity: aclMocks.verifyPaths })); vi.mock("../src/oauth/local-lock.js", () => ({ @@ -34,6 +38,7 @@ const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); beforeEach(() => { Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); aclMocks.verifyPath.mockResolvedValue(true); + aclMocks.verifyPaths.mockResolvedValue(true); aclMocks.secureFile.mockResolvedValue(true); aclMocks.createPrivateDirectory.mockImplementation(async (directory) => { try { @@ -43,13 +48,23 @@ beforeEach(() => { return false; } }); + aclMocks.createPrivateDirectoryInParent.mockImplementation(async (_parent, directory) => { + try { + await mkdir(directory); + return true; + } catch { + return false; + } + }); }); afterEach(async () => { if (platformDescriptor !== undefined) Object.defineProperty(process, "platform", platformDescriptor); aclMocks.createPrivateDirectory.mockReset(); + aclMocks.createPrivateDirectoryInParent.mockReset(); aclMocks.secureFile.mockReset(); aclMocks.verifyPath.mockReset(); + aclMocks.verifyPaths.mockReset(); await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); @@ -70,13 +85,13 @@ describe("Console Windows first-run boundary", () => { scopes: ["openid"] })).resolves.toMatchObject({ changed: true, write: true }); - expect(aclMocks.createPrivateDirectory).toHaveBeenCalledWith(configDirectory); + expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledWith(parent, configDirectory); // Catalog discovery independently verifies the created configuration after // publication. The creator must not add a second directory probe before it. - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(3); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, parent, "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah$/u), "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(3, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, expect.stringMatching(/[/\\]miftah$/u), "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); + expect(aclMocks.verifyPaths).not.toHaveBeenCalled(); expect(aclMocks.secureFile).toHaveBeenCalledWith(configPath); }); @@ -85,7 +100,7 @@ describe("Console Windows first-run boundary", () => { temporaryDirectories.push(parent); const configDirectory = join(parent, "miftah"); const configPath = join(configDirectory, "miftah.json"); - aclMocks.createPrivateDirectory.mockImplementationOnce(async (directory) => { + aclMocks.createPrivateDirectoryInParent.mockImplementationOnce(async (_parent, directory) => { await mkdir(directory); return false; }); @@ -100,11 +115,13 @@ describe("Console Windows first-run boundary", () => { scopes: ["openid"] })).resolves.toMatchObject({ changed: true, write: true }); - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(4); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, parent, "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, configDirectory, "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(3, expect.stringMatching(/[/\\]miftah$/u), "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(4, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); + expect(aclMocks.verifyPaths).toHaveBeenCalledWith([ + { path: parent, kind: "directory" }, + { path: configDirectory, kind: "directory" } + ]); + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, expect.stringMatching(/[/\\]miftah$/u), "directory"); + expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); }); it("fails closed before audit or config creation when the standard directory cannot be verified", async () => { @@ -112,8 +129,8 @@ describe("Console Windows first-run boundary", () => { temporaryDirectories.push(parent); const configDirectory = join(parent, "miftah"); const configPath = join(configDirectory, "miftah.json"); - aclMocks.createPrivateDirectory.mockResolvedValue(false); - aclMocks.verifyPath.mockResolvedValue(false); + aclMocks.createPrivateDirectoryInParent.mockResolvedValue(false); + aclMocks.verifyPaths.mockResolvedValue(false); const service = new ConsoleDashboardApplicationService({ configDirectory, defaultConfigPath: configPath }); await expect(service.onboardNativeOAuth({ @@ -141,7 +158,11 @@ describe("Console Windows first-run boundary", () => { }); await expect(store.discard(draft.revision)).resolves.toBeUndefined(); - expect(aclMocks.createPrivateDirectory).toHaveBeenCalledTimes(1); + expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledWith(parent, join(parent, "setup-draft")); + expect(aclMocks.verifyPaths).toHaveBeenCalledWith([ + { path: parent, kind: "directory" }, + { path: join(parent, "setup-draft"), kind: "directory" } + ]); }); it("does not reverify a known draft during first-run cleanup", async () => { @@ -179,9 +200,68 @@ describe("Console Windows first-run boundary", () => { await expect(draftStore.load()).resolves.toBeUndefined(); await expect(loadSetupDraft()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); - expect(aclMocks.createPrivateDirectory).toHaveBeenCalledTimes(2); + expect(aclMocks.createPrivateDirectory).not.toHaveBeenCalled(); + expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledTimes(2); expect(aclMocks.secureFile).toHaveBeenCalledTimes(2); - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(12); + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(6); + expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(2); + }); + + it("attributes Windows ACL helper launches across the complete first-run draft lifecycle", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-draft-boundaries-")); + temporaryDirectories.push(parent); + const configDirectory = join(parent, "miftah"); + const configPath = join(configDirectory, "miftah.json"); + const draftStore = new FileSetupDraftStore({ directory: join(parent, "setup-draft") }); + const service = new ConsoleDashboardApplicationService({ + configDirectory, + defaultConfigPath: configPath, + setupDraftStore: draftStore + }); + const saveSetupDraft = service.saveSetupDraft; + const loadSetupDraft = service.loadSetupDraft; + if (saveSetupDraft === undefined || loadSetupDraft === undefined) { + throw new Error("Expected the first-run service to expose the configured setup-draft capability."); + } + + const snapshot = (): Readonly> => ({ + create: aclMocks.createPrivateDirectory.mock.calls.length, + createInParent: aclMocks.createPrivateDirectoryInParent.mock.calls.length, + secure: aclMocks.secureFile.mock.calls.length, + verify: aclMocks.verifyPath.mock.calls.length, + verifyBatch: aclMocks.verifyPaths.mock.calls.length + }); + + const draft = await saveSetupDraft({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + }); + const afterSave = snapshot(); + await expect(loadSetupDraft()).resolves.toEqual(draft); + const afterLoad = snapshot(); + await expect(service.onboardNativeOAuth({ + name: "first-run", + profile: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid"] + })).resolves.toMatchObject({ changed: true, write: true }); + const afterPublication = snapshot(); + await expect(draftStore.load()).resolves.toBeUndefined(); + const afterDraftRead = snapshot(); + await expect(loadSetupDraft()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); + const afterConfiguredRead = snapshot(); + + expect({ afterSave, afterLoad, afterPublication, afterDraftRead, afterConfiguredRead }).toEqual({ + afterSave: { create: 0, createInParent: 1, secure: 1, verify: 0, verifyBatch: 0 }, + afterLoad: { create: 0, createInParent: 1, secure: 1, verify: 0, verifyBatch: 1 }, + afterPublication: { create: 0, createInParent: 2, secure: 2, verify: 3, verifyBatch: 2 }, + afterDraftRead: { create: 0, createInParent: 2, secure: 2, verify: 4, verifyBatch: 2 }, + afterConfiguredRead: { create: 0, createInParent: 2, secure: 2, verify: 6, verifyBatch: 2 } + }); }); it("checks each stable setup-draft path ACL once before retaining its opened identity", async () => { @@ -196,12 +276,15 @@ describe("Console Windows first-run boundary", () => { stage: "connection" }); aclMocks.verifyPath.mockClear(); + aclMocks.verifyPaths.mockClear(); await expect(store.load()).resolves.toEqual(draft); - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, directory, "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/setup-draft-v1\.json$/u), "file"); + expect(aclMocks.verifyPath).not.toHaveBeenCalled(); + expect(aclMocks.verifyPaths).toHaveBeenCalledWith([ + { path: directory, kind: "directory" }, + { path: expect.stringMatching(/setup-draft-v1\.json$/u), kind: "file" } + ]); }); it("relies on completed Windows ACL helper verification when publishing a new draft", async () => { @@ -217,9 +300,9 @@ describe("Console Windows first-run boundary", () => { stage: "connection" })).resolves.toMatchObject({ revision: 1 }); - expect(aclMocks.createPrivateDirectory).toHaveBeenCalledWith(directory); + expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledWith(parent, directory); expect(aclMocks.secureFile).toHaveBeenCalledOnce(); - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(1); - expect(aclMocks.verifyPath).toHaveBeenCalledWith(parent, "directory"); + expect(aclMocks.verifyPath).not.toHaveBeenCalled(); + expect(aclMocks.verifyPaths).not.toHaveBeenCalled(); }); }); diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index 30ad4c8..8da4af1 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -19,9 +19,11 @@ vi.mock("node:child_process", async (importOriginal) => ({ import { copyWindowsConfigSecurityDescriptor, copyWindowsConfigSecurityDescriptors, + createWindowsPrivateDirectoryInPrivateParent, createWindowsPrivateMigrationDirectory, secureWindowsConfigFile, - verifyWindowsConfigPathSecurity + verifyWindowsConfigPathSecurity, + verifyWindowsConfigPathsSecurity } from "../src/cli/windows-config-acl.js"; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -87,6 +89,39 @@ describe("Windows migration ACL boundary", () => { await expect(verifyWindowsConfigPathSecurity("C:\\Users\\miftah\\.config\\miftah\\gsc.json", "file")).resolves.toBe(false); }); + it("verifies multiple stable private paths in one trusted helper without relaxing either check", async () => { + windowsAclMocks.spawn.mockImplementation(() => { + const child = createChild(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await expect(verifyWindowsConfigPathsSecurity([ + { path: "C:\\Users\\miftah\\.config\\miftah", kind: "directory" }, + { path: "C:\\Users\\miftah\\.config\\miftah\\gsc.json", kind: "file" } + ])).resolves.toBe(true); + + expect(windowsAclMocks.spawn).toHaveBeenCalledOnce(); + const [, args, options] = windowsAclMocks.spawn.mock.calls[0] ?? []; + expect(options).toMatchObject({ shell: false, windowsHide: true, stdio: "ignore" }); + expect(Buffer.from(options?.env?.MIFTAH_CONFIG_ACL_REQUEST ?? "", "base64").toString("utf8")).toBe( + "verify-private-paths\u0000directory\u0000C:\\Users\\miftah\\.config\\miftah\u0000file\u0000C:\\Users\\miftah\\.config\\miftah\\gsc.json" + ); + const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); + expect(command).toContain("verify-private-paths"); + expect(command).toContain("for ($index = 1; $index -lt $fields.Count; $index += 2)"); + expect(command).toContain("Test-MiftahPrivatePath $path $kind"); + }); + + it("fails closed before launch for an empty or NUL-containing batch verification request", async () => { + await expect(verifyWindowsConfigPathsSecurity([])).resolves.toBe(false); + await expect(verifyWindowsConfigPathsSecurity([ + { path: "C:\\Users\\miftah\\.config\\miftah\u0000unexpected", kind: "directory" } + ])).resolves.toBe(false); + + expect(windowsAclMocks.spawn).not.toHaveBeenCalled(); + }); + it("fails closed when source descriptor verification fails", async () => { windowsAclMocks.spawn.mockImplementation(() => { const child = createChild(); @@ -141,6 +176,40 @@ describe("Windows migration ACL boundary", () => { expect(command).toContain("$expectedSddl.Length -gt 0"); }); + it("checks an immediate private parent in the same trusted helper before creating its child directory", async () => { + windowsAclMocks.spawn.mockImplementation(() => { + const child = createChild(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await expect(createWindowsPrivateDirectoryInPrivateParent( + "C:\\Users\\miftah\\.config", + "C:\\Users\\miftah\\.config\\miftah" + )).resolves.toBe(true); + + expect(windowsAclMocks.spawn).toHaveBeenCalledOnce(); + const [, args, options] = windowsAclMocks.spawn.mock.calls[0] ?? []; + expect(options).toMatchObject({ shell: false, windowsHide: true, stdio: "ignore" }); + expect(Buffer.from(options?.env?.MIFTAH_CONFIG_ACL_REQUEST ?? "", "base64").toString("utf8")).toBe( + "create-private-directory-in-private-parent\u0000C:\\Users\\miftah\\.config\u0000C:\\Users\\miftah\\.config\\miftah" + ); + const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); + expect(command).toContain("create-private-directory-in-private-parent"); + expect(command).toContain("GetDirectoryName($directoryPath)"); + expect(command).toContain("Test-MiftahPrivatePath $parent 'directory'"); + expect(command).toContain("Test-MiftahPrivatePath $directory 'directory' $expected $true"); + }); + + it("fails closed before launch when the parent-checked directory request contains a NUL byte", async () => { + await expect(createWindowsPrivateDirectoryInPrivateParent( + "C:\\Users\\miftah\\.config\u0000unexpected", + "C:\\Users\\miftah\\.config\\miftah" + )).resolves.toBe(false); + + expect(windowsAclMocks.spawn).not.toHaveBeenCalled(); + }); + it("copies a non-null binary descriptor and verifies the persisted access rules", async () => { windowsAclMocks.spawn.mockImplementation(() => { const child = createChild(); From e556e3318aeaa9283cdefde183a7873700a6bab9 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 18:08:04 +0400 Subject: [PATCH 10/25] docs(cli): clarify resumed checkpoint state --- docs/cli.md | 2 +- tests/readme-product-contract.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 6d23c72..c4bd2b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -78,7 +78,7 @@ miftah validate --config "$HOME/Miftah configs/work wrapper.json" `miftah setup ... --plan` validates a complete noninteractive first-configuration candidate and prints a structural, non-secret JSON plan without creating a directory, writing a file, or contacting an upstream. It includes the supplied output location, but deliberately excludes the endpoint, executable, working directory, arguments, environment names, credential references, headers, OAuth details, and secret values. `--plan` is unavailable for interactive, import, profile-addition, and native-OAuth setup because those flows have their own review or discovery boundaries. -For a bare interactive `connector` journey, Miftah writes a private checkpoint after the configuration name and catalog preset are selected, before it asks for connection details. `miftah setup --resume` requires a TTY and cannot be combined with any setup-input option. It restores only that name and preset, then asks again for every URL, executable, argument, path, environment reference, credential-file choice, client selection, and OAuth/browser detail. It never creates a configuration until the normal final publication succeeds. `miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY. Both operations reject a malformed, stale, concurrent, unsafe, or unavailable private checkpoint rather than replacing or guessing from it. +For a bare interactive `connector` journey, Miftah writes a private checkpoint after the configuration name and catalog preset are selected, before it asks for connection details. `miftah setup --resume` requires a TTY and cannot be combined with any setup-input option. It restores the connector source, configuration name, catalog preset, and setup stage, then asks again for every URL, executable, argument, path, environment reference, credential-file choice, client selection, and OAuth/browser detail. It never creates a configuration until the normal final publication succeeds. `miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY. Both operations reject a malformed, stale, concurrent, unsafe, or unavailable private checkpoint rather than replacing or guessing from it. `miftah setup --add-profile --config ` has two typed returning-user paths. For a reviewed provider-owned adapter, noninteractive use requires `--profile` and `--oauth-client-secrets-file`; `--description`, `--make-default`, and `--verify` are optional. Miftah proves that the entire selected configuration matches one adapter and that every existing account has a literal absolute credential-file reference plus a distinct canonical provider-state directory. It then atomically adds one new profile and isolated state directory through that adapter contract. Miftah never reads the credential file or the upstream token cache. Unsupported, modified, mixed, or shared-state configurations fail closed with `PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED`; an invalid credential-file path fails with `PROVIDER_ACCOUNT_INPUT_INVALID`. `--verify` checks the new account only. diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index 8cf0cd6..920623b 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -119,6 +119,7 @@ describe("product README", () => { expect(cliDocs).toContain("Browser sign-in setup can finish with browser authorization still outstanding."); expect(cliDocs).toContain("`miftah setup ... --plan` validates a complete noninteractive first-configuration candidate and prints a structural, non-secret JSON plan without creating a directory, writing a file, or contacting an upstream."); expect(cliDocs).toContain("`miftah setup --resume` requires a TTY and cannot be combined with any setup-input option."); + expect(cliDocs).toContain("It restores the connector source, configuration name, catalog preset, and setup stage, then asks again for every URL, executable, argument, path, environment reference, credential-file choice, client selection, and OAuth/browser detail."); expect(cliDocs).toContain("`miftah setup --discard-draft` deletes that checkpoint without creating a configuration and does not require a TTY."); expect(consoleDocs).toContain("`GET /api/v1/setup-draft` returns the current safe draft or `null`."); expect(consoleDocs).toContain("`PUT /api/v1/setup-draft` accepts only the safe connector intent"); From b48a9defaac703715c7e3a2ab9ec29f198aab85e Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 18:17:46 +0400 Subject: [PATCH 11/25] fix(windows): bound ACL helper command size --- src/cli/windows-config-acl.ts | 49 ++++++++++---------------------- tests/windows-config-acl.test.ts | 20 ++++++++++++- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index fb25d12..38ed855 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -414,40 +414,21 @@ try { exit 0 } - if ($fields.Count -eq 2 -and $fields[0] -eq 'create-private-directory') { - $directory = [System.IO.DirectoryInfo]::new($fields[1]) - if ($directory.Exists) { exit 1 } - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User - if ($null -eq $identity) { exit 1 } - $security = [System.Security.AccessControl.DirectorySecurity]::new() - $security.SetAccessRuleProtection($true, $false) - $security.SetOwner($identity) - $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit - $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $identity, - [System.Security.AccessControl.FileSystemRights]::FullControl, - [System.Security.AccessControl.InheritanceFlags]$inheritance, - [System.Security.AccessControl.PropagationFlags]::None, - [System.Security.AccessControl.AccessControlType]::Allow - ) - $security.SetAccessRule($rule) - $expected = $security.GetSecurityDescriptorSddlForm($directorySections) - $directory.Create($security) - if (-not (Test-MiftahPrivatePath $directory 'directory' $expected $true)) { exit 1 } - exit 0 - } - - if ($fields.Count -eq 3 -and $fields[0] -eq 'create-private-directory-in-private-parent') { - $parent = [System.IO.DirectoryInfo]::new($fields[1]) - $directory = [System.IO.DirectoryInfo]::new($fields[2]) - $trimCharacters = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - $parentPath = [System.IO.Path]::GetFullPath($parent.FullName).TrimEnd($trimCharacters) - $directoryPath = [System.IO.Path]::GetFullPath($directory.FullName) - $directoryParent = [System.IO.Path]::GetDirectoryName($directoryPath) - if ([string]::IsNullOrEmpty($directoryParent)) { exit 1 } - $directoryParent = $directoryParent.TrimEnd($trimCharacters) - if (-not [string]::Equals($parentPath, $directoryParent, [System.StringComparison]::OrdinalIgnoreCase)) { exit 1 } - if (-not (Test-MiftahPrivatePath $parent 'directory')) { exit 1 } + if ( + ($fields.Count -eq 2 -and $fields[0] -eq 'create-private-directory') -or + ($fields.Count -eq 3 -and $fields[0] -eq 'create-private-directory-in-private-parent') + ) { + $parentBound = $fields[0] -eq 'create-private-directory-in-private-parent' + $directory = [System.IO.DirectoryInfo]::new($fields[($fields.Count - 1)]) + if ($parentBound) { + $parent = [System.IO.DirectoryInfo]::new($fields[1]) + $trimCharacters = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $parentPath = [System.IO.Path]::GetFullPath($parent.FullName).TrimEnd($trimCharacters) + $directoryParent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($directory.FullName)) + if ([string]::IsNullOrEmpty($directoryParent)) { exit 1 } + if (-not [string]::Equals($parentPath, $directoryParent.TrimEnd($trimCharacters), [System.StringComparison]::OrdinalIgnoreCase)) { exit 1 } + if (-not (Test-MiftahPrivatePath $parent 'directory')) { exit 1 } + } if ($directory.Exists) { exit 1 } $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User if ($null -eq $identity) { exit 1 } diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index 8da4af1..47faac9 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -196,11 +196,29 @@ describe("Windows migration ACL boundary", () => { ); const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); expect(command).toContain("create-private-directory-in-private-parent"); - expect(command).toContain("GetDirectoryName($directoryPath)"); + expect(command).toContain("GetDirectoryName([System.IO.Path]::GetFullPath($directory.FullName))"); expect(command).toContain("Test-MiftahPrivatePath $parent 'directory'"); expect(command).toContain("Test-MiftahPrivatePath $directory 'directory' $expected $true"); }); + it("keeps the encoded ACL helper below the conservative Windows command-line budget", async () => { + windowsAclMocks.spawn.mockImplementation(() => { + const child = createChild(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await expect(createWindowsPrivateDirectoryInPrivateParent( + "C:\\Users\\miftah\\.config", + "C:\\Users\\miftah\\.config\\miftah" + )).resolves.toBe(true); + + const [, args] = windowsAclMocks.spawn.mock.calls[0] ?? []; + // `-EncodedCommand` consumes most of the CreateProcess command line by + // itself. Keep room for the absolute System32 launcher and fixed flags. + expect(args?.[4].length).toBeLessThanOrEqual(30_000); + }); + it("fails closed before launch when the parent-checked directory request contains a NUL byte", async () => { await expect(createWindowsPrivateDirectoryInPrivateParent( "C:\\Users\\miftah\\.config\u0000unexpected", From b6f3d2c5a845e964293d2bc810556826b8089aa2 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 18:36:06 +0400 Subject: [PATCH 12/25] fix(windows): verify ACL ancestor chain --- src/cli/windows-config-acl.ts | 36 ++++++---- tests/windows-config-acl.test.ts | 19 ++++++ tests/windows-config-migration-acl.test.ts | 76 +++++++++++++++++++++- 3 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index 38ed855..e8e8d8a 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -254,14 +254,15 @@ function runWindowsAclCommand(launcher: string, request: string): Promise { expect(command).toContain("Test-MiftahPrivatePath $directory 'directory' $expected $true"); }); + it("requires a trusted non-mutable ancestor chain before accepting a private path", async () => { + windowsAclMocks.spawn.mockImplementation(() => { + const child = createChild(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await expect(createWindowsPrivateDirectoryInPrivateParent( + "C:\\Users\\miftah\\.config", + "C:\\Users\\miftah\\.config\\miftah" + )).resolves.toBe(true); + + const [, args] = windowsAclMocks.spawn.mock.calls[0] ?? []; + const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); + expect(command).toContain("function Test-MiftahAncestors"); + expect(command).toContain("Test-MiftahPrivatePath $ancestor.FullName 'directory' $null $false $false"); + expect(command).toContain("Test-MiftahAncestors $parent"); + }); + it("keeps the encoded ACL helper below the conservative Windows command-line budget", async () => { windowsAclMocks.spawn.mockImplementation(() => { const child = createChild(); diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index d854ad6..ac942c9 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -1,11 +1,14 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, win32 } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { runMigrateConfigCommand } from "../src/cli/migrate-config.js"; -import { verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; +import { + createWindowsPrivateDirectoryInPrivateParent, + verifyWindowsConfigPathSecurity +} from "../src/cli/windows-config-acl.js"; const requestEnvironmentName = "MIFTAH_TEST_CONFIG_ACL_REQUEST"; const privateDirectoryRequestEnvironmentName = "MIFTAH_TEST_PRIVATE_DIRECTORY_ACL_REQUEST"; @@ -100,6 +103,27 @@ try { const encodedAclProbe = Buffer.from(aclProbe, "utf16le").toString("base64"); const encodedHangingAclProbe = Buffer.from("Start-Sleep -Seconds 10", "utf16le").toString("base64"); +const unsafeAncestorProbe = String.raw`$ErrorActionPreference = 'Stop' +$requestName = '${privateDirectoryRequestEnvironmentName}' +try { + $encoded = [Environment]::GetEnvironmentVariable($requestName, [EnvironmentVariableTarget]::Process) + $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)) + [Environment]::SetEnvironmentVariable($requestName, $null, [EnvironmentVariableTarget]::Process) + if ([string]::IsNullOrEmpty($path)) { exit 1 } + $security = [System.IO.Directory]::GetAccessControl($path) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-545'), + [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $security.AddAccessRule($rule) + [System.IO.Directory]::SetAccessControl($path, $security) + exit 0 +} catch { + exit 1 +}`; +const encodedUnsafeAncestorProbe = Buffer.from(unsafeAncestorProbe, "utf16le").toString("base64"); + const privateDirectoryProbe = String.raw`[Console]::Out.Write('MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_BOOTSTRAP') $ErrorActionPreference = 'Stop' $requestName = '${privateDirectoryRequestEnvironmentName}' @@ -398,6 +422,37 @@ async function windowsPrivateDirectoryProbe(directory: string): Promise { }); } +async function grantUntrustedAncestorMutation(directory: string): Promise { + const request = Buffer.from(directory, "utf8").toString("base64"); + await new Promise((resolve, reject) => { + const child = spawn( + trustedPowerShellExecutable(), + ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedUnsafeAncestorProbe], + { env: restrictedAclEnvironment(request), shell: false, windowsHide: true, stdio: "ignore" } + ); + const timeout = setTimeout(() => { + try { + child.kill(); + } catch { + // The test probe has no verified result after its bounded execution time. + } + reject(new Error("Windows unsafe-ancestor ACL probe timed out")); + }, 5_000); + child.once("error", () => { + clearTimeout(timeout); + reject(new Error("Windows unsafe-ancestor ACL probe could not start")); + }); + child.once("close", (code) => { + clearTimeout(timeout); + if (code === 0) { + resolve(); + return; + } + reject(new Error("Windows unsafe-ancestor ACL probe failed")); + }); + }); +} + async function windowsCopyFileSecurityProbe( source: string, target: string, @@ -443,6 +498,23 @@ async function windowsCopyFileSecurityProbe( } describe("Windows migration ACL contract", () => { + it.runIf(process.platform === "win32")( + "rejects a private child whose grandparent lets untrusted users replace its parent", + async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-windows-unsafe-ancestor-")); + temporaryDirectories.push(root); + const unsafeGrandparent = join(root, "unsafe-grandparent"); + const privateParent = join(unsafeGrandparent, "private-parent"); + const child = join(privateParent, "miftah"); + await mkdir(unsafeGrandparent); + await grantUntrustedAncestorMutation(unsafeGrandparent); + await windowsPrivateDirectoryProbe(privateParent); + + await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, child)).resolves.toBe(false); + }, + 10_000 + ); + it.runIf(process.platform === "win32")( "creates a private migration directory under the production ACL environment", async () => { From c1d453a87bc12a5595912be975cbdbddc27062cf Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 18:47:29 +0400 Subject: [PATCH 13/25] fix(windows): scope ACL ancestor checks --- src/cli/windows-config-acl.ts | 25 ++++++++++--------- src/setup/setup-draft.ts | 11 ++++++--- tests/console-windows-first-run.test.ts | 32 +++++++++++++++++++++---- tests/windows-config-acl.test.ts | 7 +++++- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index e8e8d8a..5190bd4 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -122,14 +122,15 @@ export async function verifyWindowsConfigPathSecurity( } /** - * Verifies a bounded set of already-stable private paths in one trusted helper - * process. Each path receives the same owner, DACL, and reparse-point checks - * as verifyWindowsConfigPathSecurity; callers retain their own identity checks. + * Verifies a bounded stable private-directory boundary and its related paths + * in one trusted helper. The first path must be the protected directory; its + * full ancestor chain is checked before the helper accepts the boundary. */ export async function verifyWindowsConfigPathsSecurity(paths: readonly WindowsPrivatePath[]): Promise { if ( paths.length === 0 || paths.length > 8 || + paths[0]?.kind !== "directory" || paths.some((candidate) => ( typeof candidate !== "object" || candidate === null || @@ -268,18 +269,16 @@ function Test-MiftahPrivatePath { if ($kind -ne 'file' -and $kind -ne 'directory') { return $false } $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User if ($null -eq $identity) { return $false } - $reparsePoint = [int][System.IO.FileAttributes]::ReparsePoint + $reparse = [int][System.IO.FileAttributes]::ReparsePoint if ($kind -eq 'file') { $entry = [System.IO.FileInfo]::new($path) - $parent = $entry.Directory if (-not $entry.Exists) { return $false } - if (([int]$entry.Attributes -band $reparsePoint) -ne 0) { return $false } + if (([int]$entry.Attributes -band $reparse) -ne 0) { return $false } $acl = [System.IO.File]::GetAccessControl($path, $verifySections) } else { $entry = [System.IO.DirectoryInfo]::new($path) - $parent = $entry.Parent if (-not $entry.Exists) { return $false } - if (([int]$entry.Attributes -band $reparsePoint) -ne 0) { return $false } + if (([int]$entry.Attributes -band $reparse) -ne 0) { return $false } $acl = [System.IO.Directory]::GetAccessControl($path, $verifySections) } $entry.Refresh() @@ -290,11 +289,11 @@ function Test-MiftahPrivatePath { if ($null -eq $raw.DiscretionaryAcl -or -not $acl.AreAccessRulesCanonical) { return $false } if ($requireProtected -and -not $acl.AreAccessRulesProtected) { return $false } if ($expectedSddl.Length -gt 0 -and $acl.GetSecurityDescriptorSddlForm($dirSections) -ne $expectedSddl) { return $false } - if (([int]$entry.Attributes -band $reparsePoint) -ne 0) { return $false } + if (([int]$entry.Attributes -band $reparse) -ne 0) { return $false } $creatorOwner = 'S-1-3-0' $creatorGroup = 'S-1-3-1' $inheritOnly = [int][System.Security.AccessControl.PropagationFlags]::InheritOnly - $restrictedRights = if ($kind -eq 'file') { + $restricted = if ($kind -eq 'file') { [int]( [System.Security.AccessControl.FileSystemRights]::ReadData -bor [System.Security.AccessControl.FileSystemRights]::ReadExtendedAttributes -bor @@ -328,9 +327,8 @@ function Test-MiftahPrivatePath { ($rule.IdentityReference.Value -ceq $creatorOwner -or $rule.IdentityReference.Value -ceq $creatorGroup) -and (([int]$rule.PropagationFlags -band $inheritOnly) -ne 0) ) { continue } - if (([int]$rule.FileSystemRights -band $restrictedRights) -ne 0) { return $false } + if (([int]$rule.FileSystemRights -band $restricted) -ne 0) { return $false } } - if ($requireOwner -and -not (Test-MiftahAncestors $parent)) { return $false } return $true } function Test-MiftahAncestors { @@ -401,6 +399,7 @@ try { $path = $fields[$index + 1] if (-not (Test-MiftahPrivatePath $path $kind)) { exit 1 } } + if($fields[1] -ne 'directory' -or -not(Test-MiftahAncestors ([System.IO.DirectoryInfo]::new($fields[2]).Parent))){exit 1} exit 0 } @@ -439,7 +438,7 @@ try { $directoryParent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($directory.FullName)) if ([string]::IsNullOrEmpty($directoryParent)) { exit 1 } if (-not [string]::Equals($parentPath, $directoryParent.TrimEnd($trimCharacters), [System.StringComparison]::OrdinalIgnoreCase)) { exit 1 } - if (-not (Test-MiftahPrivatePath $parent 'directory')) { exit 1 } + if(-not(Test-MiftahPrivatePath $parent 'directory') -or -not(Test-MiftahAncestors $parent.Parent)){exit 1} } if ($directory.Exists) { exit 1 } $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User diff --git a/src/setup/setup-draft.ts b/src/setup/setup-draft.ts index 152ca5d..6251e9d 100644 --- a/src/setup/setup-draft.ts +++ b/src/setup/setup-draft.ts @@ -441,9 +441,14 @@ export class FileSetupDraftStore implements SetupDraftStore { const observed = await privateFileMetadata(this.path, false); if (observed === undefined) { // Returning an absent draft still influences which setup path the caller - // may take. Keep the directory's original fail-closed verification even - // though there is no file to include in a batch yet. - if (!(await verifyWindowsConfigPathSecurity(this.directory, "directory"))) setupDraftUnavailable(); + // may take. Re-establish the parent-protected directory boundary even + // though there is no file to include in the batch yet. + if (!(await verifyWindowsConfigPathsSecurity([ + { path: dirname(this.directory), kind: "directory" }, + { path: this.directory, kind: "directory" } + ]))) { + setupDraftUnavailable(); + } const verifiedDirectory = await privateDirectoryMetadata(this.directory, false); if (verifiedDirectory === undefined || !sameFileIdentity(directory, verifiedDirectory)) setupDraftUnavailable(); return undefined; diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index dbca099..96c552a 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -203,8 +203,8 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.createPrivateDirectory).not.toHaveBeenCalled(); expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledTimes(2); expect(aclMocks.secureFile).toHaveBeenCalledTimes(2); - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(6); - expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(2); + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(5); + expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(3); }); it("attributes Windows ACL helper launches across the complete first-run draft lifecycle", async () => { @@ -259,8 +259,8 @@ describe("Console Windows first-run boundary", () => { afterSave: { create: 0, createInParent: 1, secure: 1, verify: 0, verifyBatch: 0 }, afterLoad: { create: 0, createInParent: 1, secure: 1, verify: 0, verifyBatch: 1 }, afterPublication: { create: 0, createInParent: 2, secure: 2, verify: 3, verifyBatch: 2 }, - afterDraftRead: { create: 0, createInParent: 2, secure: 2, verify: 4, verifyBatch: 2 }, - afterConfiguredRead: { create: 0, createInParent: 2, secure: 2, verify: 6, verifyBatch: 2 } + afterDraftRead: { create: 0, createInParent: 2, secure: 2, verify: 3, verifyBatch: 3 }, + afterConfiguredRead: { create: 0, createInParent: 2, secure: 2, verify: 5, verifyBatch: 3 } }); }); @@ -287,6 +287,30 @@ describe("Console Windows first-run boundary", () => { ]); }); + it("checks the protected parent chain before accepting an absent setup draft", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-setup-draft-windows-absent-")); + temporaryDirectories.push(parent); + const directory = join(parent, "setup-draft"); + const store = new FileSetupDraftStore({ directory }); + const draft = await store.save({ + source: "connector", + name: "posthog-work", + preset: "generic", + stage: "connection" + }); + await store.discard(draft.revision); + aclMocks.verifyPath.mockClear(); + aclMocks.verifyPaths.mockClear(); + + await expect(store.load()).resolves.toBeUndefined(); + + expect(aclMocks.verifyPath).not.toHaveBeenCalled(); + expect(aclMocks.verifyPaths).toHaveBeenCalledWith([ + { path: parent, kind: "directory" }, + { path: directory, kind: "directory" } + ]); + }); + it("relies on completed Windows ACL helper verification when publishing a new draft", async () => { const parent = await mkdtemp(join(tmpdir(), "miftah-setup-draft-windows-write-")); temporaryDirectories.push(parent); diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index 2380c8f..bbf68ba 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -111,6 +111,7 @@ describe("Windows migration ACL boundary", () => { expect(command).toContain("verify-private-paths"); expect(command).toContain("for ($index = 1; $index -lt $fields.Count; $index += 2)"); expect(command).toContain("Test-MiftahPrivatePath $path $kind"); + expect(command).toContain("Test-MiftahAncestors ([System.IO.DirectoryInfo]::new($fields[2]).Parent)"); }); it("fails closed before launch for an empty or NUL-containing batch verification request", async () => { @@ -118,6 +119,10 @@ describe("Windows migration ACL boundary", () => { await expect(verifyWindowsConfigPathsSecurity([ { path: "C:\\Users\\miftah\\.config\\miftah\u0000unexpected", kind: "directory" } ])).resolves.toBe(false); + await expect(verifyWindowsConfigPathsSecurity([ + { path: "C:\\Users\\miftah\\.config\\miftah.json", kind: "file" }, + { path: "C:\\Users\\miftah\\.config\\miftah", kind: "directory" } + ])).resolves.toBe(false); expect(windowsAclMocks.spawn).not.toHaveBeenCalled(); }); @@ -217,7 +222,7 @@ describe("Windows migration ACL boundary", () => { const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); expect(command).toContain("function Test-MiftahAncestors"); expect(command).toContain("Test-MiftahPrivatePath $ancestor.FullName 'directory' $null $false $false"); - expect(command).toContain("Test-MiftahAncestors $parent"); + expect(command).toContain("Test-MiftahAncestors $parent.Parent"); }); it("keeps the encoded ACL helper below the conservative Windows command-line budget", async () => { From 272eb7243ca63ea54bb78c60eef1b1f897fc6a0a Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 18:57:27 +0400 Subject: [PATCH 14/25] test(windows): diagnose temporary ACL ancestor boundary --- tests/windows-config-migration-acl.test.ts | 119 +++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index ac942c9..48b944a 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -124,6 +124,72 @@ try { }`; const encodedUnsafeAncestorProbe = Buffer.from(unsafeAncestorProbe, "utf16le").toString("base64"); +const ancestorDiagnosticProbe = String.raw`$ErrorActionPreference = 'Stop' +$requestName = '${privateDirectoryRequestEnvironmentName}' +$sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner +function Write-Result { + param([string]$value) + [Console]::Out.Write($value) + exit 0 +} +try { + $encoded = [Environment]::GetEnvironmentVariable($requestName, [EnvironmentVariableTarget]::Process) + $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)) + [Environment]::SetEnvironmentVariable($requestName, $null, [EnvironmentVariableTarget]::Process) + if ([string]::IsNullOrEmpty($path)) { Write-Result 'probe' } + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + if ($null -eq $identity) { Write-Result 'probe' } + $trusted = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-4', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') + $restricted = [int]( + [System.Security.AccessControl.FileSystemRights]::WriteData -bor + [System.Security.AccessControl.FileSystemRights]::AppendData -bor + [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [System.Security.AccessControl.FileSystemRights]::Delete -bor + [System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor + [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor + [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [System.Security.AccessControl.FileSystemRights]::TakeOwnership + ) + $creatorOwner = 'S-1-3-0' + $creatorGroup = 'S-1-3-1' + $inheritOnly = [int][System.Security.AccessControl.PropagationFlags]::InheritOnly + $entry = [System.IO.DirectoryInfo]::new($path) + $index = 0 + while ($null -ne $entry) { + if (-not $entry.Exists) { Write-Result "missing:$index" } + $reparse = [int][System.IO.FileAttributes]::ReparsePoint + if (([int]$entry.Attributes -band $reparse) -ne 0) { Write-Result "reparse:$index" } + try { + $acl = [System.IO.Directory]::GetAccessControl($entry.FullName, $sections) + $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) + if ($null -eq $owner -or -not ($trusted -ccontains $owner.Value)) { Write-Result "owner:$index" } + $raw = [System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0) + if ($null -eq $raw.DiscretionaryAcl -or -not $acl.AreAccessRulesCanonical) { Write-Result "acl:$index" } + $rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) + if ($rules.Count -eq 0) { Write-Result "rules:$index" } + foreach ($rule in $rules) { + if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } + if ($trusted -ccontains $rule.IdentityReference.Value) { continue } + if ( + ($rule.IdentityReference.Value -ceq $creatorOwner -or $rule.IdentityReference.Value -ceq $creatorGroup) -and + (([int]$rule.PropagationFlags -band $inheritOnly) -ne 0) + ) { continue } + if (([int]$rule.FileSystemRights -band $restricted) -ne 0) { Write-Result "mutation:$index" } + } + } catch { + Write-Result "read:$index" + } + $next = $entry.Parent + if ($null -eq $next -or $next.FullName -ceq $entry.FullName) { break } + $entry = $next + $index++ + } + Write-Result 'ok' +} catch { + Write-Result 'probe' +}`; +const encodedAncestorDiagnosticProbe = Buffer.from(ancestorDiagnosticProbe, "utf16le").toString("base64"); + const privateDirectoryProbe = String.raw`[Console]::Out.Write('MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_BOOTSTRAP') $ErrorActionPreference = 'Stop' $requestName = '${privateDirectoryRequestEnvironmentName}' @@ -497,7 +563,60 @@ async function windowsCopyFileSecurityProbe( }); } +async function windowsAncestorDiagnostic(directory: string): Promise { + const request = Buffer.from(directory, "utf8").toString("base64"); + return new Promise((resolve, reject) => { + const child = spawn( + trustedPowerShellExecutable(), + ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedAncestorDiagnosticProbe], + { env: restrictedAclEnvironment(request), shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] } + ); + const output: Buffer[] = []; + const timeout = setTimeout(() => { + try { + child.kill(); + } catch { + // The diagnostic probe has no verified result after its bounded execution time. + } + reject(new Error("Windows ancestor diagnostic probe timed out")); + }, 5_000); + child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); + child.once("error", () => { + clearTimeout(timeout); + reject(new Error("Windows ancestor diagnostic probe could not start")); + }); + child.once("close", (code) => { + clearTimeout(timeout); + if (code !== 0) { + reject(new Error("Windows ancestor diagnostic probe failed")); + return; + } + const result = Buffer.concat(output).toString("utf8"); + if (!/^(?:missing|reparse|owner|acl|rules|mutation|read):\d+$|^(?:ok|probe)$/.test(result)) { + reject(new Error("Windows ancestor diagnostic probe returned an invalid category")); + return; + } + resolve(result); + }); + }); +} + describe("Windows migration ACL contract", () => { + it.runIf(process.platform === "win32")( + "reports a redacted rejection category for a temporary configuration ancestor chain", + async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-windows-ancestor-diagnostic-")); + temporaryDirectories.push(root); + const privateParent = join(root, "private-parent"); + await windowsPrivateDirectoryProbe(privateParent); + + const category = await windowsAncestorDiagnostic(root); + console.info(`MIFTAH_WINDOWS_ANCESTOR_DIAGNOSTIC:${category}`); + expect(category).toMatch(/^(?:missing|reparse|owner|acl|rules|mutation|read):\d+$/); + }, + 10_000 + ); + it.runIf(process.platform === "win32")( "rejects a private child whose grandparent lets untrusted users replace its parent", async () => { From 5affcb3823f8c63f80b0ec79692a62fe096b7a82 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 19:06:04 +0400 Subject: [PATCH 15/25] test(windows): anchor Console first-run fixtures --- tests/console-application-service.test.ts | 23 +++++++++++-------- ...sole-dashboard-application-service.test.ts | 11 +++++---- tests/console-server.test.ts | 5 ++-- tests/helpers/private-console-directory.ts | 12 +++++++++- tests/windows-config-migration-acl.test.ts | 14 +++++++++++ 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index 3995455..282907e 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -18,7 +18,10 @@ import { } from "../src/console/console-config-catalog.js"; import { verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; import { MiftahError } from "../src/utils/errors.js"; -import { createPrivateConsoleDirectory } from "./helpers/private-console-directory.js"; +import { + createPrivateConsoleDirectory, + createPrivateConsoleTestRoot +} from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { createMemoryProfileRenameOAuthDependencies } from "./helpers/profile-rename-oauth-dependencies.js"; import { @@ -178,7 +181,7 @@ describe("Console application service", () => { }); it("creates a validated first-run native OAuth profile and connection without accepting secret material", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-first-run-")); + const root = await createPrivateConsoleTestRoot("miftah-console-first-run-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -260,7 +263,7 @@ describe("Console application service", () => { }); it("discovers a first-run native OAuth binding from the endpoint before writing the configuration", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-discovered-native-oauth-")); + const root = await createPrivateConsoleTestRoot("miftah-console-discovered-native-oauth-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -455,7 +458,7 @@ describe("Console application service", () => { }); it("creates a first-run known connector through the shared preset setup path", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-preset-")); + const root = await createPrivateConsoleTestRoot("miftah-console-preset-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -488,7 +491,7 @@ describe("Console application service", () => { }); it("previews a first-run connector without writing configuration or audit state", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-preset-preview-")); + const root = await createPrivateConsoleTestRoot("miftah-console-preset-preview-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -550,7 +553,7 @@ describe("Console application service", () => { }); it("creates an explicitly acknowledged local stdio configuration through the same preset path", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-local-stdio-")); + const root = await createPrivateConsoleTestRoot("miftah-console-local-stdio-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -583,7 +586,7 @@ describe("Console application service", () => { }); it("creates a first-run configuration from one explicitly selected local stdio client entry without accepting credentials", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-")); + const root = await createPrivateConsoleTestRoot("miftah-console-client-entry-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -617,7 +620,7 @@ describe("Console application service", () => { }); it("creates a first-run configuration from one explicitly selected HTTPS remote client entry without OAuth discovery", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-remote-")); + const root = await createPrivateConsoleTestRoot("miftah-console-client-entry-remote-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -652,7 +655,7 @@ describe("Console application service", () => { }); it("gives a bounded advanced-manual recovery code for an entry outside the static launch grammar", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-static-launch-")); + const root = await createPrivateConsoleTestRoot("miftah-console-client-entry-static-launch-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -677,7 +680,7 @@ describe("Console application service", () => { }); it("creates the same multi-account GSC configuration as the guided CLI path", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-gsc-preset-")); + const root = await createPrivateConsoleTestRoot("miftah-console-gsc-preset-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "gsc.json"); diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 37aa4b3..1b2de7a 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -15,6 +15,7 @@ import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; import { MiftahError } from "../src/utils/errors.js"; import { createPrivateConsoleDirectory, + createPrivateConsoleTestRoot, writePrivateConsoleFile } from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; @@ -380,7 +381,7 @@ describe("Console dashboard application service", () => { }); it("creates a first known connector through the same dashboard setup boundary", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-preset-")); + const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-preset-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -404,7 +405,7 @@ describe("Console dashboard application service", () => { }); it("shares only a safe connector draft during first run and clears it after configuration publication", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-")); + const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-draft-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -441,7 +442,7 @@ describe("Console dashboard application service", () => { }); it("keeps a published first-run configuration when cached draft cleanup conflicts", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-cleanup-")); + const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-draft-cleanup-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -515,7 +516,7 @@ describe("Console dashboard application service", () => { }); it("previews a first known connector without selecting or creating a configuration", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-preset-preview-")); + const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-preset-preview-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -543,7 +544,7 @@ describe("Console dashboard application service", () => { }); it("creates one selected local stdio entry only through the first-run dashboard boundary", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-client-entry-")); + const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-client-entry-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index a434731..e18b303 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -15,6 +15,7 @@ import { MiftahError } from "../src/utils/errors.js"; import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; import { createPrivateConsoleDirectory, + createPrivateConsoleTestRoot, writePrivateConsoleFile } from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; @@ -1783,7 +1784,7 @@ describe("local Console control server", () => { }); it("serves a CSRF-protected first-run connector draft without accepting connection data", async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-draft-http-")); + const root = await createPrivateConsoleTestRoot("miftah-console-draft-http-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -1898,7 +1899,7 @@ describe("local Console control server", () => { let configPath: string; beforeEach(async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-")); + const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-"); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); // The endpoint must create this directory itself: the Windows helper diff --git a/tests/helpers/private-console-directory.ts b/tests/helpers/private-console-directory.ts index 0e27158..53b2c49 100644 --- a/tests/helpers/private-console-directory.ts +++ b/tests/helpers/private-console-directory.ts @@ -1,4 +1,5 @@ -import { chmod, mkdir, open, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, open, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { createWindowsPrivateDirectory, @@ -28,6 +29,15 @@ export async function createPrivateConsoleDirectory(parent: string, name = "conf return directory; } +/** + * Creates a disposable root for tests that exercise Windows first-run creation. + * The system temporary hierarchy is mutable by untrusted principals on hosted + * Windows runners, so it cannot model a protected configuration ancestor chain. + */ +export async function createPrivateConsoleTestRoot(prefix: string): Promise { + return mkdtemp(join(process.platform === "win32" ? homedir() : tmpdir(), prefix)); +} + /** Writes a fresh fixture file only after its Windows ACL is private and verified. */ export async function writePrivateConsoleFile(path: string, content: string): Promise { if (process.platform !== "win32") { diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index 48b944a..4b87197 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -9,6 +9,7 @@ import { createWindowsPrivateDirectoryInPrivateParent, verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; +import { createPrivateConsoleTestRoot } from "./helpers/private-console-directory.js"; const requestEnvironmentName = "MIFTAH_TEST_CONFIG_ACL_REQUEST"; const privateDirectoryRequestEnvironmentName = "MIFTAH_TEST_PRIVATE_DIRECTORY_ACL_REQUEST"; @@ -602,6 +603,19 @@ async function windowsAncestorDiagnostic(directory: string): Promise { } describe("Windows migration ACL contract", () => { + it.runIf(process.platform === "win32")( + "accepts a first-run child under a current-user-profile ancestor chain", + async () => { + const root = await createPrivateConsoleTestRoot("miftah-windows-profile-ancestor-"); + temporaryDirectories.push(root); + const privateParent = join(root, "private-parent"); + await windowsPrivateDirectoryProbe(privateParent); + + await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, join(privateParent, "miftah"))).resolves.toBe(true); + }, + 10_000 + ); + it.runIf(process.platform === "win32")( "reports a redacted rejection category for a temporary configuration ancestor chain", async () => { From 03f0cd1220cc5a1464df77f2472fab2ac82cdf90 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 19:08:58 +0400 Subject: [PATCH 16/25] test(windows): diagnose profile ancestor boundary --- tests/windows-config-migration-acl.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index 4b87197..68cb8e6 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -608,6 +608,9 @@ describe("Windows migration ACL contract", () => { async () => { const root = await createPrivateConsoleTestRoot("miftah-windows-profile-ancestor-"); temporaryDirectories.push(root); + const category = await windowsAncestorDiagnostic(root); + console.info(`MIFTAH_WINDOWS_PROFILE_ANCESTOR_DIAGNOSTIC:${category}`); + expect(category).toBe("ok"); const privateParent = join(root, "private-parent"); await windowsPrivateDirectoryProbe(privateParent); From ac4b8458c7a7063c12829893deb9c1da68dba295 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 19:13:13 +0400 Subject: [PATCH 17/25] test(windows): classify ancestor mutation rights --- tests/windows-config-migration-acl.test.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index 68cb8e6..474be1e 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -141,16 +141,6 @@ try { $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User if ($null -eq $identity) { Write-Result 'probe' } $trusted = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-4', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') - $restricted = [int]( - [System.Security.AccessControl.FileSystemRights]::WriteData -bor - [System.Security.AccessControl.FileSystemRights]::AppendData -bor - [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor - [System.Security.AccessControl.FileSystemRights]::Delete -bor - [System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor - [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor - [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor - [System.Security.AccessControl.FileSystemRights]::TakeOwnership - ) $creatorOwner = 'S-1-3-0' $creatorGroup = 'S-1-3-1' $inheritOnly = [int][System.Security.AccessControl.PropagationFlags]::InheritOnly @@ -175,7 +165,13 @@ try { ($rule.IdentityReference.Value -ceq $creatorOwner -or $rule.IdentityReference.Value -ceq $creatorGroup) -and (([int]$rule.PropagationFlags -band $inheritOnly) -ne 0) ) { continue } - if (([int]$rule.FileSystemRights -band $restricted) -ne 0) { Write-Result "mutation:$index" } + $rights = [int]$rule.FileSystemRights + if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles) -ne 0) { Write-Result "delete-child:$index" } + if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::Delete) -ne 0) { Write-Result "delete:$index" } + if (($rights -band [int]([System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership)) -ne 0) { Write-Result "ownership:$index" } + if (($rights -band [int]([System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes)) -ne 0) { Write-Result "attributes:$index" } + if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::AppendData) -ne 0) { Write-Result "create-child:$index" } + if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::WriteData) -ne 0) { Write-Result "create-file:$index" } } } catch { Write-Result "read:$index" @@ -593,7 +589,7 @@ async function windowsAncestorDiagnostic(directory: string): Promise { return; } const result = Buffer.concat(output).toString("utf8"); - if (!/^(?:missing|reparse|owner|acl|rules|mutation|read):\d+$|^(?:ok|probe)$/.test(result)) { + if (!/^(?:missing|reparse|owner|acl|rules|delete-child|delete|ownership|attributes|create-child|create-file|read):\d+$|^(?:ok|probe)$/.test(result)) { reject(new Error("Windows ancestor diagnostic probe returned an invalid category")); return; } @@ -629,7 +625,7 @@ describe("Windows migration ACL contract", () => { const category = await windowsAncestorDiagnostic(root); console.info(`MIFTAH_WINDOWS_ANCESTOR_DIAGNOSTIC:${category}`); - expect(category).toMatch(/^(?:missing|reparse|owner|acl|rules|mutation|read):\d+$/); + expect(category).toMatch(/^(?:missing|reparse|owner|acl|rules|delete-child|delete|ownership|attributes|create-child|create-file|read):\d+$/); }, 10_000 ); From eca64503dc25626aab354a764f1a6e7d5fca4b4c Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 19:22:25 +0400 Subject: [PATCH 18/25] fix(windows): exclude terminal volume root from ACL chain --- src/cli/windows-config-acl.ts | 4 +- tests/console-application-service.test.ts | 23 ++-- ...sole-dashboard-application-service.test.ts | 11 +- tests/console-server.test.ts | 5 +- tests/helpers/private-console-directory.ts | 12 +- tests/windows-config-migration-acl.test.ts | 123 +----------------- 6 files changed, 22 insertions(+), 156 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index 5190bd4..af271a2 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -90,7 +90,7 @@ export async function createWindowsPrivateDirectory(directory: string): Promise< /** * Verifies an immediate private parent and creates the child under its own * current-user-only descriptor in one trusted helper invocation. The helper - * rejects a non-child path, reparse point, unsafe parent, or failed creation. + * rejects a non-child path, reparse point, unsafe replaceable ancestor, or failed creation. */ export async function createWindowsPrivateDirectoryInPrivateParent( parent: string, @@ -334,9 +334,9 @@ function Test-MiftahPrivatePath { function Test-MiftahAncestors { param($ancestor) while ($null -ne $ancestor) { - if(-not(Test-MiftahPrivatePath $ancestor.FullName 'directory' $null $false $false)){return $false} $next = $ancestor.Parent if($null -eq $next -or $next.FullName -ceq $ancestor.FullName){break} + if(-not(Test-MiftahPrivatePath $ancestor.FullName 'directory' $null $false $false)){return $false} $ancestor = $next } return $true diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index 282907e..3995455 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -18,10 +18,7 @@ import { } from "../src/console/console-config-catalog.js"; import { verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; import { MiftahError } from "../src/utils/errors.js"; -import { - createPrivateConsoleDirectory, - createPrivateConsoleTestRoot -} from "./helpers/private-console-directory.js"; +import { createPrivateConsoleDirectory } from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { createMemoryProfileRenameOAuthDependencies } from "./helpers/profile-rename-oauth-dependencies.js"; import { @@ -181,7 +178,7 @@ describe("Console application service", () => { }); it("creates a validated first-run native OAuth profile and connection without accepting secret material", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-first-run-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-first-run-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -263,7 +260,7 @@ describe("Console application service", () => { }); it("discovers a first-run native OAuth binding from the endpoint before writing the configuration", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-discovered-native-oauth-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-discovered-native-oauth-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -458,7 +455,7 @@ describe("Console application service", () => { }); it("creates a first-run known connector through the shared preset setup path", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-preset-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-preset-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -491,7 +488,7 @@ describe("Console application service", () => { }); it("previews a first-run connector without writing configuration or audit state", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-preset-preview-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-preset-preview-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -553,7 +550,7 @@ describe("Console application service", () => { }); it("creates an explicitly acknowledged local stdio configuration through the same preset path", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-local-stdio-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-local-stdio-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -586,7 +583,7 @@ describe("Console application service", () => { }); it("creates a first-run configuration from one explicitly selected local stdio client entry without accepting credentials", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-client-entry-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -620,7 +617,7 @@ describe("Console application service", () => { }); it("creates a first-run configuration from one explicitly selected HTTPS remote client entry without OAuth discovery", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-client-entry-remote-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-remote-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -655,7 +652,7 @@ describe("Console application service", () => { }); it("gives a bounded advanced-manual recovery code for an entry outside the static launch grammar", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-client-entry-static-launch-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-static-launch-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); @@ -680,7 +677,7 @@ describe("Console application service", () => { }); it("creates the same multi-account GSC configuration as the guided CLI path", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-gsc-preset-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-gsc-preset-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "gsc.json"); diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 1b2de7a..37aa4b3 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -15,7 +15,6 @@ import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; import { MiftahError } from "../src/utils/errors.js"; import { createPrivateConsoleDirectory, - createPrivateConsoleTestRoot, writePrivateConsoleFile } from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; @@ -381,7 +380,7 @@ describe("Console dashboard application service", () => { }); it("creates a first known connector through the same dashboard setup boundary", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-preset-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-preset-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -405,7 +404,7 @@ describe("Console dashboard application service", () => { }); it("shares only a safe connector draft during first run and clears it after configuration publication", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-draft-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -442,7 +441,7 @@ describe("Console dashboard application service", () => { }); it("keeps a published first-run configuration when cached draft cleanup conflicts", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-draft-cleanup-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-cleanup-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -516,7 +515,7 @@ describe("Console dashboard application service", () => { }); it("previews a first known connector without selecting or creating a configuration", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-preset-preview-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-preset-preview-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -544,7 +543,7 @@ describe("Console dashboard application service", () => { }); it("creates one selected local stdio entry only through the first-run dashboard boundary", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-client-entry-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-client-entry-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index e18b303..a434731 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -15,7 +15,6 @@ import { MiftahError } from "../src/utils/errors.js"; import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; import { createPrivateConsoleDirectory, - createPrivateConsoleTestRoot, writePrivateConsoleFile } from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; @@ -1784,7 +1783,7 @@ describe("local Console control server", () => { }); it("serves a CSRF-protected first-run connector draft without accepting connection data", async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-draft-http-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-draft-http-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const directory = join(privateParent, "miftah"); @@ -1899,7 +1898,7 @@ describe("local Console control server", () => { let configPath: string; beforeEach(async () => { - const root = await createPrivateConsoleTestRoot("miftah-console-dashboard-"); + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); // The endpoint must create this directory itself: the Windows helper diff --git a/tests/helpers/private-console-directory.ts b/tests/helpers/private-console-directory.ts index 53b2c49..0e27158 100644 --- a/tests/helpers/private-console-directory.ts +++ b/tests/helpers/private-console-directory.ts @@ -1,5 +1,4 @@ -import { chmod, mkdir, mkdtemp, open, writeFile } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; +import { chmod, mkdir, open, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createWindowsPrivateDirectory, @@ -29,15 +28,6 @@ export async function createPrivateConsoleDirectory(parent: string, name = "conf return directory; } -/** - * Creates a disposable root for tests that exercise Windows first-run creation. - * The system temporary hierarchy is mutable by untrusted principals on hosted - * Windows runners, so it cannot model a protected configuration ancestor chain. - */ -export async function createPrivateConsoleTestRoot(prefix: string): Promise { - return mkdtemp(join(process.platform === "win32" ? homedir() : tmpdir(), prefix)); -} - /** Writes a fresh fixture file only after its Windows ACL is private and verified. */ export async function writePrivateConsoleFile(path: string, content: string): Promise { if (process.platform !== "win32") { diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index 474be1e..193f17a 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -9,7 +9,6 @@ import { createWindowsPrivateDirectoryInPrivateParent, verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; -import { createPrivateConsoleTestRoot } from "./helpers/private-console-directory.js"; const requestEnvironmentName = "MIFTAH_TEST_CONFIG_ACL_REQUEST"; const privateDirectoryRequestEnvironmentName = "MIFTAH_TEST_PRIVATE_DIRECTORY_ACL_REQUEST"; @@ -125,68 +124,6 @@ try { }`; const encodedUnsafeAncestorProbe = Buffer.from(unsafeAncestorProbe, "utf16le").toString("base64"); -const ancestorDiagnosticProbe = String.raw`$ErrorActionPreference = 'Stop' -$requestName = '${privateDirectoryRequestEnvironmentName}' -$sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner -function Write-Result { - param([string]$value) - [Console]::Out.Write($value) - exit 0 -} -try { - $encoded = [Environment]::GetEnvironmentVariable($requestName, [EnvironmentVariableTarget]::Process) - $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)) - [Environment]::SetEnvironmentVariable($requestName, $null, [EnvironmentVariableTarget]::Process) - if ([string]::IsNullOrEmpty($path)) { Write-Result 'probe' } - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User - if ($null -eq $identity) { Write-Result 'probe' } - $trusted = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-4', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') - $creatorOwner = 'S-1-3-0' - $creatorGroup = 'S-1-3-1' - $inheritOnly = [int][System.Security.AccessControl.PropagationFlags]::InheritOnly - $entry = [System.IO.DirectoryInfo]::new($path) - $index = 0 - while ($null -ne $entry) { - if (-not $entry.Exists) { Write-Result "missing:$index" } - $reparse = [int][System.IO.FileAttributes]::ReparsePoint - if (([int]$entry.Attributes -band $reparse) -ne 0) { Write-Result "reparse:$index" } - try { - $acl = [System.IO.Directory]::GetAccessControl($entry.FullName, $sections) - $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) - if ($null -eq $owner -or -not ($trusted -ccontains $owner.Value)) { Write-Result "owner:$index" } - $raw = [System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0) - if ($null -eq $raw.DiscretionaryAcl -or -not $acl.AreAccessRulesCanonical) { Write-Result "acl:$index" } - $rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) - if ($rules.Count -eq 0) { Write-Result "rules:$index" } - foreach ($rule in $rules) { - if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } - if ($trusted -ccontains $rule.IdentityReference.Value) { continue } - if ( - ($rule.IdentityReference.Value -ceq $creatorOwner -or $rule.IdentityReference.Value -ceq $creatorGroup) -and - (([int]$rule.PropagationFlags -band $inheritOnly) -ne 0) - ) { continue } - $rights = [int]$rule.FileSystemRights - if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles) -ne 0) { Write-Result "delete-child:$index" } - if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::Delete) -ne 0) { Write-Result "delete:$index" } - if (($rights -band [int]([System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership)) -ne 0) { Write-Result "ownership:$index" } - if (($rights -band [int]([System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes)) -ne 0) { Write-Result "attributes:$index" } - if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::AppendData) -ne 0) { Write-Result "create-child:$index" } - if (($rights -band [int][System.Security.AccessControl.FileSystemRights]::WriteData) -ne 0) { Write-Result "create-file:$index" } - } - } catch { - Write-Result "read:$index" - } - $next = $entry.Parent - if ($null -eq $next -or $next.FullName -ceq $entry.FullName) { break } - $entry = $next - $index++ - } - Write-Result 'ok' -} catch { - Write-Result 'probe' -}`; -const encodedAncestorDiagnosticProbe = Buffer.from(ancestorDiagnosticProbe, "utf16le").toString("base64"); - const privateDirectoryProbe = String.raw`[Console]::Out.Write('MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_BOOTSTRAP') $ErrorActionPreference = 'Stop' $requestName = '${privateDirectoryRequestEnvironmentName}' @@ -560,53 +497,12 @@ async function windowsCopyFileSecurityProbe( }); } -async function windowsAncestorDiagnostic(directory: string): Promise { - const request = Buffer.from(directory, "utf8").toString("base64"); - return new Promise((resolve, reject) => { - const child = spawn( - trustedPowerShellExecutable(), - ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedAncestorDiagnosticProbe], - { env: restrictedAclEnvironment(request), shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] } - ); - const output: Buffer[] = []; - const timeout = setTimeout(() => { - try { - child.kill(); - } catch { - // The diagnostic probe has no verified result after its bounded execution time. - } - reject(new Error("Windows ancestor diagnostic probe timed out")); - }, 5_000); - child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); - child.once("error", () => { - clearTimeout(timeout); - reject(new Error("Windows ancestor diagnostic probe could not start")); - }); - child.once("close", (code) => { - clearTimeout(timeout); - if (code !== 0) { - reject(new Error("Windows ancestor diagnostic probe failed")); - return; - } - const result = Buffer.concat(output).toString("utf8"); - if (!/^(?:missing|reparse|owner|acl|rules|delete-child|delete|ownership|attributes|create-child|create-file|read):\d+$|^(?:ok|probe)$/.test(result)) { - reject(new Error("Windows ancestor diagnostic probe returned an invalid category")); - return; - } - resolve(result); - }); - }); -} - describe("Windows migration ACL contract", () => { it.runIf(process.platform === "win32")( - "accepts a first-run child under a current-user-profile ancestor chain", + "accepts a private child when only the terminal volume root grants DELETE", async () => { - const root = await createPrivateConsoleTestRoot("miftah-windows-profile-ancestor-"); + const root = await mkdtemp(join(tmpdir(), "miftah-windows-root-delete-")); temporaryDirectories.push(root); - const category = await windowsAncestorDiagnostic(root); - console.info(`MIFTAH_WINDOWS_PROFILE_ANCESTOR_DIAGNOSTIC:${category}`); - expect(category).toBe("ok"); const privateParent = join(root, "private-parent"); await windowsPrivateDirectoryProbe(privateParent); @@ -615,21 +511,6 @@ describe("Windows migration ACL contract", () => { 10_000 ); - it.runIf(process.platform === "win32")( - "reports a redacted rejection category for a temporary configuration ancestor chain", - async () => { - const root = await mkdtemp(join(tmpdir(), "miftah-windows-ancestor-diagnostic-")); - temporaryDirectories.push(root); - const privateParent = join(root, "private-parent"); - await windowsPrivateDirectoryProbe(privateParent); - - const category = await windowsAncestorDiagnostic(root); - console.info(`MIFTAH_WINDOWS_ANCESTOR_DIAGNOSTIC:${category}`); - expect(category).toMatch(/^(?:missing|reparse|owner|acl|rules|delete-child|delete|ownership|attributes|create-child|create-file|read):\d+$/); - }, - 10_000 - ); - it.runIf(process.platform === "win32")( "rejects a private child whose grandparent lets untrusted users replace its parent", async () => { From a7a433efa207075441da23a220ebc43f0ed81ba5 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 19:48:25 +0400 Subject: [PATCH 19/25] fix(windows): recheck first-run directory before write --- src/console/console-application-service.ts | 22 ++++++++++++-- tests/console-windows-first-run.test.ts | 34 +++++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index 9f51a07..787bf0c 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir } from "node:fs/promises"; +import { lstat, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; import { readAuditJsonl } from "../cli/audit-jsonl.js"; @@ -414,6 +414,19 @@ export function consoleAuditPath(configPath: string): string { return join(dirname(resolvePath(configPath)), ".miftah", "audit", "console.jsonl"); } +/** + * Rechecks the just-created final directory without following a replacement + * symlink before the first pathname-based configuration write. + */ +async function isStableWindowsCreatedDirectory(directory: string): Promise { + try { + const metadata = await lstat(directory); + return metadata.isDirectory() && !metadata.isSymbolicLink(); + } catch { + return false; + } +} + async function ensureFirstRunConfigDirectory(configPath: string): Promise { const directory = dirname(resolvePath(configPath)); try { @@ -425,9 +438,14 @@ async function ensureFirstRunConfigDirectory(configPath: string): Promise await mkdir(parent, { recursive: true, mode: 0o700 }); const created = await createWindowsPrivateDirectoryInPrivateParent(parent, directory); // The trusted creator applies and verifies the private owner/DACL/reparse - // boundary, including its parent, before it reports success. A failed + // boundary, including its parent, before it reports success. Retain a + // no-follow metadata check before the first pathname-based write so a + // replacement directory cannot turn into a reparse point in that gap. A failed // exclusive create may be a safe concurrent Miftah creation, so both path // components need an independent verifier before that race is accepted. + if (created && !(await isStableWindowsCreatedDirectory(directory))) { + throw new Error("created configuration directory changed before first write"); + } if (!created && !(await verifyWindowsConfigPathsSecurity([ { path: parent, kind: "directory" }, { path: directory, kind: "directory" } diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index 96c552a..09df5e2 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -95,6 +95,38 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.secureFile).toHaveBeenCalledWith(configPath); }); + it("fails closed if a created config directory becomes a symlink before the first write", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-swap-")); + temporaryDirectories.push(parent); + const configDirectory = join(parent, "miftah"); + const redirectedDirectory = join(parent, "redirected"); + const configPath = join(configDirectory, "miftah.json"); + let swapStage = "not-started"; + await mkdir(redirectedDirectory); + aclMocks.createPrivateDirectoryInParent.mockImplementationOnce(async (_parent, directory) => { + await mkdir(directory); + swapStage = "created"; + await rm(directory, { recursive: true }); + swapStage = "removed"; + await symlink(redirectedDirectory, directory); + swapStage = "symlinked"; + return true; + }); + const service = new ConsoleDashboardApplicationService({ configDirectory, defaultConfigPath: configPath }); + + await expect(service.onboardNativeOAuth({ + name: "first-run", + profile: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid"] + })).rejects.toMatchObject({ code: "CONFIG_CREATE_FAILED" }); + + expect(swapStage).toBe("symlinked"); + await expect(readFile(join(redirectedDirectory, "miftah.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("fully verifies a first-run config directory created by another Windows process", async () => { const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-race-")); temporaryDirectories.push(parent); From 4863ffe6eebbdd7094ea408c87fc42b8b6203b51 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 20:25:27 +0400 Subject: [PATCH 20/25] fix(windows): bind first-run config write --- src/cli/windows-config-acl.ts | 270 ++++++++++++++++++++- src/console/console-application-service.ts | 35 +-- src/setup/setup-configuration.ts | 16 ++ tests/console-windows-first-run.test.ts | 77 +++++- tests/windows-config-acl.test.ts | 60 +++++ tests/windows-config-migration-acl.test.ts | 23 +- 6 files changed, 441 insertions(+), 40 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index af271a2..dd24904 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -38,6 +38,12 @@ interface SecurePrivateFileRequest { readonly path: string; } +interface WritePrivateFileRequest { + readonly operation: "write-private-file"; + readonly path: string; + readonly byteLength: number; +} + interface VerifyPrivatePathRequest { readonly operation: "verify-private-path"; readonly kind: "file" | "directory"; @@ -63,6 +69,8 @@ type WindowsConfigAclRequest = | VerifyPrivatePathRequest | VerifyPrivatePathsRequest; +export type WindowsPrivateConfigWriteResult = "written" | "exists" | "failed"; + /** * Copies the source file's non-null owner, group, and DACL, then verifies its * persisted access rules after a selected-section reread. Audit/SACL data is not claimed. @@ -104,6 +112,32 @@ export async function secureWindowsConfigFile(path: string): Promise { return runWindowsAclRequest({ operation: "secure-private-file", path }); } +/** + * Creates one new private configuration file while holding every verified + * Windows path component without delete sharing. The helper receives the + * bytes on stdin; neither configuration content nor paths are emitted. + */ +export async function writeWindowsPrivateConfigFile( + path: string, + content: string | Uint8Array +): Promise { + if (process.platform !== "win32") return "failed"; + const launcher = trustedPowerShellExecutable(); + const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content); + const request: WritePrivateFileRequest = { + operation: "write-private-file", + path, + byteLength: bytes.byteLength + }; + if (launcher === undefined || requestHasNul(request) || !Number.isSafeInteger(request.byteLength)) { + return "failed"; + } + const encodedRequest = encodePrivateFileWriteRequest(request); + return encodedRequest === undefined + ? "failed" + : runWindowsPrivateFileWriteCommand(launcher, encodedRequest, bytes); +} + /** Backwards-compatible name for migration transaction directories. */ export async function createWindowsPrivateMigrationDirectory(directory: string): Promise { return createWindowsPrivateDirectory(directory); @@ -152,7 +186,7 @@ async function runWindowsAclRequest(request: WindowsConfigAclRequest): Promise path.path.includes("\u0000")); if (request.operation === "create-private-directory") return request.directory.includes("\u0000"); + if (request.operation === "write-private-file") return request.path.includes("\u0000"); return request.path.includes("\u0000"); } @@ -188,6 +223,14 @@ function encodeRequest(request: WindowsConfigAclRequest): string | undefined { : undefined; } +function encodePrivateFileWriteRequest(request: WritePrivateFileRequest): string | undefined { + const payload = [request.operation, request.path, String(request.byteLength)].join("\u0000"); + const bytes = Buffer.from(payload, "utf8"); + return bytes.byteLength <= maximumRequestBytes && bytes.toString("utf8") === payload + ? bytes.toString("base64") + : undefined; +} + function trustedPowerShellExecutable(): string | undefined { const executable = win32.join( trustedWindowsRoot, @@ -252,6 +295,72 @@ function runWindowsAclCommand(launcher: string, request: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(launcher, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedPrivateFileWriteCommand], { + env: aclEnvironment(request), + shell: false, + windowsHide: true, + stdio: ["pipe", "ignore", "ignore"] + }); + } catch { + resolve("failed"); + return; + } + + let finished = false; + const finish = (result: WindowsPrivateConfigWriteResult): void => { + if (finished) return; + finished = true; + clearTimeout(timeout); + resolve(result); + }; + const timeout = setTimeout(() => { + try { + child.kill(); + } catch { + // A launcher that already exited has no verified result to trust. + } + finish("failed"); + }, aclCommandTimeoutMs); + child.once("error", () => finish("failed")); + child.once("close", (code) => finish(code === 0 ? "written" : code === 2 ? "exists" : "failed")); + if (child.stdin === null) { + try { + child.kill(); + } catch { + // The close handler will settle the unverified helper. + } + finish("failed"); + return; + } + child.stdin.once("error", () => { + try { + child.kill(); + } catch { + // The close handler will settle the unverified helper. + } + finish("failed"); + }); + try { + child.stdin.end(content); + } catch { + try { + child.kill(); + } catch { + // The close handler will settle the unverified helper. + } + finish("failed"); + } + }); +} + const aclCommand = String.raw`$ErrorActionPreference = 'Stop' $requestName = '${requestEnvironmentName}' $accessSections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Group @@ -467,3 +576,162 @@ try { }`; const encodedAclCommand = Buffer.from(aclCommand, "utf16le").toString("base64"); + +/** + * This command is intentionally separate from the general ACL helper: that + * command is already near the Windows command-line limit. It holds every + * directory from the volume/share root through the target directory without + * FILE_SHARE_DELETE, checks the held path, then streams one exclusive file + * write from stdin before releasing those handles. + */ +const privateFileWriteCommand = String.raw`$ErrorActionPreference = 'Stop' +$requestName = '${requestEnvironmentName}' +$sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner +Add-Type -TypeDefinition @' +using System; +using Microsoft.Win32.SafeHandles; +using System.Runtime.InteropServices; +public static class MiftahPrivateConfigWrite { + [StructLayout(LayoutKind.Sequential)] + private struct FileInformation { + public uint Attributes; + public System.Runtime.InteropServices.ComTypes.FILETIME Creation; + public System.Runtime.InteropServices.ComTypes.FILETIME Access; + public System.Runtime.InteropServices.ComTypes.FILETIME Write; + public uint VolumeSerial; + public uint SizeHigh; + public uint SizeLow; + public uint Links; + public uint IndexHigh; + public uint IndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out FileInformation information); + public static SafeFileHandle OpenDirectory(string path) { + SafeFileHandle handle = CreateFile(path, 0x00000080, 0x00000003, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero); + if (handle == null || handle.IsInvalid) return null; + FileInformation information; + if (!GetFileInformationByHandle(handle, out information) || + (information.Attributes & 0x00000410) != 0x00000010) { + handle.Dispose(); + return null; + } + return handle; + } +} +'@ +function Test-MiftahHeldPath { + param([string]$path, [string]$kind, [bool]$requireOwner, [bool]$requireProtected) + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + if ($null -eq $identity) { return $false } + $entry = if ($kind -eq 'file') { [System.IO.FileInfo]::new($path) } else { [System.IO.DirectoryInfo]::new($path) } + if (-not $entry.Exists) { return $false } + $acl = if ($kind -eq 'file') { [System.IO.File]::GetAccessControl($path, $sections) } else { [System.IO.Directory]::GetAccessControl($path, $sections) } + $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) + $trusted = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-4', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') + if ($null -eq $owner -or -not ($trusted -ccontains $owner.Value) -or ($requireOwner -and $owner.Value -cne $identity.Value)) { return $false } + $raw = [System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0) + if ($null -eq $raw.DiscretionaryAcl -or -not $acl.AreAccessRulesCanonical -or ($requireProtected -and -not $acl.AreAccessRulesProtected)) { return $false } + $restricted = if ($kind -eq 'file') { + [int]([System.Security.AccessControl.FileSystemRights]::ReadData -bor [System.Security.AccessControl.FileSystemRights]::ReadExtendedAttributes -bor [System.Security.AccessControl.FileSystemRights]::ReadAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership) + } else { + [int]([System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership) + } + $inheritOnly = [int][System.Security.AccessControl.PropagationFlags]::InheritOnly + foreach ($rule in @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))) { + if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $trusted -ccontains $rule.IdentityReference.Value) { continue } + if (($rule.IdentityReference.Value -ceq 'S-1-3-0' -or $rule.IdentityReference.Value -ceq 'S-1-3-1') -and (([int]$rule.PropagationFlags -band $inheritOnly) -ne 0)) { continue } + if (([int]$rule.FileSystemRights -band $restricted) -ne 0) { return $false } + } + return $true +} +$result = 1 +$temporaryCreated = $false +$finalCreated = $false +$temporaryPath = $null +$stream = $null +$handles = [System.Collections.Generic.List[System.IDisposable]]::new() +try { + $encoded = [Environment]::GetEnvironmentVariable($requestName, [EnvironmentVariableTarget]::Process) + if ([string]::IsNullOrEmpty($encoded) -or $encoded.Length -gt 16384) { throw 'invalid request' } + $fields = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)).Split([char]0) + [Environment]::SetEnvironmentVariable($requestName, $null, [EnvironmentVariableTarget]::Process) + if ($fields.Count -ne 3 -or $fields[0] -ne 'write-private-file') { throw 'invalid request' } + $path = [System.IO.Path]::GetFullPath($fields[1]) + $directory = [System.IO.FileInfo]::new($path).Directory + if ($null -eq $directory) { throw 'missing directory' } + [int64]$remaining = [Convert]::ToInt64($fields[2], [Globalization.CultureInfo]::InvariantCulture) + if ($remaining -lt 0) { throw 'invalid length' } + $chain = [System.Collections.Generic.List[string]]::new() + $current = $directory + while ($null -ne $current) { + [void]$chain.Add($current.FullName) + $parent = $current.Parent + if ($null -eq $parent -or $parent.FullName -ceq $current.FullName) { break } + $current = $parent + } + $chain.Reverse() + foreach ($component in $chain) { + $handle = [MiftahPrivateConfigWrite]::OpenDirectory($component) + if ($null -eq $handle) { throw 'unsafe path component' } + [void]$handles.Add($handle) + } + if (-not (Test-MiftahHeldPath $directory.FullName 'directory' $true $true)) { throw 'unsafe directory' } + $ancestor = $directory.Parent + $firstAncestor = $true + while ($null -ne $ancestor) { + $next = $ancestor.Parent + if ($null -eq $next -or $next.FullName -ceq $ancestor.FullName) { break } + if (-not (Test-MiftahHeldPath $ancestor.FullName 'directory' $firstAncestor $false)) { throw 'unsafe ancestor' } + $firstAncestor = $false + $ancestor = $next + } + if ([System.IO.File]::Exists($path) -or [System.IO.Directory]::Exists($path)) { + $result = 2 + } else { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + if ($null -eq $identity) { throw 'missing identity' } + $security = [System.Security.AccessControl.FileSecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $security.SetOwner($identity) + $security.SetAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow)) + $temporaryPath = [System.IO.Path]::Combine($directory.FullName, "." + [System.IO.Path]::GetFileName($path) + "." + [System.Guid]::NewGuid().ToString("N") + ".tmp") + $stream = [System.IO.FileStream]::new($temporaryPath, [System.IO.FileMode]::CreateNew, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.IO.FileShare]::None, 4096, [System.IO.FileOptions]::WriteThrough, $security) + $temporaryCreated = $true + $input = [Console]::OpenStandardInput() + $buffer = [byte[]]::new(65536) + while ($remaining -gt 0) { + $count = [int][Math]::Min([int64]$buffer.Length, $remaining) + $read = $input.Read($buffer, 0, $count) + if ($read -le 0) { throw 'truncated input' } + $stream.Write($buffer, 0, $read) + $remaining -= $read + } + if ($input.ReadByte() -ne -1) { throw 'unexpected input' } + $stream.Flush($true) + $stream.Dispose() + $stream = $null + if (-not (Test-MiftahHeldPath $temporaryPath 'file' $true $true)) { throw 'unsafe temporary file' } + [System.IO.File]::Move($temporaryPath, $path) + $temporaryCreated = $false + $finalCreated = $true + if (-not (Test-MiftahHeldPath $path 'file' $true $true)) { throw 'unsafe file' } + $result = 0 + } +} catch [System.IO.IOException] { + $result = if (([int]$_.Exception.HResult -band 0xFFFF) -in @(80, 183)) { 2 } else { 1 } +} catch { + $result = 1 +} finally { + if ($null -ne $stream) { try { $stream.Dispose() } catch {} } + if ($result -ne 0 -and $temporaryCreated) { try { [System.IO.File]::Delete($temporaryPath) } catch {} } + if ($result -ne 0 -and $finalCreated) { try { [System.IO.File]::Delete($path) } catch {} } + foreach ($handle in $handles) { try { $handle.Dispose() } catch {} } +} +exit $result`; + +const encodedPrivateFileWriteCommand = Buffer.from(privateFileWriteCommand, "utf16le").toString("base64"); diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index 787bf0c..6676a4d 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { lstat, mkdir } from "node:fs/promises"; +import { mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; import { readAuditJsonl } from "../cli/audit-jsonl.js"; @@ -35,7 +35,7 @@ import { discoverNativeOAuthConnection } from "../oauth/remote-oauth-discovery.j import { createSetupConfigurationPlan, describeSetupConfiguration, - publishSetupConfigurationPlan + publishFirstRunSetupConfigurationPlan } from "../setup/setup-configuration.js"; import type { SetupConfigurationPreview } from "../setup/setup-configuration.js"; import { createSetupCompletion, type SetupCompletion } from "../setup/setup-completion.js"; @@ -414,19 +414,6 @@ export function consoleAuditPath(configPath: string): string { return join(dirname(resolvePath(configPath)), ".miftah", "audit", "console.jsonl"); } -/** - * Rechecks the just-created final directory without following a replacement - * symlink before the first pathname-based configuration write. - */ -async function isStableWindowsCreatedDirectory(directory: string): Promise { - try { - const metadata = await lstat(directory); - return metadata.isDirectory() && !metadata.isSymbolicLink(); - } catch { - return false; - } -} - async function ensureFirstRunConfigDirectory(configPath: string): Promise { const directory = dirname(resolvePath(configPath)); try { @@ -437,15 +424,10 @@ async function ensureFirstRunConfigDirectory(configPath: string): Promise const parent = dirname(directory); await mkdir(parent, { recursive: true, mode: 0o700 }); const created = await createWindowsPrivateDirectoryInPrivateParent(parent, directory); - // The trusted creator applies and verifies the private owner/DACL/reparse - // boundary, including its parent, before it reports success. Retain a - // no-follow metadata check before the first pathname-based write so a - // replacement directory cannot turn into a reparse point in that gap. A failed - // exclusive create may be a safe concurrent Miftah creation, so both path - // components need an independent verifier before that race is accepted. - if (created && !(await isStableWindowsCreatedDirectory(directory))) { - throw new Error("created configuration directory changed before first write"); - } + // The private writer holds the complete verified directory chain through + // the exclusive first-file creation. A failed exclusive create may be a + // safe concurrent Miftah creation, so both path components need an + // independent verifier before that race is accepted. if (!created && !(await verifyWindowsConfigPathsSecurity([ { path: parent, kind: "directory" }, { path: directory, kind: "directory" } @@ -456,7 +438,8 @@ async function ensureFirstRunConfigDirectory(configPath: string): Promise if (error instanceof MiftahError) throw error; throw new MiftahError( "CONFIG_CREATE_FAILED", - "CONFIG_CREATE_FAILED: unable to create a safe first-run configuration directory" + "CONFIG_CREATE_FAILED: unable to create a safe first-run configuration directory", + { cause: error } ); } } @@ -1246,7 +1229,7 @@ export class ConsoleApplicationService implements ConsoleControlApplication { await ensureFirstRunConfigDirectory(setup.path); await this.audit.ensureWritable(); try { - await publishSetupConfigurationPlan(setup); + await publishFirstRunSetupConfigurationPlan(setup); } catch (error) { if (fileErrorCode(error) === "EEXIST") { throw new MiftahError( diff --git a/src/setup/setup-configuration.ts b/src/setup/setup-configuration.ts index 74a5db7..2b4b951 100644 --- a/src/setup/setup-configuration.ts +++ b/src/setup/setup-configuration.ts @@ -1,4 +1,5 @@ import { writeNewConfigFile } from "../cli/migrate-config.js"; +import { writeWindowsPrivateConfigFile } from "../cli/windows-config-acl.js"; import { resolvePath } from "../config/path-resolve.js"; import type { MiftahConfig, TransportType, UpstreamConfig } from "../config/types.js"; import { validateConfig } from "../config/validate-config.js"; @@ -86,3 +87,18 @@ export function describeSetupConfiguration(input: MiftahConfig): SetupConfigurat export function publishSetupConfigurationPlan(plan: SetupConfigurationPlan): Promise { return writeNewConfigFile(plan.path, plan.content); } + +/** + * Publishes a Console first-run plan through a Windows-only writer that holds + * the verified directory chain until its private file creation is complete. + * Other setup callers retain the established generic writer contract. + */ +export async function publishFirstRunSetupConfigurationPlan(plan: SetupConfigurationPlan): Promise { + if (process.platform !== "win32") return publishSetupConfigurationPlan(plan); + const result = await writeWindowsPrivateConfigFile(plan.path, plan.content); + if (result === "written") return; + if (result === "exists") { + throw Object.assign(new Error("Configuration file already exists."), { code: "EEXIST" }); + } + throw new Error("Could not create and verify a private Windows configuration file."); +} diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index 09df5e2..8a7df05 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -1,12 +1,13 @@ -import { mkdir, mkdtemp, readFile, rm, symlink } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const aclMocks = vi.hoisted(() => ({ createPrivateDirectory: vi.fn<(directory: string) => Promise>(), createPrivateDirectoryInParent: vi.fn<(parent: string, directory: string) => Promise>(), secureFile: vi.fn<(path: string) => Promise>(), + writePrivateFile: vi.fn<(path: string, content: string | Uint8Array) => Promise<"written" | "exists" | "failed">>(), verifyPath: vi.fn<(path: string, kind: "file" | "directory") => Promise>(), verifyPaths: vi.fn<(paths: readonly { readonly path: string; readonly kind: "file" | "directory" }[]) => Promise>() })); @@ -15,6 +16,7 @@ vi.mock("../src/cli/windows-config-acl.js", () => ({ createWindowsPrivateDirectory: aclMocks.createPrivateDirectory, createWindowsPrivateDirectoryInPrivateParent: aclMocks.createPrivateDirectoryInParent, secureWindowsConfigFile: aclMocks.secureFile, + writeWindowsPrivateConfigFile: aclMocks.writePrivateFile, verifyWindowsConfigPathSecurity: aclMocks.verifyPath, verifyWindowsConfigPathsSecurity: aclMocks.verifyPaths })); @@ -30,7 +32,9 @@ vi.mock("../src/oauth/local-lock.js", () => ({ })); import { ConsoleDashboardApplicationService } from "../src/console/console-dashboard-application-service.js"; +import { AuditTrail } from "../src/audit/audit-trail.js"; import { FileSetupDraftStore } from "../src/setup/setup-draft.js"; +import { MiftahError } from "../src/utils/errors.js"; const temporaryDirectories: string[] = []; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -40,6 +44,18 @@ beforeEach(() => { aclMocks.verifyPath.mockResolvedValue(true); aclMocks.verifyPaths.mockResolvedValue(true); aclMocks.secureFile.mockResolvedValue(true); + aclMocks.writePrivateFile.mockImplementation(async (path, content) => { + try { + const directory = await lstat(dirname(path)); + if (!directory.isDirectory() || directory.isSymbolicLink()) return "failed"; + await writeFile(path, content, { flag: "wx", mode: 0o600 }); + return "written"; + } catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST" + ? "exists" + : "failed"; + } + }); aclMocks.createPrivateDirectory.mockImplementation(async (directory) => { try { await mkdir(directory); @@ -63,8 +79,10 @@ afterEach(async () => { aclMocks.createPrivateDirectory.mockReset(); aclMocks.createPrivateDirectoryInParent.mockReset(); aclMocks.secureFile.mockReset(); + aclMocks.writePrivateFile.mockReset(); aclMocks.verifyPath.mockReset(); aclMocks.verifyPaths.mockReset(); + vi.restoreAllMocks(); await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); @@ -92,7 +110,7 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, expect.stringMatching(/[/\\]miftah$/u), "directory"); expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); expect(aclMocks.verifyPaths).not.toHaveBeenCalled(); - expect(aclMocks.secureFile).toHaveBeenCalledWith(configPath); + expect(aclMocks.writePrivateFile).toHaveBeenCalledWith(configPath, expect.any(String)); }); it("fails closed if a created config directory becomes a symlink before the first write", async () => { @@ -127,6 +145,34 @@ describe("Console Windows first-run boundary", () => { await expect(readFile(join(redirectedDirectory, "miftah.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + it("does not write through a config directory replaced after the audit preflight", async () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-post-audit-swap-")); + temporaryDirectories.push(parent); + const configDirectory = join(parent, "miftah"); + const redirectedDirectory = join(parent, "redirected"); + const configPath = join(configDirectory, "miftah.json"); + await mkdir(redirectedDirectory); + const originalEnsureWritable = AuditTrail.prototype.ensureWritable; + vi.spyOn(AuditTrail.prototype, "ensureWritable").mockImplementationOnce(async function(this: AuditTrail) { + await originalEnsureWritable.call(this); + await rm(configDirectory, { recursive: true }); + await symlink(redirectedDirectory, configDirectory); + }); + const service = new ConsoleDashboardApplicationService({ configDirectory, defaultConfigPath: configPath }); + + await expect(service.onboardNativeOAuth({ + name: "first-run", + profile: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid"] + })).rejects.toMatchObject({ code: "CONFIG_CREATE_FAILED" }); + + expect(aclMocks.writePrivateFile).toHaveBeenCalledWith(configPath, expect.any(String)); + await expect(readFile(join(redirectedDirectory, "miftah.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("fully verifies a first-run config directory created by another Windows process", async () => { const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-race-")); temporaryDirectories.push(parent); @@ -165,15 +211,20 @@ describe("Console Windows first-run boundary", () => { aclMocks.verifyPaths.mockResolvedValue(false); const service = new ConsoleDashboardApplicationService({ configDirectory, defaultConfigPath: configPath }); - await expect(service.onboardNativeOAuth({ + const failure = await service.onboardNativeOAuth({ name: "first-run", profile: "default", resource: "https://mcp.example.test/mcp", issuer: "https://auth.example.test", clientRegistration: "dynamic", scopes: ["openid"] - })).rejects.toMatchObject({ code: "CONFIG_CREATE_FAILED" }); + }).then( + () => undefined, + (error: unknown) => error + ); + expect(failure).toMatchObject({ code: "CONFIG_CREATE_FAILED" }); + expect((failure as MiftahError).details?.cause).toBeInstanceOf(Error); await expect(readFile(configPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -234,7 +285,8 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.createPrivateDirectory).not.toHaveBeenCalled(); expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledTimes(2); - expect(aclMocks.secureFile).toHaveBeenCalledTimes(2); + expect(aclMocks.secureFile).toHaveBeenCalledOnce(); + expect(aclMocks.writePrivateFile).toHaveBeenCalledOnce(); expect(aclMocks.verifyPath).toHaveBeenCalledTimes(5); expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(3); }); @@ -256,10 +308,11 @@ describe("Console Windows first-run boundary", () => { throw new Error("Expected the first-run service to expose the configured setup-draft capability."); } - const snapshot = (): Readonly> => ({ + const snapshot = (): Readonly> => ({ create: aclMocks.createPrivateDirectory.mock.calls.length, createInParent: aclMocks.createPrivateDirectoryInParent.mock.calls.length, secure: aclMocks.secureFile.mock.calls.length, + write: aclMocks.writePrivateFile.mock.calls.length, verify: aclMocks.verifyPath.mock.calls.length, verifyBatch: aclMocks.verifyPaths.mock.calls.length }); @@ -288,11 +341,11 @@ describe("Console Windows first-run boundary", () => { const afterConfiguredRead = snapshot(); expect({ afterSave, afterLoad, afterPublication, afterDraftRead, afterConfiguredRead }).toEqual({ - afterSave: { create: 0, createInParent: 1, secure: 1, verify: 0, verifyBatch: 0 }, - afterLoad: { create: 0, createInParent: 1, secure: 1, verify: 0, verifyBatch: 1 }, - afterPublication: { create: 0, createInParent: 2, secure: 2, verify: 3, verifyBatch: 2 }, - afterDraftRead: { create: 0, createInParent: 2, secure: 2, verify: 3, verifyBatch: 3 }, - afterConfiguredRead: { create: 0, createInParent: 2, secure: 2, verify: 5, verifyBatch: 3 } + afterSave: { create: 0, createInParent: 1, secure: 1, write: 0, verify: 0, verifyBatch: 0 }, + afterLoad: { create: 0, createInParent: 1, secure: 1, write: 0, verify: 0, verifyBatch: 1 }, + afterPublication: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 3, verifyBatch: 2 }, + afterDraftRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 3, verifyBatch: 3 }, + afterConfiguredRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 5, verifyBatch: 3 } }); }); diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index bbf68ba..abe54b6 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -22,6 +22,7 @@ import { createWindowsPrivateDirectoryInPrivateParent, createWindowsPrivateMigrationDirectory, secureWindowsConfigFile, + writeWindowsPrivateConfigFile, verifyWindowsConfigPathSecurity, verifyWindowsConfigPathsSecurity } from "../src/cli/windows-config-acl.js"; @@ -34,6 +35,15 @@ function createChild(): EventEmitter & { readonly kill: ReturnType return child; } +function createWritableChild(): EventEmitter & { + readonly kill: ReturnType; + readonly stdin: EventEmitter & { readonly end: ReturnType }; +} { + const child = createChild(); + const stdin = Object.assign(new EventEmitter(), { end: vi.fn() }); + return Object.assign(child, { stdin }); +} + beforeEach(() => { Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); vi.stubEnv("SystemRoot", "C:\\Windows"); @@ -162,6 +172,56 @@ describe("Windows migration ACL boundary", () => { expect(command).not.toContain("$actual = [System.IO.File]::GetAccessControl($path, $verifySections)"); }); + it("writes one new private configuration file through a separately bounded no-delete directory chain helper", async () => { + const child = createWritableChild(); + windowsAclMocks.spawn.mockImplementation(() => { + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await expect(writeWindowsPrivateConfigFile( + "C:\\Users\\miftah\\.config\\miftah\\miftah.json", + "{\"version\":\"3\"}\n" + )).resolves.toBe("written"); + + const [, args, options] = windowsAclMocks.spawn.mock.calls[0] ?? []; + expect(options).toMatchObject({ + shell: false, + windowsHide: true, + stdio: ["pipe", "ignore", "ignore"] + }); + expect(Buffer.from(options?.env?.MIFTAH_CONFIG_ACL_REQUEST ?? "", "base64").toString("utf8")).toBe( + "write-private-file\u0000C:\\Users\\miftah\\.config\\miftah\\miftah.json\u000016" + ); + expect(child.stdin.end).toHaveBeenCalledWith(Buffer.from("{\"version\":\"3\"}\n", "utf8")); + const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); + expect(command).toContain("MiftahPrivateConfigWrite"); + expect(command).toContain("OpenDirectory"); + expect(command).toContain("GetFileInformationByHandle"); + expect(command).toContain("CreateFile(path, 0x00000080, 0x00000003"); + expect(command).toContain("0x02200000"); + expect(command).toContain("FileMode]::CreateNew"); + expect(command).toContain("FileSystemRights]::FullControl"); + expect(command).toContain("FileShare]::None"); + expect(command).toContain("FileOptions]::WriteThrough"); + expect(command).toContain("[Console]::OpenStandardInput()"); + expect(command).toContain("$stream.Flush($true)"); + expect(command).toContain("[System.IO.File]::Move($temporaryPath, $path)"); + expect(command).toContain("Test-MiftahHeldPath"); + expect(command).not.toContain("0x00000007"); + expect(args?.[4].length).toBeLessThanOrEqual(30_000); + }); + + it("reports an existing configuration file without falling back to a pathname write", async () => { + const child = createWritableChild(); + windowsAclMocks.spawn.mockImplementation(() => { + queueMicrotask(() => child.emit("close", 2)); + return child; + }); + + await expect(writeWindowsPrivateConfigFile("C:\\Users\\miftah\\miftah.json", "{}\n")).resolves.toBe("exists"); + }); + it("creates and verifies a current-user-only private directory before returning", async () => { windowsAclMocks.spawn.mockImplementation(() => { const child = createChild(); diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index 193f17a..db6e7a7 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -7,7 +7,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { runMigrateConfigCommand } from "../src/cli/migrate-config.js"; import { createWindowsPrivateDirectoryInPrivateParent, - verifyWindowsConfigPathSecurity + verifyWindowsConfigPathSecurity, + writeWindowsPrivateConfigFile } from "../src/cli/windows-config-acl.js"; const requestEnvironmentName = "MIFTAH_TEST_CONFIG_ACL_REQUEST"; @@ -498,6 +499,26 @@ async function windowsCopyFileSecurityProbe( } describe("Windows migration ACL contract", () => { + it.runIf(process.platform === "win32")( + "streams an exact first-run config through the held private directory chain", + async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-windows-private-first-run-write-")); + temporaryDirectories.push(root); + const privateParent = join(root, "private-parent"); + const directory = join(privateParent, "miftah"); + const path = join(directory, "miftah.json"); + const content = "{\"version\":\"3\",\"name\":\"first-run\"}\n"; + await windowsPrivateDirectoryProbe(privateParent); + await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, directory)).resolves.toBe(true); + + await expect(writeWindowsPrivateConfigFile(path, content)).resolves.toBe("written"); + + await expect(readFile(path, "utf8")).resolves.toBe(content); + await expect(verifyWindowsConfigPathSecurity(path, "file")).resolves.toBe(true); + }, + 10_000 + ); + it.runIf(process.platform === "win32")( "accepts a private child when only the terminal volume root grants DELETE", async () => { From ac721e1ee994eb98f67f929c05b4067fdefb14f9 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 20:43:10 +0400 Subject: [PATCH 21/25] fix(windows): use checked helper for first-run config writes --- src/cli/windows-config-acl.ts | 220 +++---------- src/secrets/windows-secret-command.ts | 5 +- src/secrets/windows-secret-job-artifact.ts | 2 +- src/secrets/windows-secret-job.cs | 302 ++++++++++++++++++ tests/windows-config-acl.test.ts | 58 ++-- tests/windows-secret-command-contract.test.ts | 19 ++ 6 files changed, 404 insertions(+), 202 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index dd24904..80b673d 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -1,8 +1,10 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { win32 } from "node:path"; +import { resolveCheckedWindowsSecretJobExecutable } from "../secrets/windows-secret-command.js"; const requestEnvironmentName = "MIFTAH_CONFIG_ACL_REQUEST"; +const privateConfigWriteRequestEnvironmentName = "MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST"; const maximumRequestBytes = 12 * 1024; const aclCommandTimeoutMs = 5_000; // Node exposes no trusted Windows system-directory API. Use the protected default @@ -38,8 +40,7 @@ interface SecurePrivateFileRequest { readonly path: string; } -interface WritePrivateFileRequest { - readonly operation: "write-private-file"; +interface PrivateConfigWriteRequest { readonly path: string; readonly byteLength: number; } @@ -122,20 +123,18 @@ export async function writeWindowsPrivateConfigFile( content: string | Uint8Array ): Promise { if (process.platform !== "win32") return "failed"; - const launcher = trustedPowerShellExecutable(); const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content); - const request: WritePrivateFileRequest = { - operation: "write-private-file", + const request: PrivateConfigWriteRequest = { path, byteLength: bytes.byteLength }; - if (launcher === undefined || requestHasNul(request) || !Number.isSafeInteger(request.byteLength)) { + if (path.includes("\u0000") || !Number.isSafeInteger(request.byteLength)) { return "failed"; } const encodedRequest = encodePrivateFileWriteRequest(request); - return encodedRequest === undefined - ? "failed" - : runWindowsPrivateFileWriteCommand(launcher, encodedRequest, bytes); + if (encodedRequest === undefined) return "failed"; + const launcher = await resolveCheckedWindowsSecretJobExecutable(); + return launcher === undefined ? "failed" : runWindowsPrivateFileWriteCommand(launcher, encodedRequest, bytes); } /** Backwards-compatible name for migration transaction directories. */ @@ -186,7 +185,7 @@ async function runWindowsAclRequest(request: WindowsConfigAclRequest): Promise path.path.includes("\u0000")); if (request.operation === "create-private-directory") return request.directory.includes("\u0000"); - if (request.operation === "write-private-file") return request.path.includes("\u0000"); return request.path.includes("\u0000"); } @@ -223,12 +221,27 @@ function encodeRequest(request: WindowsConfigAclRequest): string | undefined { : undefined; } -function encodePrivateFileWriteRequest(request: WritePrivateFileRequest): string | undefined { - const payload = [request.operation, request.path, String(request.byteLength)].join("\u0000"); - const bytes = Buffer.from(payload, "utf8"); - return bytes.byteLength <= maximumRequestBytes && bytes.toString("utf8") === payload - ? bytes.toString("base64") - : undefined; +function encodePrivateFileWriteRequest(request: PrivateConfigWriteRequest): string | undefined { + const path = Buffer.from(request.path, "utf8"); + const requestLength = 1 + 4 + path.byteLength + 8; + if ( + request.byteLength < 0 || + requestLength > maximumRequestBytes || + path.toString("utf8") !== request.path + ) { + return undefined; + } + + const payload = Buffer.allocUnsafe(requestLength); + let offset = 0; + payload.writeUInt8(2, offset); + offset += 1; + payload.writeInt32LE(path.byteLength, offset); + offset += 4; + path.copy(payload, offset); + offset += path.byteLength; + payload.writeBigInt64LE(BigInt(request.byteLength), offset); + return payload.toString("base64"); } function trustedPowerShellExecutable(): string | undefined { @@ -252,6 +265,16 @@ function aclEnvironment(request: string): NodeJS.ProcessEnv { return environment; } +function privateConfigWriterEnvironment(request: string): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { SystemRoot: trustedWindowsRoot, windir: trustedWindowsRoot }; + for (const name of ["ComSpec", "TEMP", "TMP", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]) { + const value = environmentValue(process.env, name); + if (value !== undefined) environment[name] = value; + } + environment[privateConfigWriteRequestEnvironmentName] = request; + return environment; +} + function environmentValue(environment: NodeJS.ProcessEnv, name: string): string | undefined { const normalizedName = name.toLocaleLowerCase("en-US"); for (const [candidateName, value] of Object.entries(environment)) { @@ -303,8 +326,8 @@ function runWindowsPrivateFileWriteCommand( return new Promise((resolve) => { let child: ReturnType; try { - child = spawn(launcher, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedPrivateFileWriteCommand], { - env: aclEnvironment(request), + child = spawn(launcher, ["--write-private-config"], { + env: privateConfigWriterEnvironment(request), shell: false, windowsHide: true, stdio: ["pipe", "ignore", "ignore"] @@ -576,162 +599,3 @@ try { }`; const encodedAclCommand = Buffer.from(aclCommand, "utf16le").toString("base64"); - -/** - * This command is intentionally separate from the general ACL helper: that - * command is already near the Windows command-line limit. It holds every - * directory from the volume/share root through the target directory without - * FILE_SHARE_DELETE, checks the held path, then streams one exclusive file - * write from stdin before releasing those handles. - */ -const privateFileWriteCommand = String.raw`$ErrorActionPreference = 'Stop' -$requestName = '${requestEnvironmentName}' -$sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner -Add-Type -TypeDefinition @' -using System; -using Microsoft.Win32.SafeHandles; -using System.Runtime.InteropServices; -public static class MiftahPrivateConfigWrite { - [StructLayout(LayoutKind.Sequential)] - private struct FileInformation { - public uint Attributes; - public System.Runtime.InteropServices.ComTypes.FILETIME Creation; - public System.Runtime.InteropServices.ComTypes.FILETIME Access; - public System.Runtime.InteropServices.ComTypes.FILETIME Write; - public uint VolumeSerial; - public uint SizeHigh; - public uint SizeLow; - public uint Links; - public uint IndexHigh; - public uint IndexLow; - } - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern SafeFileHandle CreateFile( - string path, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out FileInformation information); - public static SafeFileHandle OpenDirectory(string path) { - SafeFileHandle handle = CreateFile(path, 0x00000080, 0x00000003, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero); - if (handle == null || handle.IsInvalid) return null; - FileInformation information; - if (!GetFileInformationByHandle(handle, out information) || - (information.Attributes & 0x00000410) != 0x00000010) { - handle.Dispose(); - return null; - } - return handle; - } -} -'@ -function Test-MiftahHeldPath { - param([string]$path, [string]$kind, [bool]$requireOwner, [bool]$requireProtected) - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User - if ($null -eq $identity) { return $false } - $entry = if ($kind -eq 'file') { [System.IO.FileInfo]::new($path) } else { [System.IO.DirectoryInfo]::new($path) } - if (-not $entry.Exists) { return $false } - $acl = if ($kind -eq 'file') { [System.IO.File]::GetAccessControl($path, $sections) } else { [System.IO.Directory]::GetAccessControl($path, $sections) } - $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) - $trusted = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-4', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') - if ($null -eq $owner -or -not ($trusted -ccontains $owner.Value) -or ($requireOwner -and $owner.Value -cne $identity.Value)) { return $false } - $raw = [System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0) - if ($null -eq $raw.DiscretionaryAcl -or -not $acl.AreAccessRulesCanonical -or ($requireProtected -and -not $acl.AreAccessRulesProtected)) { return $false } - $restricted = if ($kind -eq 'file') { - [int]([System.Security.AccessControl.FileSystemRights]::ReadData -bor [System.Security.AccessControl.FileSystemRights]::ReadExtendedAttributes -bor [System.Security.AccessControl.FileSystemRights]::ReadAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership) - } else { - [int]([System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership) - } - $inheritOnly = [int][System.Security.AccessControl.PropagationFlags]::InheritOnly - foreach ($rule in @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))) { - if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $trusted -ccontains $rule.IdentityReference.Value) { continue } - if (($rule.IdentityReference.Value -ceq 'S-1-3-0' -or $rule.IdentityReference.Value -ceq 'S-1-3-1') -and (([int]$rule.PropagationFlags -band $inheritOnly) -ne 0)) { continue } - if (([int]$rule.FileSystemRights -band $restricted) -ne 0) { return $false } - } - return $true -} -$result = 1 -$temporaryCreated = $false -$finalCreated = $false -$temporaryPath = $null -$stream = $null -$handles = [System.Collections.Generic.List[System.IDisposable]]::new() -try { - $encoded = [Environment]::GetEnvironmentVariable($requestName, [EnvironmentVariableTarget]::Process) - if ([string]::IsNullOrEmpty($encoded) -or $encoded.Length -gt 16384) { throw 'invalid request' } - $fields = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)).Split([char]0) - [Environment]::SetEnvironmentVariable($requestName, $null, [EnvironmentVariableTarget]::Process) - if ($fields.Count -ne 3 -or $fields[0] -ne 'write-private-file') { throw 'invalid request' } - $path = [System.IO.Path]::GetFullPath($fields[1]) - $directory = [System.IO.FileInfo]::new($path).Directory - if ($null -eq $directory) { throw 'missing directory' } - [int64]$remaining = [Convert]::ToInt64($fields[2], [Globalization.CultureInfo]::InvariantCulture) - if ($remaining -lt 0) { throw 'invalid length' } - $chain = [System.Collections.Generic.List[string]]::new() - $current = $directory - while ($null -ne $current) { - [void]$chain.Add($current.FullName) - $parent = $current.Parent - if ($null -eq $parent -or $parent.FullName -ceq $current.FullName) { break } - $current = $parent - } - $chain.Reverse() - foreach ($component in $chain) { - $handle = [MiftahPrivateConfigWrite]::OpenDirectory($component) - if ($null -eq $handle) { throw 'unsafe path component' } - [void]$handles.Add($handle) - } - if (-not (Test-MiftahHeldPath $directory.FullName 'directory' $true $true)) { throw 'unsafe directory' } - $ancestor = $directory.Parent - $firstAncestor = $true - while ($null -ne $ancestor) { - $next = $ancestor.Parent - if ($null -eq $next -or $next.FullName -ceq $ancestor.FullName) { break } - if (-not (Test-MiftahHeldPath $ancestor.FullName 'directory' $firstAncestor $false)) { throw 'unsafe ancestor' } - $firstAncestor = $false - $ancestor = $next - } - if ([System.IO.File]::Exists($path) -or [System.IO.Directory]::Exists($path)) { - $result = 2 - } else { - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User - if ($null -eq $identity) { throw 'missing identity' } - $security = [System.Security.AccessControl.FileSecurity]::new() - $security.SetAccessRuleProtection($true, $false) - $security.SetOwner($identity) - $security.SetAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow)) - $temporaryPath = [System.IO.Path]::Combine($directory.FullName, "." + [System.IO.Path]::GetFileName($path) + "." + [System.Guid]::NewGuid().ToString("N") + ".tmp") - $stream = [System.IO.FileStream]::new($temporaryPath, [System.IO.FileMode]::CreateNew, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.IO.FileShare]::None, 4096, [System.IO.FileOptions]::WriteThrough, $security) - $temporaryCreated = $true - $input = [Console]::OpenStandardInput() - $buffer = [byte[]]::new(65536) - while ($remaining -gt 0) { - $count = [int][Math]::Min([int64]$buffer.Length, $remaining) - $read = $input.Read($buffer, 0, $count) - if ($read -le 0) { throw 'truncated input' } - $stream.Write($buffer, 0, $read) - $remaining -= $read - } - if ($input.ReadByte() -ne -1) { throw 'unexpected input' } - $stream.Flush($true) - $stream.Dispose() - $stream = $null - if (-not (Test-MiftahHeldPath $temporaryPath 'file' $true $true)) { throw 'unsafe temporary file' } - [System.IO.File]::Move($temporaryPath, $path) - $temporaryCreated = $false - $finalCreated = $true - if (-not (Test-MiftahHeldPath $path 'file' $true $true)) { throw 'unsafe file' } - $result = 0 - } -} catch [System.IO.IOException] { - $result = if (([int]$_.Exception.HResult -band 0xFFFF) -in @(80, 183)) { 2 } else { 1 } -} catch { - $result = 1 -} finally { - if ($null -ne $stream) { try { $stream.Dispose() } catch {} } - if ($result -ne 0 -and $temporaryCreated) { try { [System.IO.File]::Delete($temporaryPath) } catch {} } - if ($result -ne 0 -and $finalCreated) { try { [System.IO.File]::Delete($path) } catch {} } - foreach ($handle in $handles) { try { $handle.Dispose() } catch {} } -} -exit $result`; - -const encodedPrivateFileWriteCommand = Buffer.from(privateFileWriteCommand, "utf16le").toString("base64"); diff --git a/src/secrets/windows-secret-command.ts b/src/secrets/windows-secret-command.ts index b1a2596..a1a2d28 100644 --- a/src/secrets/windows-secret-command.ts +++ b/src/secrets/windows-secret-command.ts @@ -51,7 +51,7 @@ export async function resolveWindowsSecretCommand( const executable = await resolveTargetExecutable(command.executable, command.environment); if (executable === undefined || isBatchFile(executable)) return undefined; - const launcher = await trustedWindowsSecretJobExecutable(); + const launcher = await resolveCheckedWindowsSecretJobExecutable(); if (launcher === undefined) return undefined; return { ...command, executable, launcher }; } @@ -97,7 +97,8 @@ async function resolveTargetExecutable( return resolveExecutablePath(executable, { environment, platform: "win32" }); } -async function trustedWindowsSecretJobExecutable(): Promise { +/** Resolves only the checked native helper, never an ambient executable lookup. */ +export async function resolveCheckedWindowsSecretJobExecutable(): Promise { for (const executable of windowsSecretJobExecutableCandidates) { let contents: Buffer; try { diff --git a/src/secrets/windows-secret-job-artifact.ts b/src/secrets/windows-secret-job-artifact.ts index 5b75e48..e3c641b 100644 --- a/src/secrets/windows-secret-job-artifact.ts +++ b/src/secrets/windows-secret-job-artifact.ts @@ -3,5 +3,5 @@ * Regenerate the executable with scripts/export-windows-secret-job-assembly.ps1 and verify it * on Windows with scripts/verify-windows-secret-job-assembly.ps1. */ -export const windowsSecretJobSourceSha256 = "064130730423ab24f87f003d31acda3da426c7a74898b2f6470245aa417e9e19"; +export const windowsSecretJobSourceSha256 = "91d4b3a031d67d16d3f13a5e930267745b1d5f40efaffd61769fe4ba1fce01cb"; export const windowsSecretJobExecutableSha256 = "44ef7cca43d181cf92447ed4202d60a48cd8fa8d6b66305b9e80c3a1fa4bac28"; diff --git a/src/secrets/windows-secret-job.cs b/src/secrets/windows-secret-job.cs index df93429..97f7b3e 100644 --- a/src/secrets/windows-secret-job.cs +++ b/src/secrets/windows-secret-job.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; using System.Text; public static class MiftahSecretJob @@ -9,6 +12,15 @@ public static class MiftahSecretJob private const int MaximumArgumentCount = 128; private const string RequestEnvironmentName = "MIFTAH_SECRET_RUNNER_REQUEST"; private const string StandardInputEnvironmentName = "MIFTAH_SECRET_RUNNER_STDIN"; + private const string PrivateConfigWriteRequestEnvironmentName = "MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST"; + private const byte PrivateConfigWriteRequestVersion = 2; + private const uint FileReadAttributes = 0x00000080; + private const uint FileShareReadWrite = 0x00000003; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagOpenReparsePoint = 0x00200000; + private const uint FileAttributeDirectory = 0x00000010; + private const uint FileAttributeReparsePoint = 0x00000400; private const uint JobObjectExtendedLimitInformationClass = 9; private const uint JobObjectLimitKillOnJobClose = 0x00002000; private const uint CreateNoWindow = 0x08000000; @@ -30,6 +42,18 @@ public SecretRequest(string executable, string[] arguments) } } + private sealed class PrivateConfigWriteRequest + { + public readonly string Path; + public readonly long ByteLength; + + public PrivateConfigWriteRequest(string path, long byteLength) + { + Path = path; + ByteLength = byteLength; + } + } + [StructLayout(LayoutKind.Sequential)] private struct JobObjectBasicLimitInformation { @@ -105,6 +129,21 @@ private struct ProcessInformation public uint dwThreadId; } + [StructLayout(LayoutKind.Sequential)] + private struct FileInformation + { + public uint Attributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr CreateJobObjectW(IntPtr jobAttributes, string name); @@ -125,6 +164,20 @@ uint informationLength [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW( + string path, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle(IntPtr handle, out FileInformation information); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CreateProcessW( string applicationName, @@ -192,6 +245,11 @@ IntPtr returnSize public static int Main(string[] arguments) { + if (arguments != null && arguments.Length == 1 && + String.Equals(arguments[0], "--write-private-config", StringComparison.Ordinal)) + { + return RunPrivateConfigWrite(); + } if (arguments == null || arguments.Length != 0) return 1; try { @@ -259,6 +317,250 @@ private static byte[] DecodeStandardInput(string encodedStandardInput) return standardInput.Length <= MaximumRequestBytes ? standardInput : null; } + private static int RunPrivateConfigWrite() + { + try + { + string encodedRequest = Environment.GetEnvironmentVariable( + PrivateConfigWriteRequestEnvironmentName, + EnvironmentVariableTarget.Process + ); + Environment.SetEnvironmentVariable( + PrivateConfigWriteRequestEnvironmentName, + null, + EnvironmentVariableTarget.Process + ); + PrivateConfigWriteRequest request = DecodePrivateConfigWriteRequest(encodedRequest); + return request == null ? 1 : WritePrivateConfigurationFile(request); + } + catch + { + return 1; + } + } + + private static PrivateConfigWriteRequest DecodePrivateConfigWriteRequest(string encodedRequest) + { + if (String.IsNullOrEmpty(encodedRequest)) return null; + byte[] payload = Convert.FromBase64String(encodedRequest); + if (payload.Length == 0 || payload.Length > MaximumRequestBytes) return null; + UTF8Encoding utf8 = new UTF8Encoding(false, true); + using (MemoryStream input = new MemoryStream(payload, false)) + using (BinaryReader reader = new BinaryReader(input, utf8)) + { + if (reader.ReadByte() != PrivateConfigWriteRequestVersion) return null; + string path = ReadString(reader, input, utf8); + if (String.IsNullOrEmpty(path) || path.IndexOf('\0') >= 0 || !Path.IsPathRooted(path)) return null; + long byteLength = reader.ReadInt64(); + if (byteLength < 0 || input.Position != input.Length) return null; + return new PrivateConfigWriteRequest(Path.GetFullPath(path), byteLength); + } + } + + private static int WritePrivateConfigurationFile(PrivateConfigWriteRequest request) + { + List directoryHandles = new List(); + string temporaryPath = null; + bool temporaryCreated = false; + bool finalCreated = false; + FileStream stream = null; + int result = 1; + try + { + FileInfo target = new FileInfo(request.Path); + DirectoryInfo directory = target.Directory; + if (directory == null || String.IsNullOrEmpty(target.Name)) return 1; + + List directoryChain = new List(); + DirectoryInfo component = directory; + while (component != null) + { + directoryChain.Add(component.FullName); + DirectoryInfo parent = component.Parent; + if (parent == null || String.Equals(parent.FullName, component.FullName, StringComparison.OrdinalIgnoreCase)) break; + component = parent; + } + directoryChain.Reverse(); + foreach (string path in directoryChain) + { + IntPtr handle = OpenDirectoryWithoutDeleteSharing(path); + if (!IsValidHandle(handle)) return 1; + directoryHandles.Add(handle); + } + + if (!IsPrivatePath(directory.FullName, true, true, true)) return 1; + DirectoryInfo ancestor = directory.Parent; + bool firstAncestor = true; + while (ancestor != null) + { + DirectoryInfo parent = ancestor.Parent; + if (parent == null || String.Equals(parent.FullName, ancestor.FullName, StringComparison.OrdinalIgnoreCase)) break; + if (!IsPrivatePath(ancestor.FullName, true, firstAncestor, false)) return 1; + firstAncestor = false; + ancestor = parent; + } + + if (File.Exists(request.Path) || Directory.Exists(request.Path)) return 2; + SecurityIdentifier identity = WindowsIdentity.GetCurrent().User; + if (identity == null) return 1; + FileSecurity security = new FileSecurity(); + security.SetAccessRuleProtection(true, false); + security.SetOwner(identity); + security.SetAccessRule(new FileSystemAccessRule( + identity, + FileSystemRights.FullControl, + AccessControlType.Allow + )); + temporaryPath = Path.Combine( + directory.FullName, + "." + target.Name + "." + Guid.NewGuid().ToString("N") + ".tmp" + ); + stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileSystemRights.FullControl, + FileShare.None, + 4096, + FileOptions.WriteThrough, + security + ); + temporaryCreated = true; + + Stream input = Console.OpenStandardInput(); + byte[] buffer = new byte[65536]; + long remaining = request.ByteLength; + while (remaining > 0) + { + int count = (int)Math.Min((long)buffer.Length, remaining); + int read = input.Read(buffer, 0, count); + if (read <= 0) return 1; + stream.Write(buffer, 0, read); + remaining -= read; + } + if (input.ReadByte() != -1) return 1; + stream.Flush(true); + stream.Dispose(); + stream = null; + if (!IsPrivatePath(temporaryPath, false, true, true)) return 1; + File.Move(temporaryPath, request.Path); + temporaryCreated = false; + finalCreated = true; + if (!IsPrivatePath(request.Path, false, true, true)) return 1; + result = 0; + return 0; + } + catch (IOException exception) + { + return (exception.HResult & 0xFFFF) == 80 || (exception.HResult & 0xFFFF) == 183 ? 2 : 1; + } + catch + { + return 1; + } + finally + { + if (stream != null) stream.Dispose(); + if (result != 0 && temporaryCreated && temporaryPath != null) + { + try { File.Delete(temporaryPath); } catch { } + } + if (result != 0 && finalCreated) + { + try { File.Delete(request.Path); } catch { } + } + foreach (IntPtr handle in directoryHandles) + { + if (IsValidHandle(handle)) CloseHandle(handle); + } + } + } + + private static IntPtr OpenDirectoryWithoutDeleteSharing(string path) + { + IntPtr handle = CreateFileW( + path, + FileReadAttributes, + FileShareReadWrite, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero + ); + if (!IsValidHandle(handle)) return IntPtr.Zero; + FileInformation information; + if (!GetFileInformationByHandle(handle, out information) || + (information.Attributes & (FileAttributeDirectory | FileAttributeReparsePoint)) != FileAttributeDirectory) + { + CloseHandle(handle); + return IntPtr.Zero; + } + return handle; + } + + private static bool IsPrivatePath(string path, bool directory, bool requireCurrentOwner, bool requireProtected) + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0 || + directory != ((attributes & FileAttributes.Directory) != 0)) return false; + SecurityIdentifier identity = WindowsIdentity.GetCurrent().User; + if (identity == null) return false; + AccessControlSections sections = AccessControlSections.Access | AccessControlSections.Owner; + FileSystemSecurity security = directory + ? (FileSystemSecurity)Directory.GetAccessControl(path, sections) + : File.GetAccessControl(path, sections); + SecurityIdentifier owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + if (owner == null || !IsTrustedIdentity(owner.Value, identity.Value) || + (requireCurrentOwner && !String.Equals(owner.Value, identity.Value, StringComparison.Ordinal))) return false; + RawSecurityDescriptor descriptor = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0); + if (descriptor.DiscretionaryAcl == null || !security.AreAccessRulesCanonical || + (requireProtected && !security.AreAccessRulesProtected)) return false; + int restrictedRights = directory + ? (int)( + FileSystemRights.WriteData | + FileSystemRights.AppendData | + FileSystemRights.DeleteSubdirectoriesAndFiles | + FileSystemRights.Delete | + FileSystemRights.WriteAttributes | + FileSystemRights.WriteExtendedAttributes | + FileSystemRights.ChangePermissions | + FileSystemRights.TakeOwnership + ) + : (int)( + FileSystemRights.ReadData | + FileSystemRights.ReadExtendedAttributes | + FileSystemRights.ReadAttributes | + FileSystemRights.WriteData | + FileSystemRights.AppendData | + FileSystemRights.Delete | + FileSystemRights.WriteAttributes | + FileSystemRights.WriteExtendedAttributes | + FileSystemRights.ChangePermissions | + FileSystemRights.TakeOwnership + ); + AuthorizationRuleCollection rules = security.GetAccessRules(true, true, typeof(SecurityIdentifier)); + foreach (AuthorizationRule authorizationRule in rules) + { + FileSystemAccessRule rule = authorizationRule as FileSystemAccessRule; + if (rule == null) return false; + string ruleIdentity = rule.IdentityReference.Value; + if (rule.AccessControlType != AccessControlType.Allow || IsTrustedIdentity(ruleIdentity, identity.Value)) continue; + if ((String.Equals(ruleIdentity, "S-1-3-0", StringComparison.Ordinal) || + String.Equals(ruleIdentity, "S-1-3-1", StringComparison.Ordinal)) && + (rule.PropagationFlags & PropagationFlags.InheritOnly) != 0) continue; + if (((int)rule.FileSystemRights & restrictedRights) != 0) return false; + } + return true; + } + + private static bool IsTrustedIdentity(string value, string currentIdentity) + { + return String.Equals(value, currentIdentity, StringComparison.Ordinal) || + String.Equals(value, "S-1-5-18", StringComparison.Ordinal) || + String.Equals(value, "S-1-5-32-544", StringComparison.Ordinal) || + String.Equals(value, "S-1-3-4", StringComparison.Ordinal) || + String.Equals(value, "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", StringComparison.Ordinal); + } + public static bool Initialize() { IntPtr createdJob = CreateJobObjectW(IntPtr.Zero, null); diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index abe54b6..5f88e1a 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -6,6 +6,10 @@ const windowsAclMocks = vi.hoisted(() => ({ spawn: vi.fn() })); +const windowsSecretJobMocks = vi.hoisted(() => ({ + resolveCheckedWindowsSecretJobExecutable: vi.fn<() => Promise>() +})); + vi.mock("node:fs", async (importOriginal) => ({ ...(await importOriginal()), existsSync: windowsAclMocks.existsSync @@ -16,6 +20,10 @@ vi.mock("node:child_process", async (importOriginal) => ({ spawn: windowsAclMocks.spawn })); +vi.mock("../src/secrets/windows-secret-command.js", () => ({ + resolveCheckedWindowsSecretJobExecutable: windowsSecretJobMocks.resolveCheckedWindowsSecretJobExecutable +})); + import { copyWindowsConfigSecurityDescriptor, copyWindowsConfigSecurityDescriptors, @@ -44,16 +52,35 @@ function createWritableChild(): EventEmitter & { return Object.assign(child, { stdin }); } +function decodePrivateFileWriteRequest(encoded: string): { readonly version: number; readonly path: string; readonly byteLength: number } { + const payload = Buffer.from(encoded, "base64"); + let offset = 0; + const version = payload.readUInt8(offset); + offset += 1; + const pathLength = payload.readInt32LE(offset); + offset += 4; + const path = payload.subarray(offset, offset + pathLength).toString("utf8"); + offset += pathLength; + const byteLength = Number(payload.readBigInt64LE(offset)); + offset += 8; + expect(offset).toBe(payload.byteLength); + return { version, path, byteLength }; +} + beforeEach(() => { Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); vi.stubEnv("SystemRoot", "C:\\Windows"); windowsAclMocks.existsSync.mockReturnValue(true); + windowsSecretJobMocks.resolveCheckedWindowsSecretJobExecutable.mockResolvedValue( + "C:\\Program Files\\Miftah\\windows-secret-job.exe" + ); }); afterEach(() => { if (platformDescriptor !== undefined) Object.defineProperty(process, "platform", platformDescriptor); windowsAclMocks.existsSync.mockReset(); windowsAclMocks.spawn.mockReset(); + windowsSecretJobMocks.resolveCheckedWindowsSecretJobExecutable.mockReset(); vi.useRealTimers(); vi.unstubAllEnvs(); }); @@ -172,7 +199,7 @@ describe("Windows migration ACL boundary", () => { expect(command).not.toContain("$actual = [System.IO.File]::GetAccessControl($path, $verifySections)"); }); - it("writes one new private configuration file through a separately bounded no-delete directory chain helper", async () => { + it("writes one new private configuration file through the checked no-delete directory chain helper", async () => { const child = createWritableChild(); windowsAclMocks.spawn.mockImplementation(() => { queueMicrotask(() => child.emit("close", 0)); @@ -184,32 +211,21 @@ describe("Windows migration ACL boundary", () => { "{\"version\":\"3\"}\n" )).resolves.toBe("written"); - const [, args, options] = windowsAclMocks.spawn.mock.calls[0] ?? []; + const [launcher, args, options] = windowsAclMocks.spawn.mock.calls[0] ?? []; + expect(launcher).toBe("C:\\Program Files\\Miftah\\windows-secret-job.exe"); + expect(args).toEqual(["--write-private-config"]); expect(options).toMatchObject({ shell: false, windowsHide: true, stdio: ["pipe", "ignore", "ignore"] }); - expect(Buffer.from(options?.env?.MIFTAH_CONFIG_ACL_REQUEST ?? "", "base64").toString("utf8")).toBe( - "write-private-file\u0000C:\\Users\\miftah\\.config\\miftah\\miftah.json\u000016" - ); + expect(options?.env?.MIFTAH_CONFIG_ACL_REQUEST).toBeUndefined(); + expect(decodePrivateFileWriteRequest(options?.env?.MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST ?? "")).toEqual({ + version: 2, + path: "C:\\Users\\miftah\\.config\\miftah\\miftah.json", + byteLength: 16 + }); expect(child.stdin.end).toHaveBeenCalledWith(Buffer.from("{\"version\":\"3\"}\n", "utf8")); - const command = Buffer.from(args?.[4] ?? "", "base64").toString("utf16le"); - expect(command).toContain("MiftahPrivateConfigWrite"); - expect(command).toContain("OpenDirectory"); - expect(command).toContain("GetFileInformationByHandle"); - expect(command).toContain("CreateFile(path, 0x00000080, 0x00000003"); - expect(command).toContain("0x02200000"); - expect(command).toContain("FileMode]::CreateNew"); - expect(command).toContain("FileSystemRights]::FullControl"); - expect(command).toContain("FileShare]::None"); - expect(command).toContain("FileOptions]::WriteThrough"); - expect(command).toContain("[Console]::OpenStandardInput()"); - expect(command).toContain("$stream.Flush($true)"); - expect(command).toContain("[System.IO.File]::Move($temporaryPath, $path)"); - expect(command).toContain("Test-MiftahHeldPath"); - expect(command).not.toContain("0x00000007"); - expect(args?.[4].length).toBeLessThanOrEqual(30_000); }); it("reports an existing configuration file without falling back to a pathname write", async () => { diff --git a/tests/windows-secret-command-contract.test.ts b/tests/windows-secret-command-contract.test.ts index 5e760a3..78749b1 100644 --- a/tests/windows-secret-command-contract.test.ts +++ b/tests/windows-secret-command-contract.test.ts @@ -87,6 +87,25 @@ describe("Windows secret command contract", () => { ); }); + it("routes first-run private configuration writes through the checked helper without runtime compilation", () => { + const commandSource = readFileSync( + new URL("../src/cli/windows-config-acl.ts", import.meta.url), + "utf8" + ); + const helperSource = readFileSync( + new URL("../src/secrets/windows-secret-job.cs", import.meta.url), + "utf8" + ); + + expect(commandSource).toContain('spawn(launcher, ["--write-private-config"], {'); + expect(commandSource).toContain("MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST"); + expect(commandSource).not.toContain("Add-Type -TypeDefinition"); + expect(helperSource).toContain("PrivateConfigWriteRequestEnvironmentName"); + expect(helperSource).toContain("WritePrivateConfigurationFile"); + expect(helperSource).toContain("OpenDirectoryWithoutDeleteSharing"); + expect(helperSource).toContain("FileOptions.WriteThrough"); + }); + it("contains no runtime C# compilation, compressed helper, or reflection loader", () => { const source = readFileSync(new URL("../src/secrets/windows-secret-command.ts", import.meta.url), "utf8"); From d2cd069e6ada37551ac865aa98fd6a3ed7fbba02 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 20:57:55 +0400 Subject: [PATCH 22/25] fix(windows): make private config writes recoverable --- src/cli/windows-config-acl.ts | 27 ++++-- src/secrets/windows-secret-job-artifact.ts | 2 +- src/secrets/windows-secret-job.cs | 83 +++++++++++++++++-- tests/windows-config-acl.test.ts | 17 +++- tests/windows-config-migration-acl.test.ts | 20 +++++ tests/windows-secret-command-contract.test.ts | 2 + 6 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index 80b673d..0ea00a0 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -7,6 +7,7 @@ const requestEnvironmentName = "MIFTAH_CONFIG_ACL_REQUEST"; const privateConfigWriteRequestEnvironmentName = "MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST"; const maximumRequestBytes = 12 * 1024; const aclCommandTimeoutMs = 5_000; +const privateConfigWriteCommitGraceMs = 1_000; // Node exposes no trusted Windows system-directory API. Use the protected default // system root rather than a caller-controlled environment override; unsupported // non-default layouts fail closed instead of launching an arbitrary executable. @@ -43,6 +44,7 @@ interface SecurePrivateFileRequest { interface PrivateConfigWriteRequest { readonly path: string; readonly byteLength: number; + readonly commitDeadlineUtcMilliseconds: number; } interface VerifyPrivatePathRequest { @@ -124,17 +126,22 @@ export async function writeWindowsPrivateConfigFile( ): Promise { if (process.platform !== "win32") return "failed"; const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content); + if (path.includes("\u0000") || !Number.isSafeInteger(bytes.byteLength)) { + return "failed"; + } + const launcher = await resolveCheckedWindowsSecretJobExecutable(); + if (launcher === undefined) return "failed"; const request: PrivateConfigWriteRequest = { path, - byteLength: bytes.byteLength + byteLength: bytes.byteLength, + // A forced process termination bypasses native finally blocks. The helper + // therefore refuses the irreversible move with one second left under the + // unchanged parent bound, leaving time to clean its secured temporary file. + commitDeadlineUtcMilliseconds: Date.now() + aclCommandTimeoutMs - privateConfigWriteCommitGraceMs }; - if (path.includes("\u0000") || !Number.isSafeInteger(request.byteLength)) { - return "failed"; - } + if (!Number.isSafeInteger(request.commitDeadlineUtcMilliseconds)) return "failed"; const encodedRequest = encodePrivateFileWriteRequest(request); - if (encodedRequest === undefined) return "failed"; - const launcher = await resolveCheckedWindowsSecretJobExecutable(); - return launcher === undefined ? "failed" : runWindowsPrivateFileWriteCommand(launcher, encodedRequest, bytes); + return encodedRequest === undefined ? "failed" : runWindowsPrivateFileWriteCommand(launcher, encodedRequest, bytes); } /** Backwards-compatible name for migration transaction directories. */ @@ -223,7 +230,7 @@ function encodeRequest(request: WindowsConfigAclRequest): string | undefined { function encodePrivateFileWriteRequest(request: PrivateConfigWriteRequest): string | undefined { const path = Buffer.from(request.path, "utf8"); - const requestLength = 1 + 4 + path.byteLength + 8; + const requestLength = 1 + 4 + path.byteLength + 8 + 8; if ( request.byteLength < 0 || requestLength > maximumRequestBytes || @@ -234,13 +241,15 @@ function encodePrivateFileWriteRequest(request: PrivateConfigWriteRequest): stri const payload = Buffer.allocUnsafe(requestLength); let offset = 0; - payload.writeUInt8(2, offset); + payload.writeUInt8(3, offset); offset += 1; payload.writeInt32LE(path.byteLength, offset); offset += 4; path.copy(payload, offset); offset += path.byteLength; payload.writeBigInt64LE(BigInt(request.byteLength), offset); + offset += 8; + payload.writeBigInt64LE(BigInt(request.commitDeadlineUtcMilliseconds), offset); return payload.toString("base64"); } diff --git a/src/secrets/windows-secret-job-artifact.ts b/src/secrets/windows-secret-job-artifact.ts index e3c641b..db168dc 100644 --- a/src/secrets/windows-secret-job-artifact.ts +++ b/src/secrets/windows-secret-job-artifact.ts @@ -3,5 +3,5 @@ * Regenerate the executable with scripts/export-windows-secret-job-assembly.ps1 and verify it * on Windows with scripts/verify-windows-secret-job-assembly.ps1. */ -export const windowsSecretJobSourceSha256 = "91d4b3a031d67d16d3f13a5e930267745b1d5f40efaffd61769fe4ba1fce01cb"; +export const windowsSecretJobSourceSha256 = "042ecb847c3ede5fee859098b3f5d89cbb5c7d4b4c16ae1822a3f75201804fda"; export const windowsSecretJobExecutableSha256 = "44ef7cca43d181cf92447ed4202d60a48cd8fa8d6b66305b9e80c3a1fa4bac28"; diff --git a/src/secrets/windows-secret-job.cs b/src/secrets/windows-secret-job.cs index 97f7b3e..731a7d3 100644 --- a/src/secrets/windows-secret-job.cs +++ b/src/secrets/windows-secret-job.cs @@ -13,7 +13,7 @@ public static class MiftahSecretJob private const string RequestEnvironmentName = "MIFTAH_SECRET_RUNNER_REQUEST"; private const string StandardInputEnvironmentName = "MIFTAH_SECRET_RUNNER_STDIN"; private const string PrivateConfigWriteRequestEnvironmentName = "MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST"; - private const byte PrivateConfigWriteRequestVersion = 2; + private const byte PrivateConfigWriteRequestVersion = 3; private const uint FileReadAttributes = 0x00000080; private const uint FileShareReadWrite = 0x00000003; private const uint OpenExisting = 3; @@ -46,11 +46,13 @@ private sealed class PrivateConfigWriteRequest { public readonly string Path; public readonly long ByteLength; + public readonly long CommitDeadlineUtcMilliseconds; - public PrivateConfigWriteRequest(string path, long byteLength) + public PrivateConfigWriteRequest(string path, long byteLength, long commitDeadlineUtcMilliseconds) { Path = path; ByteLength = byteLength; + CommitDeadlineUtcMilliseconds = commitDeadlineUtcMilliseconds; } } @@ -352,8 +354,9 @@ private static PrivateConfigWriteRequest DecodePrivateConfigWriteRequest(string string path = ReadString(reader, input, utf8); if (String.IsNullOrEmpty(path) || path.IndexOf('\0') >= 0 || !Path.IsPathRooted(path)) return null; long byteLength = reader.ReadInt64(); - if (byteLength < 0 || input.Position != input.Length) return null; - return new PrivateConfigWriteRequest(Path.GetFullPath(path), byteLength); + long commitDeadlineUtcMilliseconds = reader.ReadInt64(); + if (byteLength < 0 || commitDeadlineUtcMilliseconds < 0 || input.Position != input.Length) return null; + return new PrivateConfigWriteRequest(Path.GetFullPath(path), byteLength, commitDeadlineUtcMilliseconds); } } @@ -400,7 +403,14 @@ private static int WritePrivateConfigurationFile(PrivateConfigWriteRequest reque ancestor = parent; } - if (File.Exists(request.Path) || Directory.Exists(request.Path)) return 2; + // A prior helper can be terminated after its atomic move but before + // Node observes its exit. An already-private exact match is therefore + // a safe idempotent completion, never an overwrite. + if (File.Exists(request.Path) || Directory.Exists(request.Path)) + { + return MatchesExistingPrivateConfigurationFile(request) ? 0 : 2; + } + if (!HasTimeForPrivateConfigCommit(request)) return 1; SecurityIdentifier identity = WindowsIdentity.GetCurrent().User; if (identity == null) return 1; FileSecurity security = new FileSecurity(); @@ -431,6 +441,7 @@ private static int WritePrivateConfigurationFile(PrivateConfigWriteRequest reque long remaining = request.ByteLength; while (remaining > 0) { + if (!HasTimeForPrivateConfigCommit(request)) return 1; int count = (int)Math.Min((long)buffer.Length, remaining); int read = input.Read(buffer, 0, count); if (read <= 0) return 1; @@ -442,10 +453,10 @@ private static int WritePrivateConfigurationFile(PrivateConfigWriteRequest reque stream.Dispose(); stream = null; if (!IsPrivatePath(temporaryPath, false, true, true)) return 1; + if (!HasTimeForPrivateConfigCommit(request)) return 1; File.Move(temporaryPath, request.Path); temporaryCreated = false; finalCreated = true; - if (!IsPrivatePath(request.Path, false, true, true)) return 1; result = 0; return 0; } @@ -475,6 +486,66 @@ private static int WritePrivateConfigurationFile(PrivateConfigWriteRequest reque } } + private static bool MatchesExistingPrivateConfigurationFile(PrivateConfigWriteRequest request) + { + Stream input = Console.OpenStandardInput(); + FileStream existing = null; + bool matches = false; + try + { + if (File.Exists(request.Path) && IsPrivatePath(request.Path, false, true, true)) + { + existing = new FileStream(request.Path, FileMode.Open, FileAccess.Read, FileShare.Read); + matches = existing.Length == request.ByteLength; + } + byte[] inputBuffer = new byte[4096]; + byte[] existingBuffer = new byte[4096]; + long remaining = request.ByteLength; + while (remaining > 0) + { + int count = (int)Math.Min((long)inputBuffer.Length, remaining); + int inputRead = input.Read(inputBuffer, 0, count); + if (inputRead <= 0) return false; + if (matches) + { + int offset = 0; + while (offset < inputRead) + { + int existingRead = existing.Read(existingBuffer, offset, inputRead - offset); + if (existingRead <= 0) + { + matches = false; + break; + } + for (int index = offset; index < offset + existingRead; index++) + { + if (inputBuffer[index] != existingBuffer[index]) matches = false; + } + offset += existingRead; + } + } + remaining -= inputRead; + } + if (input.ReadByte() != -1) return false; + return matches && existing != null && existing.ReadByte() == -1; + } + catch + { + return false; + } + finally + { + if (existing != null) existing.Dispose(); + } + } + + private static bool HasTimeForPrivateConfigCommit(PrivateConfigWriteRequest request) + { + const long UnixEpochMilliseconds = 62135596800000L; + long utcMilliseconds = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds; + return utcMilliseconds < request.CommitDeadlineUtcMilliseconds; + } + private static IntPtr OpenDirectoryWithoutDeleteSharing(string path) { IntPtr handle = CreateFileW( diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index 5f88e1a..92a1e26 100644 --- a/tests/windows-config-acl.test.ts +++ b/tests/windows-config-acl.test.ts @@ -52,7 +52,12 @@ function createWritableChild(): EventEmitter & { return Object.assign(child, { stdin }); } -function decodePrivateFileWriteRequest(encoded: string): { readonly version: number; readonly path: string; readonly byteLength: number } { +function decodePrivateFileWriteRequest(encoded: string): { + readonly version: number; + readonly path: string; + readonly byteLength: number; + readonly commitDeadlineUtcMilliseconds: number; +} { const payload = Buffer.from(encoded, "base64"); let offset = 0; const version = payload.readUInt8(offset); @@ -63,8 +68,10 @@ function decodePrivateFileWriteRequest(encoded: string): { readonly version: num offset += pathLength; const byteLength = Number(payload.readBigInt64LE(offset)); offset += 8; + const commitDeadlineUtcMilliseconds = Number(payload.readBigInt64LE(offset)); + offset += 8; expect(offset).toBe(payload.byteLength); - return { version, path, byteLength }; + return { version, path, byteLength, commitDeadlineUtcMilliseconds }; } beforeEach(() => { @@ -201,6 +208,7 @@ describe("Windows migration ACL boundary", () => { it("writes one new private configuration file through the checked no-delete directory chain helper", async () => { const child = createWritableChild(); + vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); windowsAclMocks.spawn.mockImplementation(() => { queueMicrotask(() => child.emit("close", 0)); return child; @@ -221,9 +229,10 @@ describe("Windows migration ACL boundary", () => { }); expect(options?.env?.MIFTAH_CONFIG_ACL_REQUEST).toBeUndefined(); expect(decodePrivateFileWriteRequest(options?.env?.MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST ?? "")).toEqual({ - version: 2, + version: 3, path: "C:\\Users\\miftah\\.config\\miftah\\miftah.json", - byteLength: 16 + byteLength: 16, + commitDeadlineUtcMilliseconds: 1_700_000_004_000 }); expect(child.stdin.end).toHaveBeenCalledWith(Buffer.from("{\"version\":\"3\"}\n", "utf8")); }); diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index db6e7a7..5297e69 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -519,6 +519,26 @@ describe("Windows migration ACL contract", () => { 10_000 ); + it.runIf(process.platform === "win32")( + "recovers only an exact private first-run config after an unobserved prior completion", + async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-windows-private-first-run-recovery-")); + temporaryDirectories.push(root); + const privateParent = join(root, "private-parent"); + const directory = join(privateParent, "miftah"); + const path = join(directory, "miftah.json"); + const content = "{\"version\":\"3\",\"name\":\"first-run\"}\n"; + await windowsPrivateDirectoryProbe(privateParent); + await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, directory)).resolves.toBe(true); + await expect(writeWindowsPrivateConfigFile(path, content)).resolves.toBe("written"); + + await expect(writeWindowsPrivateConfigFile(path, content)).resolves.toBe("written"); + await expect(writeWindowsPrivateConfigFile(path, "{\"version\":\"3\",\"name\":\"different\"}\n")).resolves.toBe("exists"); + await expect(readFile(path, "utf8")).resolves.toBe(content); + }, + 10_000 + ); + it.runIf(process.platform === "win32")( "accepts a private child when only the terminal volume root grants DELETE", async () => { diff --git a/tests/windows-secret-command-contract.test.ts b/tests/windows-secret-command-contract.test.ts index 78749b1..49e94d6 100644 --- a/tests/windows-secret-command-contract.test.ts +++ b/tests/windows-secret-command-contract.test.ts @@ -104,6 +104,8 @@ describe("Windows secret command contract", () => { expect(helperSource).toContain("WritePrivateConfigurationFile"); expect(helperSource).toContain("OpenDirectoryWithoutDeleteSharing"); expect(helperSource).toContain("FileOptions.WriteThrough"); + expect(helperSource).toContain("HasTimeForPrivateConfigCommit"); + expect(helperSource).toContain("MatchesExistingPrivateConfigurationFile"); }); it("contains no runtime C# compilation, compressed helper, or reflection loader", () => { From 0e07743d03968c3a966f1ac9af2335ee4ccbea48 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 21:04:47 +0400 Subject: [PATCH 23/25] build(windows): refresh checked helper artifact --- assets/windows-secret-job.exe | Bin 11264 -> 18432 bytes .../export-windows-secret-job-assembly.ps1 | 3 ++- .../verify-windows-secret-job-assembly.ps1 | 10 +++++++--- src/secrets/windows-secret-job-artifact.ts | 2 +- tests/windows-secret-command-contract.test.ts | 14 +++++++++++++- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/assets/windows-secret-job.exe b/assets/windows-secret-job.exe index 5b1a24d8038a7c824b323ef92ab9463eeb3cbcf2..60dc1bfabaa43d47d9a421ec19287bb876bf7cb5 100644 GIT binary patch literal 18432 zcmeHudvsjok!RgU-@e`IZuPw_ceiW{x1VXOZdsCrF<^sbOU42}ge5;95Yv*b?Y3Ke zML+z&I1z`yKnNse7f!Uw!q}SNC>n$4wt3jfiwyufI<86z=@lB=G+X zX3-p5^5qzPA@tnRryOtf9m=$Z!)9chgABT|(%rH$xna7>ST_070vaNUZFt;^u1dUgX7`}t0f z;R2sO8g06pC@%l!?mp>>&@bOfbUov@VyJ-6Y`x7AMOx{%i)WgqOAizDwwAsn3yFtk(1FL!})ygC&vF%nM2ChxGT0Wb=>8Us+7lLBzEE-wYR$MKgO+=ep zNI^5~SA5tul>+pa{X_$o5-Ff8$Bk?P3L<7ckV@$1uMB1v zfQ$xnX2|1YvzLJ3;fu{v7)>`%r2s?O3>whKSj9cb(pR&s2oa)7srnHzV5Y)zU<59- z)GPupoKrhitJx6SXTW3z8D51x@RO-(7!7SkvWk!mN6cUqix7^6+$e6T9*~8 zWCpVxGQ6edIxO9dp*`1T=}EU8Sh8OvA7n`v7KIZ5E0F9DS%J(fczprrIbXE`4?w$; zFp`O2cXv~zX_?SuHZ+OswU%K8E%OsrFttAs1RCi7E7P^0*>thA;U1A%YU!3yUCDNZ z&Ub`zM#zP8!!MXQ!}KsK1Sctr*&kQZ7k@#RE#v+4&DT6U0e=mEr?J1p^stfa?=-|k zy_P1UX)^MS#*sDfnXVcxToHD$aN*U7UX>Q2KXQ16{p@VZDYz(XhR&YA1AtJKBy`5pE5}oJnftG3)3z$|g5$e9oGOdt{O&E5U007T$W7rDkF0sPW z;I}W5l#n;Aa3W-d^53*fcPaNU-DTWkVRyH^FuR=5HmfZQ8;nH6iew>UC}N~N-}-0%53@9&^zSu~Z1bq`rlk7~Etb5~jI zo}E#-`F5^^2u~|z#gZ+m?t!XgB4)KGqE+Y_ZSx6hQOaLKpy>v$GusQ{#A17KadbWZcKDo9mylT zPgu5l1(*wZMD}+u$I?^ol`N;bSK)?$4kqWV{r4f%YRa>qgZ@fwK#yc36-_1ryhfiz zQzB)h+>PL8xJEj#WVB~KBl9H)^=PSXC7tLze+q|7=lMFae9$+*3gnQWW5HZoFncxB zoYz+2@CoD+pRxDfWj8UJw$c*tITHwypRo!pgJ{X0L!ftENFs6i zf&PNkxta!_;QUPVAzbhleHT{@SL1`^PZ4a6OK>4f2=M~{%6TvR9lWJLgDL#Opm7S) z4DR^fj(K$4s`woUqa3fB5e6wKeF-o>#x6V_<@T+YdgQnj}VV{LE#k9c0Rs} zVK|sGf)dLwSZ$ow2l>FoCyXls|0QuX){K44d>g#QxESXTpR>p-ggVB`ZeicqN_GhM zOdQK~nER8q1dz}@Qr>P#kZ zD0?0AIc(7D!4C?|qSeTy^;~Km+bdi17$oFwhj6mG1CW=|-3bJ{*xkkVwm>c%az}x( ztd+gaSaEy(Csv*-T95eN$jPSWAF?lzO zgxqn6h`9)}kc)*0=SQs88RW0D!WB(v)Z6)XkclRVIx*z#MX&UMyajY0P|G-wlNekH z6ZV5~Y_YBPkCd{oW%*yUN+)yjb;wK}z0;^8T1i(twUCLLKs2SUsXw;N$&4o()T%~mfg zK%}X*dIw@j&rpWwG}co8E^}q|vp1nfG?c~TUbxK+CRtmzu_P1>WwBlGtdPX>!1YEJ z5octPpM1_0#w@0%%P?VnWXJ|R18!jhj9~U%xcfEeYvMG*Yt%Fhbar&4SH|bU^>i!~cEmQhocB%Ra!b!$ zhbc?7AujZCrrG&;5+x+dymAfNh={g31N_2#xB+ zqwMx9!pq2otoF?7=(A3Gf0lcmW~)XGpW1hw|FJ>3Kcl zmVrmwtc4*>*|wpUZbnF0c)GztxA4t|4=gVrJym97dM{XUB%DD!Q>d`^W~izl$?Q&mIEPJvJ7C^+}!i`DU+G4F2Buj%hm&%Gw_w04_j zkgGeoL3aqePiM}j%~{hRJ^a&K;l(n6X zvYw}f^KyVWOJaJ=pj9!(*9g2Ga3B2%Cw2OwaGnwP)7ZBmN$m``wrAT78WVWCa2^)? zvjV>&@Hv66w6hI~IP1Se;5!8N3EUXx)_nqR7I;eFhotqB0{^XWo)dUhIR6mm(Yq{$ zYb@3k`!e;N?l3+c5nqaT~TAceLP5`(<8YnzOgG$m$p^&jbr4c1KK0i^Z3D%h34~w(arq8H{YU>EREWkv-7u>uVZfBY zoRR{*Mc}Oh?^Di#^I7FQ;8z8HSKv8;uL_K*+`3ZWH3AO`yi4Fi0{@M`X8`-?zo==& zqQ3)NMc)ae;DfV)sJe(XdTXqPI%CY96HC=%Gx?`myr3w52g9x>DCw^!SOHEmG$Yu78B^StpM)!wCaHK`}H zDZHC%v^}F8Rd}CxlHbv06h6Z|>a2FV!dcX#UefMXx|`H*wf8GMO=_Y3u+rb82K2|2 zD`+&sj7Mm<{wd{3>X1Rs&_4aol&fe+2025I=$}_M(0hIAas4aGHS~l}eNlf#*@U#u zTF%h7^=Co-!KeOS|F$wnvr@~Sp-|v?Wi$QvOFhXxP+MrlYLD6#_<=G+_xjXrfftlv z+LrU$-V^wVGD05`>Jd5<__=aDmGa#72z?~*3aArJ>Q!Y2-O4EhwmcSCtBleeKJ{ea zf^q}>C!hLyz);8NlsMx|`kIbKfjxAuQ1_%?3RvnM`dE`%q~eJW*{K&xtkTth3U(#q zTYzgc4yeU0fYfJOn4i2;K&4QSFyz!1GJtl}(u1UT#AzMm1iB0B#<@UKHgq3-~y^aF4- zdJ#~kUrMXj>s8_Z0Wvy;@DPJc4A{`Q6u3sKgp&ir_Zv-4LyyO28I48&bs7T<$S4Ng z2s}uK02N|Mg(?E?5O_bJN)G{Q^daGVRG`=T=YqFF_ASm3neUgS*Eaxl`k_ejxGKFY z_-lfDW6j|j=_rG|*(o%~SMZ#{eLJPKmG6zU41BChWRPA8T&0cxUZdV1_>F)=YJq+e z8BuSerO_Jz*GKn*vnzT;y_1~iSCl@;xay^Vb#*D=9f0>l-wT-!MDGQBH2QvUJ^}nb zdLnuT@B@G!re~v9f&X0e)9M-eLG(%Wae5Itm(nX}eVl$9{e~KWwyV_70}cUhjQy?p z4BZs_iP|CjXJ}IJnb<4p(^Nxi2fZhD72rd$->8e}v6!Ysh+7xa6ERbJp1vGAqMjA_ zBE1@mXfINt-O^sB#qCM$SEBPZnrL^`*XaH2muMm7rNByUsq&-teq_i0*?tA!@7u4@ z3Z%z}w8yC}ex23_nNjTqWjH>i6_i4}sNF|S+yy)mKMr^gX5FXE#NPv)=leVzi@#SZ zDtE{41%4*}K3K@st`KWih@GtS2il#=Q}NdT|3e%eQ@$5ZE6>x<;t~CMidYH#EZ}1O zGs;$LmHry-x7O&2Qm}pk%a2;?^{1g>BjBg4A+&zgGPT#}Ea114RrYoIS>>VF9{ok- zvDiEHmzC4@0sU9X$7~1qv-VLvr2JR=Hng(G?o$@GU#ckT|FZv;uBaF6qrlq|pV049 z7ABaVO+2PNP6LT=sNDKA_=AaOz`ri>HQ*dYo#NF_+MD=W-BNiqJJp39zt#)1y5kQz z^VL9sF6%G@j7I|@k?(`#l7NZGk0K+OcyAm-eZLSej_M^w9ds{tV47Y)rdW*D5M}5^ zz!d_s^mo8>sGPZC`X%51y$ZOVeh0XLUI*Mnx)P!x3IT4T7~poCmOZH0R|R@dk*x{b zrL5Hwih}IKwf(K?!|E5*r_`^i-%=meeyY8$8TxVkbNaLTPxX$#Uq#NUKU4ov{eU)v zGxg`HLYtHT{a)@tN@zHBF+vXaP54#A)wmDgh2V8Ux6@vsExKRm1U)ME1@t8Dglg;= zp*!h2a$iJe<=#av$$bg^R_;q_q0aKlX#n?shjU>!>9a6?-sdye=~58*T!nrtn1<}2 z;|kz1a0PLhxI(zXxY}?|cTF5|CTp7u)#Bv# z;&ib#QaY>!#AI-In@SluyUwA z?UZX`EBO+`w!nDH{m8S}osgd$by>@ZqTRP}%3+{!F*ke4e-A-X@u!g7J zi8@=!tg*v|itxqzv}?vG51%MjYj`l0_N}GDq0NQKqxG3FXSz_X6(_xZ%xB@<&P<_F zbw=G{xh5T&`iF`YET3CBIiEeR!#}xpTS|p$%M#1dUSBMgc9qd}OUbP|w58%8gm$|7 zBmk)q1uY9bT5+oIwzu{N_f(y++SE2Uy#yb4cb?5i`LKfsq>=KmLaFEr$THIr1gA3^e>Fx-677xSX9 zXPkdBipCFP4dx67t4jpNe*L7c!x8M=z{J_j=Pl*76{_RKX$LDaPh^WbjSMH-ueopb6>Epx zdd+he?^B-INY(E*TBsccHD0MBElrI~A*_nElZf8N!tgX9fCz2`GfAlMPze6*#j-<# zGuXmYjWmV5<4o3Tg$b;BgIA><-ozxsadO-4ln-HK&)^|Ax&%dgYLh##D~m|}Zh5Lo zqYkDgsTI4e!V{LA%Y5vI`8;Xi4T!W^EORQ|=T?qlt_Zv_1Xv@PUc?=otQC(n!r0S3 zc<>+;zzJ}Xi>-Xp%N}D`7@Sei=+PVLUm>fyIr@V0XW&$U*lA})H=sYQSa0<>W9Bm2;XL5o{GrN)OD#y@Xni+Dc zM{Dj3u<>FIi#m0DKdu}3E}jMM-}lRGwjju=EHRHle3zP89j)G49UnXF9`|$Z)v1?Ze|EJBGa!hx3qQlC~GBwZTcQ0Ggykf>3+i z5)N;uLY~`MpPoR*;hoM7+g3buxJhhxkCWHF>mWAOQSW>lK_zmcDJK*Q9&smHtS|HV zUz{szy!dQkv^M%1Z^qkKbXX2Bn&C7sBKrUdTCfl@noNe3CYkB~@FBJb$3p18cf9Jvsg%$epD2@()+| zW@?mAzvEnr3N{0>#o#I@q>1`L?0=fzQg7Uqi17$UQG=3u%&C;%32egYLiMQUU@A1+ zE5Y8Rygbq*3Drm4p_z(vtmxJ`eS<RwPl}aPiGj63NdXGAlvQt{y-!oMz`Q?>=6Sb9M729R_wtAsdrIrf372k_NRf8hAjJoY{jS zqC8ogDU`@N<*Lot#}Eyj4Qa3KU=dq~hvW)}+?fLsDqN9VtlUf0Sm0X4EqPP-$MqjD z8;UtnJmKAV%=Y&qC+&tcyB*{rqyj0_z0E7flz4KkXN!Ax%}SB=^U5)xa&tFW`Tsy`*VV8O3~sK<&4TfBs@Nrq;=PfLcdQVmr+KU~nruDNC&+?y;7;YgFiT^6Y} zxf4e+qvOR%oZHwYqqPd%gc3sRZ043KM^y_o&vo#XHyxIdVxqCE&Dd(XQ{3z)D#rbD z2R)hMeLlzW){?FB&HxBC^q{VrtQ2Pu?%o3to+hSgcj5RO+nGB!S&~U&o8vrXTk#Cl zU=CJ%-*9+Uw-m~58OOKRVSd}(S%8(EcFW!~Wn)MhtmA2=QoP+u&`>s)55#-o85yLp z8Ri74vc$W}rIT|!PIxrZvX$R3fK2L*+rV1`9!Uh|Es6La6PYO-5<9)Rf_?m^ObJ`r zNdBJLf%^C`yu&9ka_e9Te`vGqTKp`4d9_M?634VN?s61i_m7luvf}Xz`+sZ2aT6Y%s-2-@2jKUvpL5IOPfcqZWiGRbum>Q-Vz!`=VeolGW+waNp zLl|c#(GBx^ZJ}M@ZbjR*7-1A+jL=@#GLBIW2!Fd^`=rI=USx-c?>04P4)tJu)0i!_ zk73q*xYpw8C1oDnM~c;Gzf9!TlhWDBS&P5Y$>VQk2JmlwU(lypCD%iHp00rGI$*tc zTh9Mj>ss{f6TBbV)`7ahA8Rf6y=dpYJOW$FeR$L>M3yD;!20QOU;}{b0a=HFIZntr z@@ywDTV8OYB@cXZ&!rQ$ZT;Y}AKriO|42Pa=9g~2d2i>ykM2_qQ^$*Y(q{=@W*M>d z^WFdUmwVvtcbr@8(fOVJ`HQU@0Yy`dkgj1m{8C*7$l$vrA%W}o9uN%~uN8C08+jey z?%}HomNwFOS)WeFg7`sHI+PB90tsbl2&w5X1=Fz*_@Ojj=Bw$rm9z2ls+b*3r`1q8 z7PnT!E&NChIDW~(0wMInZ_c1Do{p!}_>ogUO?L%C@le_z4P%5bzM3}8I4kEd`GZah zEK$s^7@!@Ag-DCxC#DPRPS9O}7%VcmCC=@p9S2lx{19C6OpmFm%BIIy5WjFx%~)5= zB#m!f0hn$_fq}08(F~v&RRKrEPk}R<5(|e_qf<57G{fq^g&&Rtam(Q9(lVsz8AUM} zz)u=O>d4-4!)N?Om5eG62^gv9WQ zk{~lf3oHjnfNwF#BnUDbAoy+$J?gxqcHOQUkhf>=jKCAPS+n<9vk!Q@yYbsKYxZH7 z9An4BWAJh;7E5G;O3a%5sK`EUTC<60CU`B(PrinYxM_kA211*?^0yAMz zdN6p2SdYcCXFqJ(H`=!_9kXw-Z;7StTT~!*`=mYG6$qQAJ^OUpl$ms4{s3c(TfsYGc||YZi#d)wDYNwJD=D+7wcG#4|DX1ND!+lv5Y6KEwm7S4 zcK&oYy70T$x8UQq#cz=3{ueR9VJ$=-#P!&%L^YKu?~|S+`nlIEjG8)t`(D&(2LQQx z8$(^imDT~N!nOkE&sX(dTxjSOh7b4`m;BLjwWt-|knr}R3c*`m)Q~0Am?Kh8y3)Qv zdX1yC0B#lSTz3|w<`e$WFXs%4L&Ek7qS&d&)L$8zv7#_c#Tq!=h<-OT*18n`*#9PgCDF3v=^lUvT`g&jv9!p4~KNRD#8sf3hFC(l5U9 z^$pjYm@Z}bT@#+3H!kbz>0OqQ*RS|eY2z|{_n2S5EaSZgExBc9mKj}_iS*T9;9P3+_!IyKzgZOd8 z-qz8eDwAm{YdrMMvj{(UhgJNQ!p3DgPT~==@gBYhZ=#p2@igEWwJKj*zfF7kmq|a2 zUG?8j`7}8Gn`CE-pHT6R<&aZ-n_Aa4v}W*`)8l>p2oG<^YrRsY#Bk%X0)C!x%*C78 zWtn=>dz#<4>>%F2I{u_Y$(lE@reXS;dA4s@({us^Hmqr^5C~rFKjIIPIervGn>PFr K`{0jw>i-*AI2;uK delta 4826 zcmZ8ldvH|M8UN0?kG=P9cFEmMb~hxD&4X|WoA(0(0!avpRTKm*;4@0B(S!{oS`otr zs}>(n(rX>XvA(E)!;BSd@ijhXtk%akYFpGPDr22#ajZ&99qS+1-}&w)Qt!1^Rs3^jriiuofZ$cuwULK({rG@e%kMbIv@Hb;nbYjYH6dkLCHCOVllsw=sAEs{Zc7=eaI$wVfi zv{B=RR7rXvm@=}cR(h6Sr=@Z>c-Tw~JCFrdXc#N$MC@zeY~rnP@IC)6_}S=xW?w%c@vw%&QwPVO^S8EUc3)^2hY#dXx&f?C9qXO|Y9-y3$6S z)O4|R?v`SB6nR)d(=nM~wbj}sYMF&&@?fF^)o8^UqpqQAGF+*M%WPY4d|LiFk$bPVEnuqETBR{>+mTCW5mVkcbPt3=CI>YSSLBy&=0F>yt$L|Wof z7_ORdM1Lln4}*teVFu&8gc6DsoGeDg$ymXS?W#glTdhM-A*0pMxp<& z-_f6Fs&Wk-jaW2DAJ>4H3AxHv&nx*491GV_mzr6_E^#AfZJvuki`AWC*5p}i3NDsh ztTNXDQxJ7UcWe`2#@Q`O&b{1EX6AqPM)MA>+)z0kJP&dyf- zPU$wgNSX5L&_u8xOU|Tu|Gc2{54zSi7jyZJsRgOMKzbQOmf9$?*QGwHUNyDkKU*{- zH-KqXZ6SIe@>6(MtsL3dzy2q~m@qwux70Sss`FP;4~k2@hN{ju|I8l9Rp5Wbd8_Ik z$X*Vv9#HY)`(>+*R}XzVqlIwcq4o*a7$G!asR#u?-T2kv2e%yIB4j6w&k|L^Qgu|n z3B7Vx9NR>%(u=^i7|)iS(xio2t7g(!jH@-J=?+zeOnS)LYMJyB<8e!ATwC>>#@J`) z6uE(kZ>tcyY}NTNYfdsI9OYBWxX4i4-K6Ji%z`+ps2aeF&OajEh)vUQXW!!-Y)x88_*}aJrN6 zLB=N;Ut#=!@pJt;_(7)AY{39;FurG~bpOWqPsVQ;Z^w=zAyZjnjHQfKri$MHjEmLbejE^@F&a-F z75jr~$T2-G4)8e$jJAA6U0Qk9O8Fw;wVh|J^5s+5TKSqfM0s1kJVH3Jc{VCPQRgqu zcFJRf3oFm=mtPR>J3Kj+&0hHp;iChOVh5xqDks^i(h;~K^R{C$E^xW!nWjw_jkG+i z_}x^YRf#4#!EBsrv{|B=syWa&^=S>Fh1TWSkk%pE=}yIR=yFP;?q#L7^1%e zE&4amCOssvF@@MgK`MfzbGdz^sXj9<;&vZ=M2-E{`O4))PG&N13Y7di!UoUVp> zA6@TE7f;Y_&VJzCPL=GZCmoMH8{zY;(}woTP6yg*8YRG6fNwkMXsY%*bXRx~y>^GM zl}f)E?Z?AgxqYj&bL?~sc#pKOHxZPOMKOv}0Zj!K(n^$55#0ey(N16){R)_-UBGI3 z0O-+fs;4;pTGr$3u}3ZzEn+tb6sCIZJ}ll7UyErBO)y#Ya1t7t{SLleCv*2_0ZrO0Tk!17 zy~GrR>n9OEz5FJK$g1Qs-B- zEooogJ8~7BIjqEa+3ZN)Iy$3oWXbg-ed~w&v+F5{vN$_9)Vr~N7z*w^?2oi{RrfS@ z&uwp7+*&thNlSNK>)iI9x~}#)U3G0sx=&wndUxBL#-29+gSMrzsl%^ozX22W-)JA2 zY1h-`)Jr3{QtIgn8l-i+5-;~Y_4fQNgG(R1;gT1={N2!B{0HZ5)1+zpyE+!eWBAaE z#v-Ydv{TVSH(lsf`zJfD^taEeZG{c(d7UEYV%@9PHmL{R6g|HMv@fM3l-KG$} zNtFEM{0ePBtjn*SKM$WOv1RTGgM<}ZW)%ueOsAC89pR>|kdaD3NS)9NeN~K$K~gDv z-r>tg6DccI5{gHoDLhiT)evqv7O`z>TGTSqA{NoCkRFRfL6KD^g#QA?2Hg!XS<^J- z;6J*c#6&~5oBUT6)JFf@$&118MW=sq!Bq1os}4?G*ed*J=XKKtg34i+!YhbALoH4` z1O7vu4~RGYuC6Mv*YE2Z*3PDc|JSZ*;X&%BE3xRkbd_&(SNLAHr>%l*W%rC9+79mR YUZagST6QPlz~X5-=$`(i7;g;y572ANBme*a diff --git a/scripts/export-windows-secret-job-assembly.ps1 b/scripts/export-windows-secret-job-assembly.ps1 index c0ba7ca..c07c949 100644 --- a/scripts/export-windows-secret-job-assembly.ps1 +++ b/scripts/export-windows-secret-job-assembly.ps1 @@ -4,6 +4,7 @@ param( ) $ErrorActionPreference = 'Stop' +$maximumArtifactBytes = 20 * 1024 $repositoryRoot = Split-Path -Parent $PSScriptRoot $sourcePath = Join-Path $repositoryRoot 'src/secrets/windows-secret-job.cs' $compiler = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' @@ -23,7 +24,7 @@ if ($LASTEXITCODE -ne 0 -or -not [IO.File]::Exists($OutputPath)) { } $artifact = [IO.File]::ReadAllBytes($OutputPath) -if ($artifact.Length -eq 0 -or $artifact.Length -gt 16384 -or $artifact[0] -ne 0x4d -or $artifact[1] -ne 0x5a) { +if ($artifact.Length -eq 0 -or $artifact.Length -gt $maximumArtifactBytes -or $artifact[0] -ne 0x4d -or $artifact[1] -ne 0x5a) { throw 'Windows Job Object executable is invalid or exceeds its runtime bound.' } $normalizedSource = [IO.File]::ReadAllText($sourcePath).Replace("`r`n", "`n").Replace("`r", "`n") diff --git a/scripts/verify-windows-secret-job-assembly.ps1 b/scripts/verify-windows-secret-job-assembly.ps1 index b491c10..4b2da6f 100644 --- a/scripts/verify-windows-secret-job-assembly.ps1 +++ b/scripts/verify-windows-secret-job-assembly.ps1 @@ -1,6 +1,7 @@ param() $ErrorActionPreference = 'Stop' +$maximumArtifactBytes = 20 * 1024 $repositoryRoot = Split-Path -Parent $PSScriptRoot $sourcePath = Join-Path $repositoryRoot 'src/secrets/windows-secret-job.cs' $metadataPath = Join-Path $repositoryRoot 'src/secrets/windows-secret-job-artifact.ts' @@ -22,7 +23,7 @@ try { # equality is not a stable provenance check. Validate canonical source compilation independently, # then validate the checked runtime artifact and both committed fingerprints. $compiled = [IO.File]::ReadAllBytes($compiledPath) - if ($compiled.Length -eq 0 -or $compiled.Length -gt 16384 -or $compiled[0] -ne 0x4d -or $compiled[1] -ne 0x5a) { + if ($compiled.Length -eq 0 -or $compiled.Length -gt $maximumArtifactBytes -or $compiled[0] -ne 0x4d -or $compiled[1] -ne 0x5a) { throw 'Canonical Windows Job Object source did not compile to a valid bounded executable.' } @@ -58,14 +59,17 @@ try { if ($artifactHash -cne $artifactHashMatch.Groups[1].Value) { throw 'Checked Windows Job Object executable fingerprint does not match its runtime artifact.' } - if ($artifact.Length -eq 0 -or $artifact.Length -gt 16384 -or $artifact[0] -ne 0x4d -or $artifact[1] -ne 0x5a) { + if ($artifact.Length -eq 0 -or $artifact.Length -gt $maximumArtifactBytes -or $artifact[0] -ne 0x4d -or $artifact[1] -ne 0x5a) { throw 'Checked Windows Job Object executable is invalid or exceeds its runtime bound.' } $assembly = [Reflection.Assembly]::LoadFile($artifactPath) $type = $assembly.GetType('MiftahSecretJob', $true) + $nonPublicStatic = [Reflection.BindingFlags]::NonPublic -bor [Reflection.BindingFlags]::Static if ($null -eq $type.GetMethod('Main') -or $null -eq $type.GetMethod('Initialize', [Type[]]@()) -or - $type.GetMethods().Where({ $_.Name -eq 'Run' }).Count -ne 2) { + $type.GetMethods().Where({ $_.Name -eq 'Run' }).Count -ne 2 -or + $null -eq $type.GetMethod('RunPrivateConfigWrite', $nonPublicStatic) -or + $null -eq $type.GetMethod('WritePrivateConfigurationFile', $nonPublicStatic)) { throw 'Checked Windows Job Object executable does not expose the required helper contract.' } & $artifactPath diff --git a/src/secrets/windows-secret-job-artifact.ts b/src/secrets/windows-secret-job-artifact.ts index db168dc..bf7cd98 100644 --- a/src/secrets/windows-secret-job-artifact.ts +++ b/src/secrets/windows-secret-job-artifact.ts @@ -4,4 +4,4 @@ * on Windows with scripts/verify-windows-secret-job-assembly.ps1. */ export const windowsSecretJobSourceSha256 = "042ecb847c3ede5fee859098b3f5d89cbb5c7d4b4c16ae1822a3f75201804fda"; -export const windowsSecretJobExecutableSha256 = "44ef7cca43d181cf92447ed4202d60a48cd8fa8d6b66305b9e80c3a1fa4bac28"; +export const windowsSecretJobExecutableSha256 = "a9cc1bb0c671bbd5671b53792caec66c382181c313183603478a330ee85d46bb"; diff --git a/tests/windows-secret-command-contract.test.ts b/tests/windows-secret-command-contract.test.ts index 49e94d6..a8a80cc 100644 --- a/tests/windows-secret-command-contract.test.ts +++ b/tests/windows-secret-command-contract.test.ts @@ -61,7 +61,7 @@ describe("Windows secret command contract", () => { windowsSecretJobSourceSha256 ); expect(createHash("sha256").update(executable).digest("hex")).toBe(windowsSecretJobExecutableSha256); - expect(executable.byteLength).toBeLessThanOrEqual(16 * 1024); + expect(executable.byteLength).toBeLessThanOrEqual(20 * 1024); expect(executable.subarray(0, 2).toString("ascii")).toBe("MZ"); }); @@ -125,5 +125,17 @@ describe("Windows secret command contract", () => { ); expect(source.trimEnd()).toMatch(/\}\nexit 0$/u); + expect(source).toContain("$maximumArtifactBytes = 20 * 1024"); + expect(source).toContain("RunPrivateConfigWrite"); + expect(source).toContain("WritePrivateConfigurationFile"); + }); + + it("keeps the checked helper export under the same explicit artifact ceiling", () => { + const source = readFileSync( + new URL("../scripts/export-windows-secret-job-assembly.ps1", import.meta.url), + "utf8" + ); + + expect(source).toContain("$maximumArtifactBytes = 20 * 1024"); }); }); From 077d77eeb0fd8b90eb1e1e6dfa0e31e2ec9fd182 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 21:15:50 +0400 Subject: [PATCH 24/25] test(windows): isolate first-run draft cleanup boundary --- ...onsole-dashboard-application-service.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 37aa4b3..58979fa 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -440,6 +440,23 @@ describe("Console dashboard application service", () => { await expect(loadSetupDraft()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); }); + it.runIf(process.platform === "win32")("cleans up a real first-run connector draft without configuration publication", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-cleanup-boundary-")); + temporaryDirectories.push(root); + const privateParent = await createPrivateConsoleDirectory(root); + const draftStore = new FileSetupDraftStore({ directory: join(privateParent, "setup-draft") }); + + const saved = await draftStore.save({ + source: "connector", + name: "support-tools", + preset: "generic", + stage: "connection" + }); + await expect(draftStore.load()).resolves.toEqual(saved); + await expect(draftStore.discard(saved.revision)).resolves.toBeUndefined(); + await expect(draftStore.load()).resolves.toBeUndefined(); + }); + it("keeps a published first-run configuration when cached draft cleanup conflicts", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-cleanup-")); temporaryDirectories.push(root); From f5527a0c604a88e9592685415966c5c00a00895b Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 21:24:24 +0400 Subject: [PATCH 25/25] test(windows): split draft lifecycle coverage --- ...sole-dashboard-application-service.test.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 58979fa..3da18cf 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -403,7 +403,7 @@ describe("Console dashboard application service", () => { await expect(service.health()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); }); - it("shares only a safe connector draft during first run and clears it after configuration publication", async () => { + it("shares only a safe connector draft during first run", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); @@ -416,7 +416,7 @@ describe("Console dashboard application service", () => { launcher: { command: process.execPath, args: ["serve"] }, setupDraftStore: draftStore }); - const { preset, ...presetOptions } = supportedKnownConnectorOptions(); + const { preset } = supportedKnownConnectorOptions(); const saveSetupDraft = service.saveSetupDraft; const loadSetupDraft = service.loadSetupDraft; if (saveSetupDraft === undefined || loadSetupDraft === undefined) { @@ -430,6 +430,34 @@ describe("Console dashboard application service", () => { stage: "connection" }); await expect(loadSetupDraft()).resolves.toEqual(draft); + }); + + it("clears a real first-run connector draft after configuration publication", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-draft-publication-")); + temporaryDirectories.push(root); + const privateParent = await createPrivateConsoleDirectory(root); + const directory = join(privateParent, "miftah"); + const configPath = join(directory, "miftah.json"); + const draftStore = new FileSetupDraftStore({ directory: join(privateParent, "setup-draft") }); + const service = new ConsoleDashboardApplicationService({ + defaultConfigPath: configPath, + configDirectory: directory, + launcher: { command: process.execPath, args: ["serve"] }, + setupDraftStore: draftStore + }); + const { preset, ...presetOptions } = supportedKnownConnectorOptions(); + const saveSetupDraft = service.saveSetupDraft; + const loadSetupDraft = service.loadSetupDraft; + if (saveSetupDraft === undefined || loadSetupDraft === undefined) { + throw new Error("Expected the first-run service to expose the configured setup-draft capability."); + } + + await saveSetupDraft({ + source: "connector", + name: "support-tools", + preset, + stage: "connection" + }); await expect(service.onboardPreset({ name: "support-tools",