diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1b6a11..f7b45f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Added -- [#204](https://github.com/mohanagy/miftah/issues/204) Added guided multi-account Google Search Console onboarding through the CLI and local Console, including an explicit opt-in first-success check that resolves only the selected target, verifies audit/policy/identity boundaries before its one declared read-only probe, and cancels with the requesting Console session. The check trusts only the catalog's reviewed launch envelope and fails closed without launching a process after execution-affecting customization. It creates named profiles with an explicit durable default and separate upstream-owned OAuth state directories per generated configuration file and profile, while continuing to keep browser login, token-cache contents, reauthentication, revocation, and account identity verification outside Miftah. A returning provider-owned account can now be added atomically through the reviewed adapter contract in either CLI or Console, with a fresh isolated state directory, a redacted fail-closed lifecycle record, and no token-cache access. It also adds explicitly acknowledged local STDIO setup and client-entry import: literal executable plus bounded argument array, no shell, no secret-shaped values, no generic-command launch during onboarding, read-only default policy, destructive handling for unknown tools, and Windows-only direct absolute `.exe`/`.com` requirements to avoid a command-shell fallback. +- [#204](https://github.com/mohanagy/miftah/issues/204) Added guided multi-account Google Search Console onboarding through the CLI and local Console, including an explicit opt-in first-success check that resolves only the selected target, verifies audit/policy/identity boundaries before its one declared read-only probe, and cancels with the requesting Console session. The check trusts only the catalog's reviewed launch envelope and fails closed without launching a process after execution-affecting customization. It creates named profiles with an explicit durable default and separate upstream-owned OAuth state directories per generated configuration file and profile, while continuing to keep browser login, token-cache contents, reauthentication, revocation, and account identity verification outside Miftah. A returning provider-owned account can now be added atomically through the reviewed adapter contract in either CLI or Console, with a fresh isolated state directory, a redacted fail-closed lifecycle record, and no token-cache access. It also adds a separate returning-user path for a simple local STDIO environment credential binding: CLI and Console accept a variable name rather than a secret, require every existing profile to share the same narrow binding, enforce multi-profile safeguards, use guarded audited replacement, and refuse remote HTTP, provider-adapter, OAuth, named-upstream, mixed-profile, duplicate-source, and arbitrary-override configurations. It also adds explicitly acknowledged local STDIO setup and client-entry import: literal executable plus bounded argument array, no shell, no secret-shaped values, no generic-command launch during onboarding, read-only default policy, destructive handling for unknown tools, and Windows-only direct absolute `.exe`/`.com` requirements to avoid a command-shell fallback. ### Changed diff --git a/README.md b/README.md index 9ef63a9b..918b8619 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,23 @@ This works only for an unchanged reviewed provider-adapter configuration whose e The local dashboard offers the same returning-user flow: run `miftah dashboard --config ~/.config/miftah/gsc.json`, then choose **Add another provider account**. The form appears only when the selected configuration meets the reviewed provider-adapter boundary. It asks for a new profile, optional description, and credential-file path; it never shows an existing path or provider cache, and it does not replace native OAuth controls for other kinds of MCP. +### Add another local environment-backed account + +Some local STDIO MCPs use one environment variable for their credential rather than provider-owned OAuth. When the existing configuration has exactly that simple shape, add a second, third, or later account without hand-editing JSON: + +```bash +miftah setup --add-profile \ + --config ~/.config/miftah/sentry.json \ + --profile personal \ + --description "Personal Sentry account" \ + --credential-env SENTRY_PERSONAL_ACCESS_TOKEN \ + --make-default +``` + +`--credential-env` is the **name** of an environment variable, never its value. Miftah stores a reference such as `${SENTRY_PERSONAL_ACCESS_TOKEN}`, never reads the credential, and does not start the upstream. This deliberately applies only to one local `stdio` upstream with one direct credential binding per profile, the same destination and policy for every existing account, no provider adapter, and no native OAuth or named upstreams. Each account must use a different source environment variable. Remote HTTP MCPs are refused because profile environments do not authenticate HTTP requests. + +There is no generic `--verify` here: Miftah does not guess a safe command for an arbitrary upstream. Set the new environment variable in the application environment that launches Miftah, restart or reconnect the MCP client, then use the service's normal safe validation path. The local dashboard offers the same flow under **Add another environment-backed account** when the selected configuration meets this exact boundary. + ### Change the durable default later Once two or more profiles already exist, you can choose which one new Miftah sessions start with without adding an account or editing JSON: diff --git a/docs/cli.md b/docs/cli.md index 04db5bdc..eaa8a91f 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 `--verify`; `--import-file ` with `--import-entry `; or `--add-profile --config --profile --oauth-client-secrets-file ` for a reviewed provider-owned account | Opens the guided first-configuration flow. 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` adds one later account only when the selected existing configuration remains wholly inside a reviewed provider-adapter boundary. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile, or just the newly added account, after the write; it is rejected for a client-entry import because no reviewed adapter is inferred. The explicit import path accepts one selected local stdio or credential-free HTTPS remote entry and never edits a client configuration file. | +| `miftah setup [name]` | none, or `--add-profile --config ` | The safe preset options accepted by `init`, except `--interactive`, plus `--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. 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 import path accepts one selected local stdio or credential-free HTTPS remote entry and never edits 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 set-default --config --profile ` | `--config`, `--profile` | `--config `, `--profile ` | Makes one existing profile the durable default for future Miftah sessions. It performs a guarded replacement with a recovery backup and finalizes a configured fail-closed audit record. Its result omits configuration bytes, profile data, provider paths, and secret references; it never starts an upstream, opens OAuth, or changes profile data, provider caches, or an active client session. | @@ -71,7 +71,9 @@ miftah validate --config "$HOME/Miftah configs/work wrapper.json" `--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C while it is collecting initial configuration data cancels without writing a config. In `miftah setup`, the first answer can be `remote` for the strict `streamable-http` path, `local` for `local-stdio`, or any catalog connector name. It asks for variable names and safe metadata, never secret values. The `remote` answer does not discover OAuth or call the upstream; use `miftah setup --native-oauth` only when the remote server advertises standards-based OAuth. For `local-stdio`, it asks for one argv element at a time and prints only a bounded count-based review summary before the acknowledgement; it does not echo arguments or credential values. For Google Search Console it asks for each account's profile name, optional description, and client-secrets path, then an explicit default profile. `miftah setup --verify` is an explicit opt-in to run each selected adapter's declared safe read-only check after the configuration write. It never accepts a tool name or arguments from the operator, never guesses a health command, does not auto-approve a policy confirmation, and returns only bounded status rather than provider output. The check runs only while the selected profile still matches the adapter's reviewed launch envelope; otherwise it reports a bounded unsupported status without launching the provider. If the post-write readiness prompt is cancelled, Miftah keeps the configuration, reports incomplete verification, and exits 1. A non-ready `setup --verify` result also keeps the configuration and exits 1. `init` is network-free and does not accept `--verify` or client-entry import flags. In noninteractive use, `init` creates only the config unless `--client` is supplied. `--client` prints JSON with absolute Node and compiled Miftah paths; it does not write a host config. For `claude-code` or `all`, it also prints a separate, exact management-tool `permissions.ask` fragment for manual merge into Claude Code settings; it never writes or overwrites those settings. Regenerate the snippets after moving or upgrading Miftah or changing the config path. -`miftah setup --add-profile --config ` is the returning-user path for a reviewed provider-owned adapter. In a noninteractive invocation it requires `--profile` and `--oauth-client-secrets-file`; `--description`, `--make-default`, and `--verify` are optional. It first 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 another 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. The durable default changes only when `--make-default` is present, so existing MCP clients still need a restart or new connection. +`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. `miftah setup --import-file --import-entry ` is a separate no-secret flow, not a generic client migration. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. It accepts either a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar—literal executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags—or one credential-free HTTPS remote entry. A remote import uses `url` under `mcpServers` or `servers` and must explicitly declare `type: "http"` or `"streamable-http"`. It requires HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments, and does not discover OAuth or call the remote endpoint. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. It rejects `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, URL userinfo, opaque credential-shaped URL path segments, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references. diff --git a/docs/console-api.md b/docs/console-api.md index 4aa9eeab..c37856e5 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -35,6 +35,8 @@ Every request must use the exact listener `Host`. Browser mutations, including b `POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. `POST /api/v1/profile-readiness` accepts only a selected `profile` plus an optional `upstream`; the Console always selects an exact configured upstream when there is more than one. It can invoke only a provider-declared safe read-only check after audit and policy preflight, binds that operation to the browser request so a disconnected client cancels it, and never accepts a tool name or arguments. `POST /api/v1/profiles/default` accepts only one existing `profile` name. It changes only the durable `defaultProfile` through the guarded replacement and audit transaction; it never starts an upstream, opens OAuth, accepts a credential value, or changes provider-owned token caches. Native-OAuth first run accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes. Reviewed-preset first run accepts only safe catalog metadata; for Google Search Console that can include strict named account objects containing a profile name, optional description, and client-secrets path, plus an explicit default when there is more than one account. The `local-stdio` preset additionally accepts one literal executable, a bounded literal argument array, optional native absolute working directory, optional credential-environment name, and a required `acceptLocalCommand: true` acknowledgement. It never uses a shell or starts that generic local executable during onboarding; shell/wrapper, URL, control, environment-reference, and credential-shaped values are rejected. On Windows, it accepts only a direct absolute `.exe` or `.com` binary, not a bare command or `.cmd`/`.bat` shim, so the direct argv path cannot fall back to a command processor. Client-entry first run accepts a bounded in-memory JSON document plus an explicit entry name; it accepts a selected local stdio entry that fits the finite static launch grammar or one credential-free HTTPS remote entry. A remote entry must be a `url` under `mcpServers` or `servers`, explicitly marked `type: "http"` or `"streamable-http"`; it must be HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments. The Console never persists, returns, or audits the pasted document, never copies `env` or headers, and does not discover OAuth or call the remote endpoint during import. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. Token, password, cookie, raw secret contents, callback, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, opaque credential-shaped URL path segments, and arbitrary extra fields are rejected. +`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. + | Method and path | Purpose | | --- | --- | | `POST /api/v1/sessions` | Exchange the one-use bootstrap code for one browser session. | @@ -47,6 +49,7 @@ Every request must use the exact listener `Host`. Browser mutations, including b | `POST /api/v1/configurations/:id/select` | Select one opaque catalog entry for this Console process. Requires CSRF; it never changes MCP client files or live MCP sessions. | | `POST /api/v1/profile-readiness` | Run one declared safe read-only check for the selected profile/upstream. Requires CSRF, never auto-runs, and returns only bounded status rather than provider output. | | `POST /api/v1/profiles/default` | Make one existing profile the durable default for future Miftah sessions. Requires CSRF, accepts only `{ "profile": "…" }`, and performs no OAuth, upstream, credential, or provider-cache operation. | +| `POST /api/v1/profiles/environment-account` | Add one simple local environment-backed account. Requires CSRF and a selected trusted configuration. It accepts only a profile, optional description, environment-variable name, and optional durable default; never a credential value. | | `GET /api/v1/profiles` | Return profile names, descriptions, tags, policy names, and named-upstream keys only. | | `GET /api/v1/connections` | Return configured non-secret OAuth connection bindings without opening the vault. | | `GET /api/v1/connections/:ref` | Return redacted credential and identity status for one exact connection. | @@ -58,7 +61,7 @@ Every request must use the exact listener `Host`. Browser mutations, including b | `GET /api/v1/audit?limit=1..200` | Return allowlisted metadata from the owner-restricted Console mutation journal, never raw JSONL or arguments. | | `GET /api/v1/client-snippets?client=` | Generate review-and-copy JSON for Claude Desktop, Claude Code, Cursor, VS Code, or `all`; never edit client files. | -Success responses use `{ "data": ... }`. Errors use `{ "error": { "code": "...", "message": "..." } }` with semantic HTTP status codes. Responses are non-cacheable and carry restrictive content-type, framing, referrer, and content-security headers. Internal paths, raw configuration, secret references, environment maps, command arguments, headers, tokens, authorization URLs, raw provider errors, and raw audit bytes are not part of the browser contract. For a recognized provider adapter, configuration metadata states only the declared authentication ownership; the Console hides the native OAuth editor rather than implying it can take over the adapter's OAuth cache. It emits that provider-owned state only when every effective profile/upstream pair matches the same reviewed adapter. A mixed or otherwise unreviewed local-process configuration is reported as `manual-only`: the Console keeps any exact reviewed safe-check targets available, but does not claim native OAuth or provider ownership and does not offer a native OAuth editor. +Success responses use `{ "data": ... }`. Errors use `{ "error": { "code": "...", "message": "..." } }` with semantic HTTP status codes. Responses are non-cacheable and carry restrictive content-type, framing, referrer, and content-security headers. Internal paths, raw configuration, secret references, environment maps, command arguments, headers, tokens, authorization URLs, raw provider errors, and raw audit bytes are not part of the browser contract. For a recognized provider adapter, configuration metadata states only the declared authentication ownership; the Console hides the native OAuth editor rather than implying it can take over the adapter's OAuth cache. It emits that provider-owned state only when every effective profile/upstream pair matches the same reviewed adapter. A manual-only local configuration receives the environment-account editor only when it meets the exact simple static-binding boundary; metadata exposes the destination environment key, never the source reference or its value. Other mixed or unreviewed local-process configurations keep any exact reviewed safe-check targets available, but do not claim native OAuth, provider ownership, or static account addition. ## Mutation and process boundary diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index 89abce20..f75780d8 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -103,6 +103,8 @@ export const ERROR_EXIT_CODES = { OAUTH_AUTHORIZATION_NOT_ENABLED: CLI_EXIT_CODES.config, PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED: CLI_EXIT_CODES.config, PROVIDER_ACCOUNT_INPUT_INVALID: CLI_EXIT_CODES.config, + 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, CONSOLE_LAUNCHER_UNAVAILABLE: CLI_EXIT_CODES.operation, CONSOLE_CONFIGURATION_NOT_FOUND: CLI_EXIT_CODES.config, diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 69cb3382..97ed7cf3 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -85,7 +85,7 @@ export interface CliOptions { readonly verify?: true; /** Discovers standards-based OAuth from one remote HTTPS MCP endpoint before creating the configuration. */ readonly nativeOAuth?: true; - /** Adds one reviewed provider-owned account to an existing trusted adapter configuration. */ + /** Adds one reviewed provider-owned or simple environment-backed account to an existing configuration. */ readonly addProfile?: true; /** Makes the newly added endpoint-first OAuth account the durable default profile. */ readonly makeDefault?: true; @@ -475,7 +475,7 @@ const OPTION_DEFINITIONS: Record = { name: "addProfile", takesValue: false, usage: "--add-profile", - description: "Add one reviewed provider-owned account to an existing configuration." + description: "Add one reviewed provider-owned or simple environment-backed account to an existing configuration." }, makeDefault: { name: "makeDefault", diff --git a/src/cli/setup-environment-profile.ts b/src/cli/setup-environment-profile.ts new file mode 100644 index 00000000..ba84dbd8 --- /dev/null +++ b/src/cli/setup-environment-profile.ts @@ -0,0 +1,172 @@ +import { resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { + runEnvironmentProfileAddition, + type EnvironmentProfileAdditionReport +} from "../setup/environment-profile-onboarding.js"; +import { CliUsageError, type CliOptions } from "./parse.js"; +import type { InitCommandContext } from "./init.js"; + +export type EnvironmentProfileSetupOptions = Pick< + CliOptions, + "config" | "profile" | "description" | "credentialEnv" | "makeDefault" +>; + +export interface EnvironmentProfileSetupResult { + readonly configPath: string; + readonly report: EnvironmentProfileAdditionReport; +} + +interface EnvironmentProfileValues { + readonly configPath: string; + readonly profile: string; + readonly description?: string; + readonly credentialEnv: string; + readonly makeDefault: boolean; +} + +interface Cancellation { + readonly promise: Promise; + dispose(): void; +} + +type PromptInterface = ReturnType; + +function usageError(message: string): never { + throw new CliUsageError(message); +} + +function isTty(context: InitCommandContext): boolean { + return context.input.isTTY === true && context.output.isTTY === true; +} + +function resolveConfigPath(configPath: string, cwd: string): string { + if (configPath.includes("\0")) usageError("Configuration path must not contain a NUL character."); + return resolve(cwd, configPath); +} + +async function prompt( + line: PromptInterface, + cancellation: Cancellation, + label: string, + defaultValue?: string +): Promise { + const suffix = defaultValue === undefined ? ": " : ` [${defaultValue}]: `; + const answer = (await Promise.race([line.question(`${label}${suffix}`), cancellation.promise])).trim(); + return answer.length === 0 ? defaultValue : answer; +} + +function createCancellation(line: PromptInterface): Cancellation { + let rejectCancellation: (reason: CliUsageError) => void = () => undefined; + let cancelled = false; + const promise = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + void promise.catch(() => undefined); + const cancel = () => { + if (cancelled) return; + cancelled = true; + rejectCancellation(new CliUsageError("Environment-backed account setup was cancelled.")); + }; + line.once("close", cancel); + line.once("SIGINT", cancel); + + return { + promise, + dispose() { + cancelled = true; + line.removeListener("close", cancel); + line.removeListener("SIGINT", cancel); + } + }; +} + +function parseYesNo(value: string | undefined): boolean { + switch (value?.toLowerCase()) { + case "y": + case "yes": + return true; + case "n": + case "no": + return false; + default: + usageError("Answer 'yes' or 'no' when choosing the durable default profile."); + } +} + +async function collectValues( + options: EnvironmentProfileSetupOptions, + context: InitCommandContext +): Promise { + if (options.config === undefined) usageError("Adding an environment-backed account requires --config."); + if (!isTty(context)) { + if (options.profile === undefined || options.credentialEnv === undefined) { + usageError("Adding an environment-backed account requires --profile and --credential-env when no TTY is available."); + } + return { + configPath: options.config, + profile: options.profile, + ...(options.description === undefined ? {} : { description: options.description }), + credentialEnv: options.credentialEnv, + makeDefault: options.makeDefault === true + }; + } + + const line = createInterface({ input: context.input, output: context.output, terminal: true }); + const cancellation = createCancellation(line); + try { + const profile = options.profile ?? await prompt(line, cancellation, "New account profile name"); + const description = options.description ?? await prompt(line, cancellation, "Account profile description (optional)"); + const credentialEnv = options.credentialEnv ?? await prompt( + line, + cancellation, + "Environment variable that holds this account's credential" + ); + const defaultAnswer = options.makeDefault === true + ? "yes" + : await prompt(line, cancellation, "Make this the durable default profile? (yes/no)", "no"); + if (profile === undefined || credentialEnv === undefined || defaultAnswer === undefined) { + usageError("Adding an environment-backed account requires a profile and environment variable name."); + } + return { + configPath: options.config, + profile, + ...(description === undefined ? {} : { description }), + credentialEnv, + makeDefault: parseYesNo(defaultAnswer) + }; + } catch (error) { + if (error instanceof CliUsageError) throw error; + throw new CliUsageError("Environment-backed account setup was cancelled."); + } finally { + cancellation.dispose(); + line.close(); + } +} + +/** + * Adds one static-credential account only when the existing configuration has + * one reviewed simple environment binding. The user supplies an environment + * variable name, not a secret value, and setup never launches the upstream. + */ +export async function runEnvironmentProfileSetup( + options: EnvironmentProfileSetupOptions, + context: InitCommandContext +): Promise { + const values = await collectValues(options, context); + if (values.profile.includes("\0")) usageError("Profile name must not contain a NUL character."); + const configPath = resolveConfigPath(values.configPath, context.cwd); + const report = await runEnvironmentProfileAddition({ + configPath, + profile: values.profile, + ...(values.description === undefined ? {} : { description: values.description }), + credentialEnv: values.credentialEnv, + ...(values.makeDefault ? { makeDefault: true } : {}) + }); + for (const action of report.actions) context.output.write(`${action}\n`); + context.output.write("Miftah saved only an environment-variable reference; it did not read or store a credential value.\n"); + if (values.makeDefault) { + context.output.write("The durable default changed. Restart or open a new MCP client connection before expecting it to take effect.\n"); + } + return { configPath, report }; +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 8d020b66..4242adca 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,5 +1,8 @@ import { createInterface } from "node:readline/promises"; +import { resolve } from "node:path"; import { runProfileReadiness, type ProfileReadinessReport } from "../setup/profile-readiness.js"; +import { loadConfig } from "../config/load-config.js"; +import { getProviderAdapterForAccountProvisioning } from "../config/provider-adapters.js"; import { MiftahError } from "../utils/errors.js"; import { CliUsageError } from "./parse.js"; import type { CliOptions } from "./parse.js"; @@ -7,6 +10,7 @@ import { runInitCommand, type InitCommandContext, type InitCommandOptions } from import { runClientEntryImportSetup } from "./setup-client-entry-import.js"; import { runNativeOAuthSetup } from "./setup-native-oauth.js"; import { runProviderAccountSetup } from "./setup-provider-account.js"; +import { runEnvironmentProfileSetup } from "./setup-environment-profile.js"; /** `init` remains network-free; only guided `setup --verify` may run the reviewed provider probe. */ export type SetupCommandOptions = InitCommandOptions & Pick< @@ -22,6 +26,7 @@ export interface SetupCommandResult { } type ReadinessDecision = "verify" | "skip" | "cancelled"; +type AccountAdditionKind = "provider" | "environment"; function flagName(option: string): string { return option.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); @@ -31,6 +36,17 @@ function isTty(context: InitCommandContext): boolean { return context.input.isTTY === true && context.output.isTTY === true; } +async function accountAdditionKind(options: SetupCommandOptions, context: InitCommandContext): Promise { + if (options.config === undefined) { + throw new CliUsageError("Adding an account profile requires --config."); + } + if (options.config.includes("\0")) { + throw new CliUsageError("Configuration path must not contain a NUL character."); + } + const config = await loadConfig(resolve(context.cwd, options.config)); + return getProviderAdapterForAccountProvisioning(config)?.accountProvisioning === undefined ? "environment" : "provider"; +} + /** * Starts the human-first setup journey while retaining `init` for scripts and * existing automation. Both entry points deliberately use the same planner, @@ -49,7 +65,6 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini "preset", "output", "client", - "credentialEnv", "npmPackage", "dockerImage", "url", @@ -62,7 +77,23 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini "upstream" ].find((name) => options[name as keyof SetupCommandOptions] !== undefined); if (incompatible !== undefined) { - throw new CliUsageError(`Option '--${flagName(incompatible)}' is unavailable when adding a provider-owned account.`); + throw new CliUsageError(`Option '--${flagName(incompatible)}' is unavailable when adding an account profile.`); + } + const kind = await accountAdditionKind(options, context); + if (kind === "environment") { + if (options.oauthClientSecretsFile !== undefined) { + throw new CliUsageError("Option '--oauth-client-secrets-file' is unavailable when adding an environment-backed account."); + } + if (options.verify === true) { + throw new CliUsageError( + "Option '--verify' is unavailable when adding an environment-backed account because this configuration has no provider-declared readiness check." + ); + } + await runEnvironmentProfileSetup(options, context); + return { verification: "not-applicable", exitCode: 0, reports: [] }; + } + if (options.credentialEnv !== undefined) { + throw new CliUsageError("Option '--credential-env' is unavailable when adding a provider-owned account."); } const added = await runProviderAccountSetup(options, context); const decision = options.verify === true ? "verify" : !isTty(context) ? "skip" : await confirmReadiness(context, "the new account now"); diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index 036fe110..95b7f3d0 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -55,6 +55,11 @@ import { type ProviderAccountAdditionAuditSink, type ProviderAccountAdditionReport } from "../setup/provider-account-onboarding.js"; +import { + runEnvironmentProfileAddition, + type EnvironmentProfileAdditionAuditSink, + type EnvironmentProfileAdditionReport +} from "../setup/environment-profile-onboarding.js"; import { runDefaultProfileChange, type DefaultProfileChangeAuditSink, @@ -139,6 +144,21 @@ export type ConsoleProviderAccountAdditionReport = Pick< "changed" | "write" | "adapter" | "profile" | "actions" >; +/** Adds one static-credential account from the current standard local stdio configuration. */ +export interface ConsoleEnvironmentProfileAdditionRequest { + readonly profile: string; + readonly description?: string; + /** An inherited environment variable name, never a credential value. */ + readonly credentialEnv: string; + readonly makeDefault?: boolean; +} + +/** Console responses intentionally omit configuration bytes, recovery paths, and credential references. */ +export type ConsoleEnvironmentProfileAdditionReport = Pick< + EnvironmentProfileAdditionReport, + "changed" | "write" | "profile" | "actions" +>; + /** Changes only the durable default for an already configured profile. */ export interface ConsoleDefaultProfileChangeRequest { readonly profile: string; @@ -228,6 +248,10 @@ export interface ConsoleControlApplication { addProviderAccount?( request: ConsoleProviderAccountAdditionRequest ): Promise; + /** Available when an initialized local stdio configuration has one safe static credential binding. */ + addEnvironmentProfile?( + request: ConsoleEnvironmentProfileAdditionRequest + ): Promise; /** Available when an initialized configuration has an existing profile to make durable. */ setDefaultProfile?( request: ConsoleDefaultProfileChangeRequest @@ -393,6 +417,32 @@ class ConsoleProviderAccountAuditSink implements ProviderAccountAdditionAuditSin } } +class ConsoleEnvironmentProfileAuditSink implements EnvironmentProfileAdditionAuditSink { + constructor(private readonly trail: AuditTrail) {} + + ensureWritable(): Promise { + return this.trail.ensureWritable(); + } + + intent(event: { readonly profile: string }): Promise { + return this.trail.writeRequiredLifecycle({ + operation: "console/environment-profile-add-intent", + name: "profile", + profile: event.profile, + status: "success" + }); + } + + record(event: { readonly profile: string; readonly status: "success" }): Promise { + return this.trail.writeRequiredLifecycle({ + operation: "console/environment-profile-add", + name: "profile", + profile: event.profile, + status: event.status + }); + } +} + class ConsoleDefaultProfileAuditSink implements DefaultProfileChangeAuditSink { constructor(private readonly trail: AuditTrail) {} @@ -790,6 +840,29 @@ export class ConsoleApplicationService implements ConsoleControlApplication { }; } + async addEnvironmentProfile( + request: ConsoleEnvironmentProfileAdditionRequest + ): Promise { + const configPath = resolvePath(this.configPath); + const source = this.trustedConfiguration?.migrationSource ?? await readConfigMigrationSource(configPath); + const result = await runEnvironmentProfileAddition({ + configPath, + profile: request.profile, + ...(request.description === undefined ? {} : { description: request.description }), + credentialEnv: request.credentialEnv, + ...(request.makeDefault === true ? { makeDefault: true } : {}) + }, { + trustedSource: source, + audit: new ConsoleEnvironmentProfileAuditSink(this.audit) + }); + return { + changed: result.changed, + write: result.write, + profile: result.profile, + actions: result.actions + }; + } + async setDefaultProfile( request: ConsoleDefaultProfileChangeRequest ): Promise { diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index 86c7ba81..6e426506 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -214,6 +214,17 @@ const page = `
+
Add another native OAuth account
@@ -415,6 +426,9 @@ const script = `(() => { const providerAccountEditor = byId("provider-account-editor"); const providerAccountCredentialLabel = byId("provider-account-credential-label"); const providerAccountCredentialFile = byId("provider-account-credential-file"); + const environmentProfileEditor = byId("environment-profile-editor"); + const environmentProfileCredentialLabel = byId("environment-profile-credential-label"); + const environmentProfileCredentialEnv = byId("environment-profile-credential-env"); const nativeOAuthEditor = byId("native-oauth-editor"); const nativeOAuthAccountEditor = byId("native-oauth-account-editor"); const presetInputNames = Object.freeze({ @@ -664,6 +678,7 @@ const script = `(() => { const manualOnly = authentication.mode === "manual-only"; const nativeOAuth = authentication.mode === "miftah-native-oauth"; const accountAddition = record(authentication.accountAddition); + const environmentProfileAddition = record(authentication.environmentProfileAddition); const credentialFileLabel = typeof accountAddition.credentialFileLabel === "string" ? accountAddition.credentialFileLabel : ""; @@ -671,8 +686,13 @@ const script = `(() => { ? accountAddition.credentialFilePlaceholder : ""; const providerAccount = providerAdapter && credentialFileLabel.length > 0 && credentialFilePlaceholder.length > 0; + const credentialEnvironment = typeof environmentProfileAddition.credentialEnvironment === "string" + ? environmentProfileAddition.credentialEnvironment + : ""; + const environmentProfile = manualOnly && credentialEnvironment.length > 0; if (providerAuthenticationView) providerAuthenticationView.hidden = !providerAdapter && !manualOnly; if (providerAccountEditor) providerAccountEditor.hidden = !providerAccount; + if (environmentProfileEditor) environmentProfileEditor.hidden = !environmentProfile; if (providerAccountCredentialLabel) { providerAccountCredentialLabel.textContent = providerAccount ? credentialFileLabel : "Provider credential-file path"; } @@ -680,11 +700,21 @@ const script = `(() => { providerAccountCredentialFile.placeholder = providerAccount ? credentialFilePlaceholder : ""; if (!providerAccount) providerAccountCredentialFile.value = ""; } + if (environmentProfileCredentialLabel) { + environmentProfileCredentialLabel.textContent = environmentProfile + ? "Environment variable that holds this account's credential for " + credentialEnvironment + : "Environment variable that holds this account's credential"; + } + if (environmentProfileCredentialEnv instanceof HTMLInputElement && !environmentProfile) { + environmentProfileCredentialEnv.value = ""; + } if (nativeOAuthEditor) nativeOAuthEditor.hidden = !nativeOAuth; if (nativeOAuthAccountEditor) nativeOAuthAccountEditor.hidden = !nativeOAuth; if (!providerAuthenticationCopy) return; if (manualOnly) { - providerAuthenticationCopy.textContent = "This configuration includes local upstream settings outside Miftah's reviewed adapter envelope. Miftah will not take over OAuth here; use each upstream's documented authentication setup."; + providerAuthenticationCopy.textContent = environmentProfile + ? "This local MCP uses one environment credential binding. Add another named account by choosing a different environment variable; Miftah stores only that reference and does not launch the upstream." + : "This configuration includes local upstream settings outside Miftah's reviewed adapter envelope. Miftah will not take over OAuth here; use each upstream's documented authentication setup."; return; } if (!providerAdapter) return; @@ -1171,6 +1201,28 @@ const script = `(() => { }); } + const environmentProfileForm = byId("environment-profile-form"); + if (environmentProfileForm instanceof HTMLFormElement) { + environmentProfileForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(environmentProfileForm); + message("Adding the environment-backed account without reading its credential or launching the upstream…"); + try { + await api("/api/v1/profiles/environment-account", { + method: "POST", + body: { + profile: String(data.get("profile") || "").trim(), + description: String(data.get("description") || "").trim() || undefined, + credentialEnv: String(data.get("credentialEnv") || "").trim(), + ...(data.get("makeDefault") === "true" ? { makeDefault: true } : {}) + } + }); + environmentProfileForm.reset(); + await refresh(); + } catch (error) { message(errorMessage(error)); } + }); + } + const manualConnectionForm = byId("manual-connection-form"); if (manualConnectionForm instanceof HTMLFormElement) { manualConnectionForm.addEventListener("submit", async (event) => { diff --git a/src/console/console-config-metadata.ts b/src/console/console-config-metadata.ts index c3fadd42..ea5ea1e0 100644 --- a/src/console/console-config-metadata.ts +++ b/src/console/console-config-metadata.ts @@ -3,6 +3,7 @@ import { getProviderAdapterForProfileTarget, getProviderAdapterForConfiguration } from "../config/provider-adapters.js"; +import { environmentProfileCredentialDestination } from "../setup/environment-profile-onboarding.js"; import type { MiftahConfig } from "../config/types.js"; export interface ConsoleAuthenticationMetadata { @@ -25,6 +26,11 @@ export interface ConsoleAuthenticationMetadata { readonly credentialFileLabel: string; readonly credentialFilePlaceholder: string; }; + /** Present only when one local stdio static credential binding can safely add another account. */ + readonly environmentProfileAddition?: { + /** The child-process environment key expected by the existing upstream. */ + readonly credentialEnvironment: string; + }; } export interface ConsoleProfileReadinessTarget { @@ -119,6 +125,7 @@ export function consoleAuthenticationMetadata(config: MiftahConfig): ConsoleAuth const targets = readinessTargets(config); const adapter = getProviderAdapterForConfiguration(config); const provisioningAdapter = getProviderAdapterForAccountProvisioning(config); + const credentialEnvironment = environmentProfileCredentialDestination(config); if (adapter !== undefined) { return { mode: "provider-adapter", @@ -147,7 +154,8 @@ export function consoleAuthenticationMetadata(config: MiftahConfig): ConsoleAuth credentialOwner: "manual-only", browserHandoff: "manual-only", tokenStore: "external", - ...(targets.length === 0 ? {} : { readinessTargets: targets }) + ...(targets.length === 0 ? {} : { readinessTargets: targets }), + ...(credentialEnvironment === undefined ? {} : { environmentProfileAddition: { credentialEnvironment } }) }; } diff --git a/src/console/console-dashboard-application-service.ts b/src/console/console-dashboard-application-service.ts index e70298f4..af20c472 100644 --- a/src/console/console-dashboard-application-service.ts +++ b/src/console/console-dashboard-application-service.ts @@ -15,6 +15,8 @@ import { type ConsoleDiscoveredNativeOAuthAccountRequest, type ConsoleDiscoveredNativeOAuthConnectionRequest, type ConsoleDiscoveredNativeOAuthOnboardingRequest, + type ConsoleEnvironmentProfileAdditionReport, + type ConsoleEnvironmentProfileAdditionRequest, type ConsoleHealth, type ConsoleNativeOAuthOnboardingRequest, type ConsoleProfileReadinessRequest, @@ -209,6 +211,17 @@ export class ConsoleDashboardApplicationService implements ConsoleControlApplica return result; } + async addEnvironmentProfile( + request: ConsoleEnvironmentProfileAdditionRequest + ): Promise { + const selected = await this.selectedApplication(); + const result = await selected.application.addEnvironmentProfile(request); + // The guarded commit changes the file contents; require a fresh explicit + // selection instead of retaining a stale trusted snapshot. + this.active = undefined; + return result; + } + async setDefaultProfile( request: ConsoleDefaultProfileChangeRequest ): Promise { diff --git a/src/console/console-server.ts b/src/console/console-server.ts index e9be36d9..6bf97bab 100644 --- a/src/console/console-server.ts +++ b/src/console/console-server.ts @@ -56,6 +56,12 @@ const providerAccountAdditionSchema = z.object({ credentialFile: z.string().min(1).max(4_096), makeDefault: z.literal(true).optional() }).strict(); +const environmentProfileAdditionSchema = z.object({ + profile: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,63})$/u), + description: z.string().min(1).max(1_024).optional(), + credentialEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u), + makeDefault: z.literal(true).optional() +}).strict(); const defaultProfileChangeSchema = z.object({ // Profile keys are compatibility data owned by the selected configuration. // Validate their existence in the guarded configuration transaction rather @@ -326,6 +332,20 @@ function publicApplicationError(error: unknown): ConsoleHttpError { "Choose an absolute literal credential-file path." ); } + if (error.code === "ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED") { + return new ConsoleHttpError( + 422, + "environment_profile_addition_unsupported", + "This configuration does not have one simple local environment credential binding to copy safely." + ); + } + if (error.code === "ENVIRONMENT_PROFILE_INPUT_INVALID") { + return new ConsoleHttpError( + 422, + "environment_profile_input_invalid", + "Choose a safe profile name and an environment variable name, not a credential value." + ); + } if ( error.code.startsWith("CONFIG_") || error.code.startsWith("OAUTH_CONNECTION_") || @@ -744,6 +764,27 @@ class LocalConsoleServer implements ConsoleServer { } return; } + if (request.url === "/api/v1/profiles/environment-account") { + if (request.method !== "POST") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "POST" }); + } + this.requireCsrf(request, session); + const parsed = environmentProfileAdditionSchema.safeParse( + await readJsonBody(request, this.options.maximumRequestBytes) + ); + if (!parsed.success) throw new ConsoleHttpError(422, "validation_error", "The request body is invalid."); + if (this.application.addEnvironmentProfile === undefined) { + throw new ConsoleHttpError(404, "not_found", "The requested resource does not exist."); + } + try { + const result = await this.application.addEnvironmentProfile(parsed.data); + session.lastUsedAt = this.options.now(); + writeJson(response, 201, { data: result }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } // This exact route must precede the broad /connections/:reference status route below. if (request.url === "/api/v1/connections/discover") { if (request.method !== "POST") { diff --git a/src/setup/environment-profile-onboarding.ts b/src/setup/environment-profile-onboarding.ts new file mode 100644 index 00000000..95d8fff9 --- /dev/null +++ b/src/setup/environment-profile-onboarding.ts @@ -0,0 +1,324 @@ +import { dirname } from "node:path"; +import { AuditLogger } from "../audit/audit-logger.js"; +import { AuditTrail } from "../audit/audit-trail.js"; +import { + applyConfigReplacement, + readConfigMigrationSource, + restoreConfigReplacementWithoutPublishingBackup, + type ConfigMigrationSource +} from "../cli/migrate-config.js"; +import { + assertSafeProviderAdapterAccountProfileName, + getProviderAdapterForConfiguration, + ProviderAdapterAccountProfileError +} from "../config/provider-adapters.js"; +import { planConfigMigration } from "../config/migrate-config.js"; +import { resolvePath } from "../config/path-resolve.js"; +import type { MiftahConfig, ProfileConfig } from "../config/types.js"; +import { validateConfig } from "../config/validate-config.js"; +import { MiftahError } from "../utils/errors.js"; + +const environmentVariableName = /^[A-Za-z_][A-Za-z0-9_]*$/u; +const environmentReference = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/u; +const supportedTemplateFields = new Set(["description", "env", "policy"]); + +export interface EnvironmentProfileAdditionRequest { + readonly configPath: string; + /** New account identifier; it is stored only as a configuration key and audit label. */ + readonly profile: string; + /** Optional human-facing account label; credentials are never accepted here. */ + readonly description?: string; + /** Name of the inherited process environment variable that contains this account's credential. */ + readonly credentialEnv: string; + /** Changes only the durable default; existing MCP clients still require a restart or new connection. */ + readonly makeDefault?: boolean; +} + +export interface EnvironmentProfileAdditionPlan { + readonly profile: string; + readonly config: MiftahConfig; + readonly actions: readonly string[]; +} + +/** Public command and Console results deliberately omit the candidate configuration. */ +export type EnvironmentProfileAdditionReport = Omit & { + readonly changed: true; + readonly write: true; + /** Exact original bytes retained before the guarded configuration replacement. */ + readonly backupPath?: string; +}; + +export interface EnvironmentProfileAdditionAuditSink { + ensureWritable(): Promise; + /** Records durable intent before Miftah mutates the selected configuration. */ + intent(event: { readonly profile: string }): Promise; + /** Records only a completed durable static-credential profile addition. */ + record(event: { readonly profile: string; readonly status: "success" }): Promise; +} + +export interface EnvironmentProfileAdditionDependencies { + /** A source snapshot already verified by an embedding such as Console. */ + readonly trustedSource?: ConfigMigrationSource; + /** Console callers provide their own redacted local lifecycle journal. */ + readonly audit?: EnvironmentProfileAdditionAuditSink; +} + +function sourceInput(source: ConfigMigrationSource): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(source.originalBytes)); + } catch { + throw new MiftahError("CONFIG_INVALID_JSON", "CONFIG_INVALID_JSON: configuration is not valid JSON"); + } +} + +function unsupported(): never { + throw new MiftahError( + "ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED", + "ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED: this configuration does not have one simple environment-backed credential profile to copy safely" + ); +} + +function inputInvalid(message: string): never { + throw new MiftahError("ENVIRONMENT_PROFILE_INPUT_INVALID", `ENVIRONMENT_PROFILE_INPUT_INVALID: ${message}`); +} + +function assertProfileName(profile: unknown): asserts profile is string { + if (typeof profile !== "string") inputInvalid("choose a safe profile name"); + try { + assertSafeProviderAdapterAccountProfileName(profile); + } catch (error) { + if (error instanceof ProviderAdapterAccountProfileError) inputInvalid("choose a safe profile name"); + throw error; + } +} + +function assertDescription(description: unknown): asserts description is string | undefined { + if (description === undefined) return; + if ( + typeof description !== "string" || + description.length === 0 || + description.length > 1_024 || + description.trim() !== description || + Array.from(description).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + }) + ) { + inputInvalid("choose an optional trimmed account description without control characters"); + } +} + +function assertCredentialEnvironmentName(credentialEnv: unknown): asserts credentialEnv is string { + if (typeof credentialEnv !== "string" || !environmentVariableName.test(credentialEnv)) { + inputInvalid("choose an environment variable name, not a secret value"); + } +} + +function templateCredentialBinding(profile: ProfileConfig): readonly [target: string, source: string] { + if (Object.keys(profile).some((key) => !supportedTemplateFields.has(key))) unsupported(); + const entries = Object.entries(profile.env ?? {}); + if (entries.length !== 1) unsupported(); + const [target, source] = entries[0]!; + if (!environmentVariableName.test(target) || environmentReference.exec(source) === null) unsupported(); + return [target, source]; +} + +interface EnvironmentProfileTemplate { + readonly profile: ProfileConfig; + readonly target: string; +} + +function supportedEnvironmentProfileTemplate(config: MiftahConfig): EnvironmentProfileTemplate | undefined { + if ( + getProviderAdapterForConfiguration(config) !== undefined || + (config.version === "3" && config.oauth?.connections !== undefined) || + config.upstream === undefined || + config.upstream.transport !== "stdio" || + config.upstreams !== undefined + ) { + return undefined; + } + const template = config.profiles[config.defaultProfile]; + if (template === undefined) return undefined; + try { + const [target] = templateCredentialBinding(template); + for (const profile of Object.values(config.profiles)) { + const [existingTarget] = templateCredentialBinding(profile); + if (existingTarget !== target || profile.policy !== template.policy) return undefined; + } + return { profile: template, target }; + } catch { + return undefined; + } +} + +/** Returns the one local child-process environment destination safe for another named account, if any. */ +export function environmentProfileCredentialDestination(config: MiftahConfig): string | undefined { + return supportedEnvironmentProfileTemplate(config)?.target; +} + +function environmentProfile(template: ProfileConfig, target: string, options: EnvironmentProfileAdditionRequest): ProfileConfig { + return { + ...(template.policy === undefined ? {} : { policy: template.policy }), + ...(options.description === undefined ? {} : { description: options.description }), + env: { [target]: `\${${options.credentialEnv}}` } + }; +} + +/** + * Plans one additional static-credential account from a standard configuration. + * The scope is deliberately narrow: only one existing literal + * `${ENVIRONMENT_VARIABLE}` binding can be replaced, so setup never parses, + * stores, or duplicates a secret value or an arbitrary profile override. + */ +export function planEnvironmentProfileAddition( + input: unknown, + options: EnvironmentProfileAdditionRequest +): EnvironmentProfileAdditionPlan { + assertProfileName(options.profile); + assertDescription(options.description); + assertCredentialEnvironmentName(options.credentialEnv); + + const migrated = planConfigMigration(input); + const config = structuredClone(migrated.config) as MiftahConfig; + const template = supportedEnvironmentProfileTemplate(config); + if (template === undefined) unsupported(); + if (Object.hasOwn(config.profiles, options.profile)) { + throw new MiftahError("PROFILE_ALREADY_EXISTS", "PROFILE_ALREADY_EXISTS: account profile already exists"); + } + const credentialReference = `\${${options.credentialEnv}}`; + if (Object.values(config.profiles).some((profile) => Object.values(profile.env ?? {}).includes(credentialReference))) { + inputInvalid("choose a distinct environment variable name for this account"); + } + + const candidate = structuredClone(config) as MiftahConfig; + candidate.profiles[options.profile] = environmentProfile(template.profile, template.target, options); + if (options.makeDefault === true) candidate.defaultProfile = options.profile; + const requireProfileSwitchConfirmation = candidate.security?.requireProfileSwitchConfirmation !== true; + const requireExplicitSelectionForDestructive = candidate.security?.requireExplicitSelectionForDestructive !== true; + candidate.security = { + ...candidate.security, + requireProfileSwitchConfirmation: true, + requireExplicitSelectionForDestructive: true + }; + validateConfig(candidate); + + return { + profile: options.profile, + config: candidate, + actions: [ + ...migrated.actions, + `Created environment-backed account profile '${options.profile}'.`, + ...(requireProfileSwitchConfirmation ? ["Enabled required profile-switch confirmation."] : []), + ...(requireExplicitSelectionForDestructive ? ["Required explicit selection for destructive tools."] : []), + ...(options.makeDefault === true ? [`Set durable default profile to '${options.profile}'.`] : []) + ] + }; +} + +class ConfiguredEnvironmentProfileAuditSink implements EnvironmentProfileAdditionAuditSink { + constructor(private readonly trail: AuditTrail) {} + + ensureWritable(): Promise { + return this.trail.ensureWritable(); + } + + intent(event: { readonly profile: string }): Promise { + return this.trail.writeRequiredLifecycle({ + operation: "config/environment-profile-add-intent", + name: "profile", + profile: event.profile, + status: "success" + }); + } + + record(event: { readonly profile: string; readonly status: "success" }): Promise { + return this.trail.writeRequiredLifecycle({ + operation: "config/environment-profile-add", + name: "profile", + profile: event.profile, + status: event.status + }); + } +} + +function configuredAuditSink(config: MiftahConfig, configPath: string): EnvironmentProfileAdditionAuditSink | undefined { + if (config.audit?.enabled === false || config.audit?.path === undefined) return undefined; + const logger = new AuditLogger(resolvePath(config.audit.path, dirname(configPath)), { + includeArguments: config.audit.includeArguments, + failureMode: config.audit.failureMode, + rotation: config.audit.rotation, + integrity: config.audit.integrity + }); + return new ConfiguredEnvironmentProfileAuditSink(new AuditTrail(config.name, logger)); +} + +function serializedConfig(config: MiftahConfig): Buffer { + return Buffer.from(`${JSON.stringify(config, null, 2)}\n`); +} + +async function restoreAfterAuditFailure( + configPath: string, + source: ConfigMigrationSource, + candidate: MiftahConfig +): Promise { + const replacement = await readConfigMigrationSource(configPath); + if (!replacement.originalBytes.equals(serializedConfig(candidate))) { + throw new MiftahError( + "AUDIT_WRITE_FAILED", + "AUDIT_WRITE_FAILED: required audit finalization failed and configuration recovery could not be confirmed" + ); + } + await restoreConfigReplacementWithoutPublishingBackup(configPath, replacement, source); +} + +function auditFailureAfterRestoration(backupPath: string): MiftahError { + return new MiftahError( + "AUDIT_WRITE_FAILED", + `AUDIT_WRITE_FAILED: required audit finalization failed; configuration was restored and the original configuration backup was retained at '${backupPath}'`, + { backupPaths: [backupPath] } + ); +} + +function auditFailureWithUnconfirmedRecovery(backupPath: string): MiftahError { + return new MiftahError( + "AUDIT_WRITE_FAILED", + `AUDIT_WRITE_FAILED: required audit finalization failed and configuration recovery could not be confirmed; the original configuration backup was retained at '${backupPath}'`, + { backupPaths: [backupPath] } + ); +} + +/** + * Performs one fail-closed, guarded environment-profile replacement. It does + * not start the upstream or read a credential; the running MCP process later + * receives its selected inherited environment variable in the usual way. + */ +export async function runEnvironmentProfileAddition( + options: EnvironmentProfileAdditionRequest, + dependencies: EnvironmentProfileAdditionDependencies = {} +): Promise { + const configPath = resolvePath(options.configPath); + const source = dependencies.trustedSource ?? await readConfigMigrationSource(configPath); + const plan = planEnvironmentProfileAddition(sourceInput(source), { ...options, configPath }); + const audit = dependencies.audit ?? configuredAuditSink(plan.config, configPath); + await audit?.ensureWritable(); + await audit?.intent({ profile: plan.profile }); + const backupPath = await applyConfigReplacement(configPath, source, plan.config); + try { + await audit?.record({ profile: plan.profile, status: "success" }); + } catch { + try { + await restoreAfterAuditFailure(configPath, source, plan.config); + } catch { + throw auditFailureWithUnconfirmedRecovery(backupPath); + } + throw auditFailureAfterRestoration(backupPath); + } + return { + changed: true, + write: true, + profile: plan.profile, + actions: plan.actions, + ...(backupPath === undefined ? {} : { backupPath }) + }; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 3fe11d91..5cc2dfa7 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -108,6 +108,8 @@ export type MiftahErrorCode = | "OAUTH_AUTHORIZATION_NOT_ENABLED" | "PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED" | "PROVIDER_ACCOUNT_INPUT_INVALID" + | "ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED" + | "ENVIRONMENT_PROFILE_INPUT_INVALID" | "CLIENT_ENTRY_STATIC_LAUNCH_UNSUPPORTED" | "CONSOLE_LAUNCHER_UNAVAILABLE" | "CONSOLE_CONFIGURATION_NOT_FOUND" diff --git a/tests/cli-exit-codes.test.ts b/tests/cli-exit-codes.test.ts index 3ee998de..20847d74 100644 --- a/tests/cli-exit-codes.test.ts +++ b/tests/cli-exit-codes.test.ts @@ -97,6 +97,8 @@ const expectedErrorExitCodes: Record = { OAUTH_AUTHORIZATION_NOT_ENABLED: CLI_EXIT_CODES.config, PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED: CLI_EXIT_CODES.config, PROVIDER_ACCOUNT_INPUT_INVALID: CLI_EXIT_CODES.config, + 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, CONSOLE_LAUNCHER_UNAVAILABLE: CLI_EXIT_CODES.operation, CONSOLE_CONFIGURATION_NOT_FOUND: CLI_EXIT_CODES.config, diff --git a/tests/cli-parse.test.ts b/tests/cli-parse.test.ts index a9a7b69b..7b623162 100644 --- a/tests/cli-parse.test.ts +++ b/tests/cli-parse.test.ts @@ -233,7 +233,7 @@ describe("CLI parser", () => { expectUsageError(["init", "--native-oauth"]); }); - it("offers reviewed provider-owned account addition only from guided setup", () => { + it("offers safe provider-owned and environment-backed account addition only from guided setup", () => { expect(parseCli([ "setup", "--add-profile", @@ -254,7 +254,26 @@ describe("CLI parser", () => { verify: true } }); + expect(parseCli([ + "setup", + "--add-profile", + "--config", "sentry.json", + "--profile", "personal", + "--credential-env", "SENTRY_PERSONAL_ACCESS_TOKEN", + "--make-default" + ])).toEqual({ + kind: "run", + command: "setup", + options: { + addProfile: true, + config: "sentry.json", + profile: "personal", + credentialEnv: "SENTRY_PERSONAL_ACCESS_TOKEN", + makeDefault: true + } + }); expect(renderCommandHelp("setup")).toContain("--add-profile"); + expect(renderCommandHelp("setup")).toContain("environment-backed account"); expectUsageError(["init", "--add-profile"]); }); diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index 2e6341e9..e84b9cbf 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -11,6 +11,7 @@ import { import { verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.js"; import { MiftahError } from "../src/utils/errors.js"; import { createPrivateConsoleDirectory } from "./helpers/private-console-directory.js"; +import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { startOAuthCompatibilityProbe, type OAuthCompatibilityProbe @@ -670,6 +671,46 @@ describe("Console application service", () => { expect(JSON.stringify(await service.auditRecords(10))).not.toContain(thirdSecrets); }); + it("adds an environment-backed account through the shared guarded lifecycle without reading a credential", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-add-environment-account-")); + temporaryDirectories.push(root); + const configPath = join(root, "sentry.json"); + await writeFile(configPath, `${JSON.stringify(environmentProfileConfig("sentry"), null, 2)}\n`, { mode: 0o600 }); + const service = new ConsoleApplicationService(configPath); + + await expect(service.addEnvironmentProfile({ + profile: "govalidate", + description: "GoValidate Sentry account", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN", + makeDefault: true + })).resolves.toEqual({ + changed: true, + write: true, + profile: "govalidate", + actions: [ + "Created environment-backed account profile 'govalidate'.", + "Enabled required profile-switch confirmation.", + "Required explicit selection for destructive tools.", + "Set durable default profile to 'govalidate'." + ] + }); + const config = JSON.parse(await readFile(configPath, "utf8")) as { + readonly defaultProfile: string; + readonly profiles: Record }>; + }; + expect(config.defaultProfile).toBe("govalidate"); + expect(config.profiles.govalidate).toEqual({ + description: "GoValidate Sentry account", + env: { STATIC_ACCESS_TOKEN: "${STATIC_GOVALIDATE_ACCESS_TOKEN}" }, + policy: "readonly" + }); + await expect(service.auditRecords(10)).resolves.toContainEqual(expect.objectContaining({ + operation: "console/environment-profile-add", + profile: "govalidate", + status: "success" + })); + }); + it("changes the durable default profile without altering existing provider accounts", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-default-profile-")); temporaryDirectories.push(root); diff --git a/tests/console-config-metadata.test.ts b/tests/console-config-metadata.test.ts index a962a7f0..119325c0 100644 --- a/tests/console-config-metadata.test.ts +++ b/tests/console-config-metadata.test.ts @@ -92,4 +92,33 @@ describe("Console configuration metadata", () => { sharedState.profiles.personal!.env!.GSC_CONFIG_DIR = privatePath("miftah", "gsc", "work"); expect(consoleAuthenticationMetadata(sharedState)).not.toHaveProperty("accountAddition"); }); + + it("offers static account addition only for one simple local environment binding", () => { + const config: MiftahConfig = { + version: "3", + name: "internal-tools", + defaultProfile: "production", + upstream: { + transport: "stdio", + command: "internal-mcp", + args: ["--stdio"] + }, + profiles: { + production: { + env: { INTERNAL_API_TOKEN: "${INTERNAL_PRODUCTION_TOKEN}" }, + policy: "readonly" + } + } + }; + + expect(consoleAuthenticationMetadata(config)).toMatchObject({ + mode: "manual-only", + environmentProfileAddition: { credentialEnvironment: "INTERNAL_API_TOKEN" } + }); + expect(JSON.stringify(consoleAuthenticationMetadata(config))).not.toContain("INTERNAL_PRODUCTION_TOKEN"); + + const unsafe = structuredClone(config); + unsafe.profiles.production!.args = ["--tenant=production"]; + expect(consoleAuthenticationMetadata(unsafe)).not.toHaveProperty("environmentProfileAddition"); + }); }); diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 51c872aa..cd78b780 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -15,6 +15,7 @@ import { createPrivateConsoleDirectory, writePrivateConsoleFile } from "./helpers/private-console-directory.js"; +import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { startOAuthCompatibilityProbe } from "./helpers/fake-remote-upstream.js"; const temporaryDirectories: string[] = []; @@ -489,6 +490,45 @@ describe("Console dashboard application service", () => { await expect(service.health()).rejects.toMatchObject({ code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" }); }); + it("adds a static environment-backed account only after selection and clears the stale selection", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-environment-account-")); + temporaryDirectories.push(root); + const directory = await createPrivateConsoleDirectory(root); + const configPath = join(directory, "sentry.json"); + await writeConfig(configPath, environmentProfileConfig("sentry")); + const service = new ConsoleDashboardApplicationService({ + defaultConfigPath: join(directory, "miftah.json"), + configDirectory: directory + }); + const request = { + profile: "govalidate", + description: "GoValidate Sentry account", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN", + makeDefault: true + }; + + await expect(service.addEnvironmentProfile(request)).rejects.toMatchObject({ + code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" + }); + const initial = await service.configMetadata(); + const selected = initial.catalog?.configurations.find((configuration) => configuration.name === "sentry"); + if (selected === undefined) throw new Error("Expected discovered Sentry configuration."); + await service.selectConfiguration(selected.id); + + await expect(service.addEnvironmentProfile(request)).resolves.toMatchObject({ + changed: true, + write: true, + profile: "govalidate" + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + defaultProfile: "govalidate", + profiles: { govalidate: { env: { STATIC_ACCESS_TOKEN: "${STATIC_GOVALIDATE_ACCESS_TOKEN}" } } } + }); + await expect(service.addEnvironmentProfile(request)).rejects.toMatchObject({ + code: "CONSOLE_CONFIGURATION_SELECTION_REQUIRED" + }); + }); + it("rejects an unselected Console operation before scanning its catalog", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-unselected-")); temporaryDirectories.push(root); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index 8dadf767..240954ae 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -16,6 +16,7 @@ import { createPrivateConsoleDirectory, writePrivateConsoleFile } from "./helpers/private-console-directory.js"; +import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { startOAuthCompatibilityProbe } from "./helpers/fake-remote-upstream.js"; const temporaryDirectories: string[] = []; @@ -739,6 +740,9 @@ describe("local Console control server", () => { expect(html).toContain("Add another native OAuth account"); expect(html).toContain('id="provider-account-editor"'); expect(html).toContain("Add another provider account"); + expect(html).toContain('id="environment-profile-editor"'); + expect(html).toContain("Add another environment-backed account"); + expect(html).toContain("It does not read the credential, start this upstream, or copy a provider token cache."); expect(html).toContain("Advanced manual OAuth registration"); expect(html).toContain("acceptLocalCommand"); expect(html).toContain('id="preset-onboarding-view"'); @@ -777,6 +781,7 @@ describe("local Console control server", () => { expect(javascript).toContain("/api/v1/connections/discover"); expect(javascript).toContain("/api/v1/profiles/native-oauth/discover"); expect(javascript).toContain("/api/v1/profiles/provider-account"); + expect(javascript).toContain("/api/v1/profiles/environment-account"); expect(javascript).toContain("/api/v1/onboarding/preset"); expect(javascript).toContain("local-stdio"); expect(javascript).toContain("acceptLocalCommand"); @@ -872,6 +877,9 @@ describe("local Console control server", () => { expect(javascript).toContain('const providerAccountEditor = byId("provider-account-editor");'); expect(javascript).toContain("authentication.accountAddition"); expect(javascript).toContain("Adding the provider-owned account"); + expect(javascript).toContain('const environmentProfileEditor = byId("environment-profile-editor");'); + expect(javascript).toContain("authentication.environmentProfileAddition"); + expect(javascript).toContain("Adding the environment-backed account without reading its credential or launching the upstream"); expect(javascript).toContain('const nativeOAuthAccountEditor = byId("native-oauth-account-editor");'); expect(javascript).toContain("if (nativeOAuthAccountEditor) nativeOAuthAccountEditor.hidden = !nativeOAuth;"); expect(javascript).toContain('action === "credential" ? "DELETE" : "POST"'); @@ -2209,6 +2217,74 @@ describe("local Console control server", () => { } }); + it("adds a static environment-backed account only through a strict CSRF-protected Console request", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-environment-account-")); + temporaryDirectories.push(directory); + const configPath = join(directory, "sentry.json"); + await writeFile(configPath, `${JSON.stringify(environmentProfileConfig("sentry"), null, 2)}\n`, { mode: 0o600 }); + const server = await startConsoleServer(configPath, { bootstrapCredential: "test-only-bootstrap-credential" }); + + try { + const session = await bootstrapSession(server); + const endpoint = new URL("/api/v1/profiles/environment-account", server.url); + const request = { + profile: "govalidate", + description: "GoValidate Sentry account", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN", + makeDefault: true + }; + const rejected = await fetch(endpoint, { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ ...request, accessToken: "must-not-be-accepted" }) + }); + expect(rejected.status).toBe(422); + expect(await readFile(configPath, "utf8")).not.toContain("govalidate"); + + const created = await fetch(endpoint, { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify(request) + }); + expect(created.status).toBe(201); + const payload = await created.json() as { readonly data: Record }; + expect(payload.data).toEqual({ + changed: true, + write: true, + profile: "govalidate", + actions: [ + "Created environment-backed account profile 'govalidate'.", + "Enabled required profile-switch confirmation.", + "Required explicit selection for destructive tools.", + "Set durable default profile to 'govalidate'." + ] + }); + expect(JSON.stringify(payload)).not.toContain("STATIC_GOVALIDATE_ACCESS_TOKEN"); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + defaultProfile: "govalidate", + profiles: { + govalidate: { + description: "GoValidate Sentry account", + env: { STATIC_ACCESS_TOKEN: "${STATIC_GOVALIDATE_ACCESS_TOKEN}" }, + policy: "readonly" + } + } + }); + } finally { + await server.close(); + } + }); + it("changes the durable default profile only through a strict CSRF-protected Console request", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-console-default-profile-")); temporaryDirectories.push(directory); diff --git a/tests/environment-profile-onboarding.test.ts b/tests/environment-profile-onboarding.test.ts new file mode 100644 index 00000000..fd6ecea7 --- /dev/null +++ b/tests/environment-profile-onboarding.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildPresetConfig } from "../src/config/presets.js"; +import { + planEnvironmentProfileAddition, + runEnvironmentProfileAddition +} from "../src/setup/environment-profile-onboarding.js"; +import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; + +describe("environment credential profile onboarding", () => { + it("plans a distinct static-credential profile without copying a secret or changing the upstream", () => { + const input = environmentProfileConfig("sentry"); + const original = structuredClone(input); + + const plan = planEnvironmentProfileAddition(input, { + configPath: "/Users/example/.config/miftah/sentry.json", + profile: "govalidate", + description: "GoValidate Sentry account", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN", + makeDefault: true + }); + + expect(plan).toMatchObject({ + profile: "govalidate", + config: { + defaultProfile: "govalidate", + upstream: input.upstream, + profiles: { + default: input.profiles.default, + govalidate: { + description: "GoValidate Sentry account", + env: { STATIC_ACCESS_TOKEN: "${STATIC_GOVALIDATE_ACCESS_TOKEN}" }, + policy: "readonly" + } + }, + security: { + requireProfileSwitchConfirmation: true, + requireExplicitSelectionForDestructive: true + } + }, + actions: [ + "Created environment-backed account profile 'govalidate'.", + "Enabled required profile-switch confirmation.", + "Required explicit selection for destructive tools.", + "Set durable default profile to 'govalidate'." + ] + }); + expect(JSON.stringify(plan.actions)).not.toContain("STATIC_GOVALIDATE_ACCESS_TOKEN"); + expect(input).toEqual(original); + }); + + it("reports only the security safeguards that account onboarding must enable", () => { + const hardened = { + ...environmentProfileConfig("sentry"), + security: { + requireProfileSwitchConfirmation: true, + requireExplicitSelectionForDestructive: true + } + }; + + const plan = planEnvironmentProfileAddition(hardened, { + configPath: "/Users/example/.config/miftah/sentry.json", + profile: "govalidate", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN" + }); + + expect(plan.actions).toEqual([ + "Created environment-backed account profile 'govalidate'." + ]); + }); + + it("refuses a remote MCP because profile environment does not authenticate HTTP requests", () => { + const input = buildPresetConfig("posthog", "streamable-http", { + url: "https://mcp.example.test/mcp", + credentialEnv: "POSTHOG_WORK_TOKEN", + headerName: "Authorization", + headerPrefix: "Bearer " + }); + input.profiles.default = { env: { POSTHOG_TOKEN: "${POSTHOG_WORK_TOKEN}" } }; + const original = structuredClone(input); + + const failure = (() => { + try { + return planEnvironmentProfileAddition(input, { + configPath: "/Users/example/.config/miftah/posthog.json", + profile: "personal", + credentialEnv: "POSTHOG_PERSONAL_TOKEN" + }); + } catch (error) { + return error; + } + })(); + + expect(failure).toMatchObject({ code: "ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED" }); + expect(input).toEqual(original); + }); + + it("refuses a new account that reuses an existing credential environment reference", () => { + const input = environmentProfileConfig("sentry"); + const original = structuredClone(input); + + const failure = (() => { + try { + return planEnvironmentProfileAddition(input, { + configPath: "/Users/example/.config/miftah/sentry.json", + profile: "second-account", + credentialEnv: "STATIC_DEFAULT_ACCESS_TOKEN" + }); + } catch (error) { + return error; + } + })(); + + expect(failure).toMatchObject({ code: "ENVIRONMENT_PROFILE_INPUT_INVALID" }); + expect(input).toEqual(original); + }); + + it("keeps a third independent account eligible after a second safe account exists", () => { + const first = planEnvironmentProfileAddition(environmentProfileConfig("sentry"), { + configPath: "/Users/example/.config/miftah/sentry.json", + profile: "govalidate", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN" + }); + + const third = planEnvironmentProfileAddition(first.config, { + configPath: "/Users/example/.config/miftah/sentry.json", + profile: "personal", + credentialEnv: "STATIC_PERSONAL_ACCESS_TOKEN" + }); + + expect(third.config.profiles).toMatchObject({ + default: { env: { STATIC_ACCESS_TOKEN: "${STATIC_DEFAULT_ACCESS_TOKEN}" } }, + govalidate: { env: { STATIC_ACCESS_TOKEN: "${STATIC_GOVALIDATE_ACCESS_TOKEN}" } }, + personal: { env: { STATIC_ACCESS_TOKEN: "${STATIC_PERSONAL_ACCESS_TOKEN}" } } + }); + expect(third.config.defaultProfile).toBe("default"); + }); + + it("fails closed and restores the original configuration when final audit recording fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-environment-profile-audit-")); + const configPath = join(directory, "sentry.json"); + const original = `${JSON.stringify(environmentProfileConfig("sentry"), null, 2)}\n`; + await writeFile(configPath, original, { mode: 0o600 }); + const auditEvents: string[] = []; + + try { + await expect(runEnvironmentProfileAddition({ + configPath, + profile: "govalidate", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN" + }, { + audit: { + ensureWritable: async () => { auditEvents.push("writable"); }, + intent: async () => { auditEvents.push("intent"); }, + record: async () => { + auditEvents.push("record"); + throw new Error("simulated final audit failure"); + } + } + })).rejects.toMatchObject({ code: "AUDIT_WRITE_FAILED" }); + + expect(auditEvents).toEqual(["writable", "intent", "record"]); + expect(await readFile(configPath, "utf8")).toBe(original); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/helpers/environment-profile-config.ts b/tests/helpers/environment-profile-config.ts new file mode 100644 index 00000000..5b7bd844 --- /dev/null +++ b/tests/helpers/environment-profile-config.ts @@ -0,0 +1,27 @@ +/** + * A direct-executable local STDIO configuration used by static credential + * account tests. `process.execPath` is an absolute `.exe` on Windows, unlike + * the Sentry preset's intentional `npx` command-shell boundary. + */ +export function environmentProfileConfig(name = "environment-account") { + return { + version: "3" as const, + name, + defaultProfile: "default", + upstream: { + transport: "stdio" as const, + command: process.execPath, + args: ["provider.mjs"] + }, + profiles: { + default: { + description: "Default static account", + policy: "readonly", + env: { STATIC_ACCESS_TOKEN: "${STATIC_DEFAULT_ACCESS_TOKEN}" } + } + }, + policies: { + readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] } + } + }; +} diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index 8178ac15..066a207d 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -107,6 +107,10 @@ describe("product README", () => { expect(readme).toContain("miftah setup --add-profile"); expect(readme).toContain("The new account receives its own `GSC_CONFIG_DIR`"); expect(readme).toContain("Add another provider account"); + expect(readme).toContain("### Add another local environment-backed account"); + expect(readme).toContain("--credential-env SENTRY_PERSONAL_ACCESS_TOKEN"); + expect(readme).toContain("Remote HTTP MCPs are refused because profile environments do not authenticate HTTP requests."); + expect(readme).toContain("Add another environment-backed account"); expect(readme).toContain("### Change the durable default later"); expect(readme).toContain("miftah profile set-default --config ~/.config/miftah/gsc.json --profile google-personal"); expect(readme).toContain("does not switch an already-running MCP client"); diff --git a/tests/setup-command.test.ts b/tests/setup-command.test.ts index eb8af9a7..6aa56fa1 100644 --- a/tests/setup-command.test.ts +++ b/tests/setup-command.test.ts @@ -15,6 +15,7 @@ import { runNativeOAuthSetup } from "../src/cli/setup-native-oauth.js"; import { runProviderAccountSetup } from "../src/cli/setup-provider-account.js"; import { buildPresetConfig } from "../src/config/presets.js"; import { validateConfig } from "../src/config/validate-config.js"; +import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; import { startOAuthCompatibilityProbe } from "./helpers/fake-remote-upstream.js"; const outputRoot = resolve(process.cwd(), ".setup-command-test-output"); @@ -81,7 +82,7 @@ describe("setup command", () => { expect(renderCommandHelp("setup")).toContain("guided MCP setup flow"); }); - it("renders incompatible provider-account flags exactly as users pass them", async () => { + it("requires a configuration before classifying account-addition flags", async () => { await expect(runSetupCommand({ addProfile: true, credentialEnv: "GSC_TOKEN" @@ -90,7 +91,76 @@ describe("setup command", () => { output: new PassThrough(), cwd: outputRoot, launcher: { command: process.execPath, args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] } - })).rejects.toThrow("Option '--credential-env' is unavailable when adding a provider-owned account."); + })).rejects.toThrow("Adding an account profile requires --config."); + }); + + it("adds a named environment-backed account to an existing standard configuration without launching its upstream", async () => { + await mkdir(outputRoot, { recursive: true }); + const configPath = resolve(outputRoot, "sentry.json"); + await writeFile(configPath, `${JSON.stringify(environmentProfileConfig("sentry"), null, 2)}\n`, { mode: 0o600 }); + const input = Object.assign(new PassThrough(), { isTTY: false }); + const output = Object.assign(new PassThrough(), { isTTY: false }); + let transcript = ""; + output.on("data", (chunk: Buffer) => { transcript += chunk.toString(); }); + + await expect(runSetupCommand({ + addProfile: true, + config: configPath, + profile: "govalidate", + description: "GoValidate Sentry account", + credentialEnv: "STATIC_GOVALIDATE_ACCESS_TOKEN", + makeDefault: true + }, { + input, + output, + cwd: outputRoot, + launcher: { command: process.execPath, args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] } + })).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + + const config = validateConfig(JSON.parse(await readFile(configPath, "utf8"))); + expect(config).toMatchObject({ + defaultProfile: "govalidate", + profiles: { + govalidate: { + description: "GoValidate Sentry account", + env: { STATIC_ACCESS_TOKEN: "${STATIC_GOVALIDATE_ACCESS_TOKEN}" }, + policy: "readonly" + } + } + }); + expect(profileReadinessMocks.run).not.toHaveBeenCalled(); + expect(transcript).toContain("Created environment-backed account profile 'govalidate'."); + expect(transcript).toContain("Enabled required profile-switch confirmation."); + expect(transcript).toContain("Required explicit selection for destructive tools."); + }); + + it("cancels an interactive environment-backed account setup on EOF or SIGINT before changing the configuration", async () => { + await mkdir(outputRoot, { recursive: true }); + const configPath = resolve(outputRoot, "environment-account-cancelled.json"); + const original = `${JSON.stringify(environmentProfileConfig("sentry"), null, 2)}\n`; + await writeFile(configPath, original, { mode: 0o600 }); + + for (const cancel of [ + (streams: ReturnType) => streams.input.end(), + (streams: ReturnType) => streams.input.write("\u0003") + ]) { + const streams = createStreams(); + const command = runSetupCommand({ + addProfile: true, + config: configPath + }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { command: process.execPath, args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] } + }); + + await streams.transcript.waitFor("New account profile name"); + cancel(streams); + await expect(command).rejects.toThrow("Environment-backed account setup was cancelled."); + streams.input.end(); + expect(await readFile(configPath, "utf8")).toBe(original); + } }); it("creates a native OAuth configuration only after endpoint discovery without registering or storing a credential", async () => {