diff --git a/CHANGELOG.md b/CHANGELOG.md index 7846dbf0..0fb66e4f 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 42f99b7d..538b5741 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 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. 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/assets/windows-secret-job.exe b/assets/windows-secret-job.exe index 5b1a24d8..60dc1bfa 100644 Binary files a/assets/windows-secret-job.exe and b/assets/windows-secret-job.exe differ diff --git a/docs/cli.md b/docs/cli.md index 7679a5d6..c4bd2b27 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 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. 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 41d16bc9..81ebc00d 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 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 | | --- | --- | | `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/scripts/export-windows-secret-job-assembly.ps1 b/scripts/export-windows-secret-job-assembly.ps1 index c0ba7ca7..c07c9492 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 b491c109..4b2da6f7 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/cli/exit-codes.ts b/src/cli/exit-codes.ts index d181b072..c024da3f 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 7200365e..88e94b98 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, @@ -32,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, @@ -60,6 +62,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 +419,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 +428,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,12 +437,12 @@ 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 }; } 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/main.ts b/src/cli/main.ts index 518dc55d..203e0afa 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 c738fb2e..c0b9bae8 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 a3642e17..4c4e9a6b 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,170 @@ 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; +} + +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, * 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 discardPublishedSetupDraft(store, draft.revision, context); + return finishCreatedSetup(options, context, created); + } if (options.plan === true) { const incompatible = [ "interactive", @@ -436,6 +608,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 +639,36 @@ 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 checkpointStore = context.setupDraftStore; + const initContext = checkpointConnectorIntent && checkpointStore !== undefined + ? { + ...context, + onSetupDraftIntent: async (intent: Parameters>[0]) => { + try { + checkpointedDraft = await checkpointStore.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" - ); - } - 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: [] }; + }, initContext); + if (checkpointedDraft !== undefined && checkpointStore !== undefined) { + await discardPublishedSetupDraft(checkpointStore, checkpointedDraft.revision, context); } - 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/cli/windows-config-acl.ts b/src/cli/windows-config-acl.ts index 7e1d4bb4..0ea00a08 100644 --- a/src/cli/windows-config-acl.ts +++ b/src/cli/windows-config-acl.ts @@ -1,10 +1,13 @@ 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; +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. @@ -27,23 +30,49 @@ 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; } +interface PrivateConfigWriteRequest { + readonly path: string; + readonly byteLength: number; + readonly commitDeadlineUtcMilliseconds: number; +} + interface VerifyPrivatePathRequest { readonly operation: "verify-private-path"; readonly kind: "file" | "directory"; 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; + +export type WindowsPrivateConfigWriteResult = "written" | "exists" | "failed"; /** * Copies the source file's non-null owner, group, and DACL, then verifies its @@ -64,16 +93,57 @@ 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 }); } +/** + * 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 replaceable ancestor, 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 }); } +/** + * 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 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, + // 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 (!Number.isSafeInteger(request.commitDeadlineUtcMilliseconds)) 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); @@ -91,6 +161,28 @@ export async function verifyWindowsConfigPathSecurity( return runWindowsAclRequest({ operation: "verify-private-path", path, kind }); } +/** + * 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 || + (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 +199,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 +212,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 @@ -128,6 +228,31 @@ function encodeRequest(request: WindowsConfigAclRequest): string | undefined { : undefined; } +function encodePrivateFileWriteRequest(request: PrivateConfigWriteRequest): string | undefined { + const path = Buffer.from(request.path, "utf8"); + const requestLength = 1 + 4 + path.byteLength + 8 + 8; + if ( + request.byteLength < 0 || + requestLength > maximumRequestBytes || + path.toString("utf8") !== request.path + ) { + return undefined; + } + + const payload = Buffer.allocUnsafe(requestLength); + let offset = 0; + 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"); +} + function trustedPowerShellExecutable(): string | undefined { const executable = win32.join( trustedWindowsRoot, @@ -149,6 +274,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)) { @@ -192,45 +327,114 @@ function runWindowsAclCommand(launcher: string, request: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(launcher, ["--write-private-config"], { + env: privateConfigWriterEnvironment(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 -$directorySections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner +$dirSections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner $verifySections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner function Test-MiftahPrivatePath { param( [string]$path, - [string]$kind + [string]$kind, + [string]$expectedSddl = $null, + [bool]$requireProtected = $false, + [bool]$requireOwner = $true ) 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) 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) 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() + $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') $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) - if ($null -eq $owner -or $owner.Value -cne $identity.Value) { return $false } + 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) { 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. - $trustedSids = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-4') - $creatorOwnerSid = 'S-1-3-0' - $creatorGroupSid = 'S-1-3-1' + 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 $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 @@ -259,12 +463,22 @@ function Test-MiftahPrivatePath { if ($rules.Count -eq 0) { return $false } foreach ($rule in $rules) { if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } - if ($trustedSids -ccontains $rule.IdentityReference.Value) { continue } + if ($trusted -ccontains $rule.IdentityReference.Value) { continue } if ( - ($rule.IdentityReference.Value -ceq $creatorOwnerSid -or $rule.IdentityReference.Value -ceq $creatorGroupSid) -and + ($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 } + } + return $true +} +function Test-MiftahAncestors { + param($ancestor) + while ($null -ne $ancestor) { + $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 } @@ -315,6 +529,21 @@ 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 } + } + if($fields[1] -ne 'directory' -or -not(Test-MiftahAncestors ([System.IO.DirectoryInfo]::new($fields[2]).Parent))){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) @@ -333,16 +562,25 @@ 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 } - if ($fields.Count -eq 2 -and $fields[0] -eq 'create-private-directory') { - $directory = [System.IO.DirectoryInfo]::new($fields[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') -or -not(Test-MiftahAncestors $parent.Parent)){exit 1} + } if ($directory.Exists) { exit 1 } $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User if ($null -eq $identity) { exit 1 } @@ -358,12 +596,9 @@ try { [System.Security.AccessControl.AccessControlType]::Allow ) $security.SetAccessRule($rule) - $expected = $security.GetSecurityDescriptorSddlForm($directorySections) + $expected = $security.GetSecurityDescriptorSddlForm($dirSections) $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' $expected $true)) { exit 1 } exit 0 } diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index a1f3f963..6676a4d9 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -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"; @@ -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, @@ -85,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, @@ -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. */ @@ -416,18 +423,23 @@ 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"); - } - await createWindowsPrivateDirectory(directory); - if (!(await verifyWindowsConfigPathSecurity(directory, "directory"))) { - throw new Error("unsafe configuration directory"); + const created = await createWindowsPrivateDirectoryInPrivateParent(parent, directory); + // 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" } + ]))) { + throw new Error("unsafe configuration directory or parent"); } } catch (error) { 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 } ); } } @@ -1217,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/console/console-assets.ts b/src/console/console-assets.ts index 17075a4a..8241fd39 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,57 @@ 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"); + 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)); } + finally { resumeSetupDraft.disabled = false; } + }); + } + + if (discardSetupDraft instanceof HTMLButtonElement) { + discardSetupDraft.addEventListener("click", async () => { + 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 } }); + activeSetupDraft = undefined; + 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; } + }); + } + + renderSetupDraftControls(); + if (presetCreateReviewed instanceof HTMLButtonElement) { presetCreateReviewed.addEventListener("click", async () => { const request = pendingPresetRequest; @@ -1565,6 +1699,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 59288e32..164a8acc 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,40 @@ 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; + // 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) { this.firstRunApplication = this.applicationFor(options.defaultConfigPath); + this.setupDraftStore = options.setupDraftStore; + const store = this.setupDraftStore; + if (store !== undefined) { + this.loadSetupDraft = async () => { + await this.assertFirstRunAvailable(); + const draft = await store.load(); + this.knownSetupDraftRevision = draft?.revision; + return draft; + }; + this.saveSetupDraft = async (input, expectedRevision) => { + await this.assertFirstRunAvailable(); + 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; + }; + } } async configMetadata(): Promise { @@ -140,6 +172,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 +182,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 +190,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 +203,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 +391,30 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica } } + private async clearSetupDraftAfterFirstRunPublication(): Promise { + 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); + this.knownSetupDraftRevision = undefined; + } + } 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 { 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 be5d4932..3174cbc7 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/secrets/windows-secret-command.ts b/src/secrets/windows-secret-command.ts index b1a2596a..a1a2d28c 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 5b75e489..bf7cd980 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 windowsSecretJobExecutableSha256 = "44ef7cca43d181cf92447ed4202d60a48cd8fa8d6b66305b9e80c3a1fa4bac28"; +export const windowsSecretJobSourceSha256 = "042ecb847c3ede5fee859098b3f5d89cbb5c7d4b4c16ae1822a3f75201804fda"; +export const windowsSecretJobExecutableSha256 = "a9cc1bb0c671bbd5671b53792caec66c382181c313183603478a330ee85d46bb"; diff --git a/src/secrets/windows-secret-job.cs b/src/secrets/windows-secret-job.cs index df934292..731a7d3e 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 = 3; + 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,20 @@ public SecretRequest(string executable, string[] arguments) } } + private sealed class PrivateConfigWriteRequest + { + public readonly string Path; + public readonly long ByteLength; + public readonly long CommitDeadlineUtcMilliseconds; + + public PrivateConfigWriteRequest(string path, long byteLength, long commitDeadlineUtcMilliseconds) + { + Path = path; + ByteLength = byteLength; + CommitDeadlineUtcMilliseconds = commitDeadlineUtcMilliseconds; + } + } + [StructLayout(LayoutKind.Sequential)] private struct JobObjectBasicLimitInformation { @@ -105,6 +131,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 +166,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 +247,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 +319,319 @@ 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(); + long commitDeadlineUtcMilliseconds = reader.ReadInt64(); + if (byteLength < 0 || commitDeadlineUtcMilliseconds < 0 || input.Position != input.Length) return null; + return new PrivateConfigWriteRequest(Path.GetFullPath(path), byteLength, commitDeadlineUtcMilliseconds); + } + } + + 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; + } + + // 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(); + 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) + { + 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; + 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; + if (!HasTimeForPrivateConfigCommit(request)) return 1; + File.Move(temporaryPath, request.Path); + temporaryCreated = false; + finalCreated = true; + 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 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( + 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/src/setup/setup-configuration.ts b/src/setup/setup-configuration.ts index 74a5db78..2b4b951d 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/src/setup/setup-draft.ts b/src/setup/setup-draft.ts new file mode 100644 index 00000000..6251e9d8 --- /dev/null +++ b/src/setup/setup-draft.ts @@ -0,0 +1,508 @@ +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 { + createWindowsPrivateDirectoryInPrivateParent, + secureWindowsConfigFile, + verifyWindowsConfigPathSecurity, + verifyWindowsConfigPathsSecurity +} from "../cli/windows-config-acl.js"; +import { PRESET_CATALOG, type PresetCatalogName } from "../config/presets.js"; +import { 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(cause?: unknown): never { + throw new MiftahError( + "SETUP_DRAFT_UNAVAILABLE", + "SETUP_DRAFT_UNAVAILABLE: private setup draft storage is unavailable", + cause === undefined ? undefined : { cause } + ); +} + +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 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 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. + 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 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(); +} + +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, + verifyWindowsAcl: boolean = true +): 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 (verifyWindowsAcl && !(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, verifyWindowsAcl: boolean = true): 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 (verifyWindowsAcl && !(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(true); + if (expectedRevision !== undefined && expectedRevision !== (existing?.revision ?? 0)) { + 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(true); + 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; + setupDraftUnavailable(error); + } + } + + private async loadUnchecked(directoryAlreadyVerified: boolean = false): Promise { + const observed = await this.observedDraftFileForRead(directoryAlreadyVerified); + 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 }); + // 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) || + !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, false); + 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 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. 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; + } + 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(); + 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); + // 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, process.platform !== "win32"); + } 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 36ecd1dc..bc9c6fea 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 fc90d256..9f5643bc 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 5bd68297..5ef52821 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 172f7747..3da18cf8 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -11,6 +11,8 @@ 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 { MiftahError } from "../src/utils/errors.js"; import { createPrivateConsoleDirectory, writePrivateConsoleFile @@ -401,6 +403,162 @@ 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", 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(privateParent, "setup-draft") }); + const service = new ConsoleDashboardApplicationService({ + defaultConfigPath: configPath, + configDirectory: directory, + launcher: { command: process.execPath, args: ["serve"] }, + setupDraftStore: draftStore + }); + const { preset } = 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); + }); + + 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", + preset, + ...presetOptions + })).resolves.toMatchObject({ name: "support-tools" }); + await expect(draftStore.load()).resolves.toBeUndefined(); + 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); + 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(); + 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.", + { 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"), + 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 54d32b20..a4347310 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,215 @@ 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 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; + 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 +1435,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 +1511,35 @@ 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 + }); + 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"); @@ -1540,6 +1782,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(privateParent, "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/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index bd30bd77..8a7df052 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -1,21 +1,40 @@ -import { mkdir, mkdtemp, readFile, rm } 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>(), - verifyPath: vi.fn<(path: string, kind: "file" | "directory") => 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>() })); vi.mock("../src/cli/windows-config-acl.js", () => ({ createWindowsPrivateDirectory: aclMocks.createPrivateDirectory, + createWindowsPrivateDirectoryInPrivateParent: aclMocks.createPrivateDirectoryInParent, secureWindowsConfigFile: aclMocks.secureFile, - verifyWindowsConfigPathSecurity: aclMocks.verifyPath + writeWindowsPrivateConfigFile: aclMocks.writePrivateFile, + verifyWindowsConfigPathSecurity: aclMocks.verifyPath, + verifyWindowsConfigPathsSecurity: aclMocks.verifyPaths +})); + +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 { 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"); @@ -23,23 +42,52 @@ 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.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) => { - await mkdir(directory, { recursive: true }); - return true; + try { + await mkdir(directory); + return true; + } catch { + 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.writePrivateFile.mockReset(); aclMocks.verifyPath.mockReset(); + aclMocks.verifyPaths.mockReset(); + vi.restoreAllMocks(); await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); 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"); @@ -55,19 +103,33 @@ describe("Console Windows first-run boundary", () => { scopes: ["openid"] })).resolves.toMatchObject({ changed: true, write: true }); - expect(aclMocks.createPrivateDirectory).toHaveBeenCalledWith(configDirectory); - expect(aclMocks.verifyPath).toHaveBeenCalledWith(configDirectory, "directory"); - expect(aclMocks.secureFile).toHaveBeenCalledWith(configPath); - expect(aclMocks.verifyPath).toHaveBeenCalledWith(expect.stringMatching(/[/\\]miftah\.json$/u), "file"); + 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(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.writePrivateFile).toHaveBeenCalledWith(configPath, expect.any(String)); }); - it("fails closed before audit or config creation when the standard directory cannot be verified", async () => { - const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-denied-")); + 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"); - aclMocks.createPrivateDirectory.mockResolvedValue(false); - aclMocks.verifyPath.mockResolvedValue(false); + 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({ @@ -79,6 +141,277 @@ describe("Console Windows first-run boundary", () => { 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("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); + const configDirectory = join(parent, "miftah"); + const configPath = join(configDirectory, "miftah.json"); + aclMocks.createPrivateDirectoryInParent.mockImplementationOnce(async (_parent, 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.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 () => { + const parent = await mkdtemp(join(tmpdir(), "miftah-console-windows-first-run-denied-")); + temporaryDirectories.push(parent); + const configDirectory = join(parent, "miftah"); + const configPath = join(configDirectory, "miftah.json"); + aclMocks.createPrivateDirectoryInParent.mockResolvedValue(false); + aclMocks.verifyPaths.mockResolvedValue(false); + const service = new ConsoleDashboardApplicationService({ configDirectory, defaultConfigPath: configPath }); + + 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"] + }).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" }); }); + + 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.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 () => { + 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).not.toHaveBeenCalled(); + expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledTimes(2); + expect(aclMocks.secureFile).toHaveBeenCalledOnce(); + expect(aclMocks.writePrivateFile).toHaveBeenCalledOnce(); + expect(aclMocks.verifyPath).toHaveBeenCalledTimes(5); + expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(3); + }); + + 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, + write: aclMocks.writePrivateFile.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, 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 } + }); + }); + + 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(); + aclMocks.verifyPaths.mockClear(); + + await expect(store.load()).resolves.toEqual(draft); + + expect(aclMocks.verifyPath).not.toHaveBeenCalled(); + expect(aclMocks.verifyPaths).toHaveBeenCalledWith([ + { path: directory, kind: "directory" }, + { path: expect.stringMatching(/setup-draft-v1\.json$/u), kind: "file" } + ]); + }); + + 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); + 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.createPrivateDirectoryInParent).toHaveBeenCalledWith(parent, directory); + expect(aclMocks.secureFile).toHaveBeenCalledOnce(); + expect(aclMocks.verifyPath).not.toHaveBeenCalled(); + expect(aclMocks.verifyPaths).not.toHaveBeenCalled(); + }); }); diff --git a/tests/init-command.test.ts b/tests/init-command.test.ts index 6d3d18da..52055264 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 ca0d12b6..920623b0 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 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"); expect(readme).toContain("## First setup: GitHub with Claude Desktop"); @@ -112,6 +118,13 @@ 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 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"); + 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 5412cce6..5129d71d 100644 --- a/tests/setup-command.test.ts +++ b/tests/setup-command.test.ts @@ -16,6 +16,8 @@ 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 { MiftahError } from "../src/utils/errors.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { startOAuthCompatibilityProbe } from "./helpers/fake-remote-upstream.js"; @@ -92,6 +94,228 @@ 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("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({ + 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("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 new file mode 100644 index 00000000..ee0efdc4 --- /dev/null +++ b/tests/setup-draft.test.ts @@ -0,0 +1,205 @@ +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("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({ + 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(); + }); + + 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) }); + }); +}); diff --git a/tests/windows-config-acl.test.ts b/tests/windows-config-acl.test.ts index ffa186ad..92a1e267 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,12 +20,19 @@ vi.mock("node:child_process", async (importOriginal) => ({ spawn: windowsAclMocks.spawn })); +vi.mock("../src/secrets/windows-secret-command.js", () => ({ + resolveCheckedWindowsSecretJobExecutable: windowsSecretJobMocks.resolveCheckedWindowsSecretJobExecutable +})); + import { copyWindowsConfigSecurityDescriptor, copyWindowsConfigSecurityDescriptors, + createWindowsPrivateDirectoryInPrivateParent, createWindowsPrivateMigrationDirectory, secureWindowsConfigFile, - verifyWindowsConfigPathSecurity + writeWindowsPrivateConfigFile, + verifyWindowsConfigPathSecurity, + verifyWindowsConfigPathsSecurity } from "../src/cli/windows-config-acl.js"; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -32,16 +43,51 @@ 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 }); +} + +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); + 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; + const commitDeadlineUtcMilliseconds = Number(payload.readBigInt64LE(offset)); + offset += 8; + expect(offset).toBe(payload.byteLength); + return { version, path, byteLength, commitDeadlineUtcMilliseconds }; +} + 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(); }); @@ -87,6 +133,44 @@ 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"); + 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 () => { + await expect(verifyWindowsConfigPathsSecurity([])).resolves.toBe(false); + 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(); + }); + it("fails closed when source descriptor verification fails", async () => { windowsAclMocks.spawn.mockImplementation(() => { const child = createChild(); @@ -118,7 +202,139 @@ 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("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; + }); + + await expect(writeWindowsPrivateConfigFile( + "C:\\Users\\miftah\\.config\\miftah\\miftah.json", + "{\"version\":\"3\"}\n" + )).resolves.toBe("written"); + + 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(options?.env?.MIFTAH_CONFIG_ACL_REQUEST).toBeUndefined(); + expect(decodePrivateFileWriteRequest(options?.env?.MIFTAH_CONFIG_PRIVATE_FILE_WRITE_REQUEST ?? "")).toEqual({ + version: 3, + path: "C:\\Users\\miftah\\.config\\miftah\\miftah.json", + byteLength: 16, + commitDeadlineUtcMilliseconds: 1_700_000_004_000 + }); + expect(child.stdin.end).toHaveBeenCalledWith(Buffer.from("{\"version\":\"3\"}\n", "utf8")); + }); + + 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(); + 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' $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("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([System.IO.Path]::GetFullPath($directory.FullName))"); + expect(command).toContain("Test-MiftahPrivatePath $parent 'directory'"); + 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.Parent"); + }); + + 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", + "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 () => { diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index d854ad66..5297e697 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -1,11 +1,15 @@ 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, + writeWindowsPrivateConfigFile +} from "../src/cli/windows-config-acl.js"; const requestEnvironmentName = "MIFTAH_TEST_CONFIG_ACL_REQUEST"; const privateDirectoryRequestEnvironmentName = "MIFTAH_TEST_PRIVATE_DIRECTORY_ACL_REQUEST"; @@ -100,6 +104,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 +423,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 +499,76 @@ 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")( + "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 () => { + const root = await mkdtemp(join(tmpdir(), "miftah-windows-root-delete-")); + 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")( + "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 () => { diff --git a/tests/windows-secret-command-contract.test.ts b/tests/windows-secret-command-contract.test.ts index 5e760a35..a8a80ccf 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"); }); @@ -87,6 +87,27 @@ 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"); + expect(helperSource).toContain("HasTimeForPrivateConfigCommit"); + expect(helperSource).toContain("MatchesExistingPrivateConfigurationFile"); + }); + 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"); @@ -104,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"); }); });