Skip to content

feat!: stop the installer from clobbering configured AuthKit projects - #205

Open
nicknisi wants to merge 3 commits into
mainfrom
nicknisi/dax-feedback
Open

feat!: stop the installer from clobbering configured AuthKit projects#205
nicknisi wants to merge 3 commits into
mainfrom
nicknisi/dax-feedback

Conversation

@nicknisi

Copy link
Copy Markdown
Member

Summary

A customer ran npx workos@latest against a Next.js app that already had AuthKit fully wired up (@workos-inc/authkit-nextjs, custom middleware, SCIM/SSO reconciliation). The CLI provisioned a brand new unclaimed WorkOS environment, rewrote his .env.local with those credentials, then died four minutes later on an opaque HTTP 500 from the LLM gateway and left everything it had written in place.

He asked for "a check that detects an existing AuthKit install and stops before touching dashboard config." There wasn't one, and two of us thought there was. The check cited internally (run-with-core.ts:252) is the checkAuthentication actor: it reads the CLI's own keyring config store and never looks at the project. detectSingleIntegration only answers which framework is present, and no @workos-inc/* package appears in it. The only AuthKit-aware detector we own (doctor/checks/sdk.ts) is reachable during install only after the agent has already run.

The root cause is ordering, not a missing feature. resolveInstallCredentials runs in the bin.ts handlers before the installer state machine exists, provisions an unclaimed environment, and writes credentials into the project's env file. readExistingCredentials then reads that same file and finds the key the CLI just wrote. Two individually correct functions in the wrong order, where the guard silently reads back its own write. The terminal error state has no rollback.

What changed

Area Change
Preflight guard New src/lib/preflight-authkit.ts, wired ahead of credential resolution in all three install entry points (install, dashboard, $0). Prompts on an interactive TTY, exits non-zero in agent/CI/JSON mode, --force overrides.
Ordering New src/lib/project-env.ts reads whichever env file the CLI would write (.env.local when package.json exists, else .env) before provisioning. tryProvisionUnclaimedEnv refuses again at the write site, so a direct caller can't bypass it.
Env safety writeEnvLocal and the .env branch of writeCredentialsEnv now do line-preserving upserts, so comments, blank lines, and key order survive. One git-ignored backup of the pre-CLI file, written once and never overwritten.
Dashboard truthfulness setHomepageUrl reads before writing and reports already set instead of an unconditional updated. Output now states credential provenance, so an unclaimed throwaway can't be mistaken for production.
Error surfacing One shared failure-classifier.ts replaces three duplicated 5xx regexes (agent-interface.ts, cli-adapter.ts, headless-adapter.ts). Deterministic gateway failures no longer render as "temporarily unavailable, try again in a few minutes."
Proxy timeout 120s → 600s in both startCredentialProxy and startClaimTokenProxy. The reported path uses the claim-token proxy, so fixing one was fixing neither.

Two details worth a reviewer's eye:

  • The backup had to be gitignored before it is written. ensureGitignore's existing covering patterns (.env.local, .env*.local, .env*) never match a .bak; a Next.js project ships .env*.local so the old guard no-ops; and stageAndCommit runs git add -A with commit defaulting to true. An unignored backup would have committed a live WORKOS_API_KEY and WORKOS_CLAIM_TOKEN.
  • installer-core.ts re-emits the already-rendered message onto the error event, so the adapters classify our own copy rather than the raw SDK text. Today's transient path only round-trips by accident ("temporarily unavailable" happens to match service.*unavailable). Hence the named DETERMINISTIC_HEADLINE constant and its round-trip test: without it the new verdict would be computed and then silently discarded one hop later.

run-with-core.ts also now reads .env for non-JS projects, where the deleted private helper only ever read .env.local. That is the "close the read-back loop" half of the fix, but it is a behavior change beyond a pure refactor: a Django/Rails project with WORKOS_API_KEY in .env will now have it picked up as existing credentials.

Test plan

Unit tests only, by design, so the tests carry the whole guarantee. Several were mutation-checked (the fix was temporarily disabled to confirm the new assertions actually go red).

pnpm typecheck                 # clean
pnpm test                      # 136 files, 2084 tests pass
pnpm build                     # clean
pnpm lint && pnpm format:check # clean

# The load-bearing structural checks
grep -c 'assertNoExistingAuthKit' src/bin.ts   # 3 (one per entry point)
grep -c 'forceOption' src/bin.ts               # 3 (definition + both option sets)
grep -c '120_000' src/lib/credential-proxy.ts  # 0
grep -c '50\[0-9\]' src/lib/agent-interface.ts src/lib/adapters/*.ts  # 0 each

The three grep checks are deliberate. bin.ts runs runCli() at import and has no test seams, so the ordering invariant that this whole PR turns on cannot be asserted by a unit test. Guard placement relative to resolveInstallCredentials is worth checking by eye in review.

End-to-end sanity, run by hand:

  • WORKOS_MODE=agent workos install --install-dir <fixture with @workos-inc/authkit-nextjs> → exit 1, {"error":{"code":"authkit_already_installed",...}}, and the fixture directory is untouched (no .env, no .env.local, no .gitignore written).
  • Positive control: a fixture with @workos-inc/node only sails past the guard, confirming no false positive on the base SDK.
  • workos --force --help parses on the .strict() $0 parser.

Not in this PR

  • The server-side half. LlmGatewayService.createMessage calls messages.create non-streaming, and the pinned @anthropic-ai/sdk@0.40.0 throws a status-less AnthropicError for any max_tokens > 21,333, which toApiErrorResponse turns into an opaque 500. That is a separate PR against workos/workos (packages/api), independently mergeable. Its deploy should land with or after this release: once the gateway aggregates a stream internally, a non-streaming client receives no bytes until the turn completes, which the old 120s timeout would have killed.
  • --repair for projects the guard now turns away. The guard points at workos doctor.
  • The duplicated .env writers in the python and go integrations still destroy comments.

`npx workos` provisioned a fresh WorkOS environment and rewrote the
project's env file before the installer state machine existed, then read
back its own write when it later checked for existing credentials. In a
project that already had AuthKit wired up, that replaced the real
credentials and destroyed every comment in the file, and a failed run
left all of it in place with no rollback.

Reported against 0.18.0 by a customer whose Next.js app already had
@workos-inc/authkit-nextjs, custom middleware, and SCIM/SSO
reconciliation wired up.

- Add a preflight guard (src/lib/preflight-authkit.ts) ahead of
  credential resolution in all three install entry points: it prompts on
  an interactive TTY, exits non-zero in agent/CI/JSON mode, and takes
  `--force` as the override. Matches AUTHKIT_PACKAGES only, so
  @workos-inc/node alone does not trip it.
- Read the project's env file before provisioning, and refuse again at
  the write site, so a project that already has WORKOS_API_KEY is never
  provisioned over even by a direct caller.
- Preserve comments, blank lines, and key order on every env write, and
  leave one git-ignored backup of the pre-CLI file. The backup is
  gitignored before it is written: `stageAndCommit` runs `git add -A`,
  so an unignored backup would commit a live API key.
- Read the homepage URL before overwriting it, and state credential
  provenance in the dashboard-config output, so an unclaimed throwaway
  environment cannot be mistaken for production.
- Classify deterministic gateway failures separately from transient ones,
  in one shared classifier instead of three duplicated regexes, so users
  stop being told to retry a failure that recurs every time.
- Raise the 120s upstream socket timeout to 600s in both proxy
  factories. The reported path uses the claim-token proxy, so fixing
  only one was fixing neither.

BREAKING CHANGE: `workos install` now stops in a project that already has
an AuthKit SDK installed. Pass `--force` to continue.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a real customer incident where npx workos@latest provisioned a new WorkOS environment and overwrote credentials in a project that already had AuthKit fully wired up. The root cause was ordering: resolveInstallCredentials provisioned and wrote credentials before any guard could inspect the project state, and the existing "guard" silently read back its own write.

  • Preflight guard (preflight-authkit.ts) added before credential resolution in all three entry points, detecting AuthKit SDKs in package.json and exiting early in agent/CI/JSON mode; interactive users get a prompt with accurate context about what will happen.
  • No-clobber enforcement now exists at two independent layers (resolveInstallCredentials and tryProvisionUnclaimedEnv), and the old readExistingCredentials helper that only read .env.local is replaced with readProjectEnvCredentials which scans all four env files the frameworks actually load.
  • Env upsert (upsertEnvLines) rewrites keys in place without destroying comments, blank lines, or key ordering; a .bak backup is written once and gitignored before the write.
  • Failure classifier (failure-classifier.ts) consolidates three copies of the "5xx means transient" regex into a single ordered classifier that correctly distinguishes deterministic gateway errors (which recur on every retry) from genuine outages.
  • Proxy timeout raised from 120 s to 600 s in both proxy factories; the claim-token proxy (used by the unclaimed-environment path) was previously not fixed despite the bug report.

Confidence Score: 5/5

Safe to merge. The guard runs before any credential resolution, the no-clobber invariant is enforced at two independent layers, and the env upsert replaces the destructive overwrite without touching keys it wasn't asked to change.

The three structural correctness properties the PR turns on — guard placement before credential resolution, read-before-write at both the resolveInstallCredentials and tryProvisionUnclaimedEnv layers, and the failure-classifier ordering — are each individually tested. The failure classifier's round-trip tests confirm the deterministic verdict survives the emitError re-emit hop. The backup is gitignored before it's written. No rollback path introduces new data loss.

Files Needing Attention: No files require special attention. The deliberate behaviour change in run-with-core.ts (reading .env for non-JS projects) is the only side-effect beyond the pure guard path, and it is documented in both the code and the PR description.

Important Files Changed

Filename Overview
src/bin.ts Wires assertNoExistingAuthKit before resolveInstallCredentials in all three entry points; adds --force option to installerOptions and $0 parser. Ordering is the core invariant and the placement is correct.
src/lib/preflight-authkit.ts New preflight guard; detects AuthKit SDKs, exits non-zero in non-interactive/JSON modes, prompts on TTY, respects --force. Handles the JSON-mode-on-TTY edge case correctly.
src/lib/project-env.ts New module separating the write target path (resolveProjectEnvPath) from the full credential scan (readProjectEnvCredentials). Reads all four env files frameworks load, closing the loop the old .env.local-only helper missed.
src/lib/env-writer.ts Replaces destructive overwrite with upsertEnvLines (key-in-place rewrite preserving comments/whitespace), adds backupEnvFile (one backup per file, gitignored before write), and writeSecretFile (0600 for new files).
src/lib/failure-classifier.ts New shared classifier consolidating three copies of the 5xx=transient regex. Ordering matters: deterministic checks precede the generic 5xx branch. Named constant enables the round-trip the adapters rely on.
src/lib/resolve-install-credentials.ts Adds a project-env check before provisioning: if WORKOS_API_KEY already exists in the project, skip provisioning and fall back to login only. Closes the read-back loop that previously read the key the CLI itself had just written.
src/lib/unclaimed-env-provision.ts Adds no-clobber guard at the write site (defense-in-depth), so a direct caller that bypasses resolveInstallCredentials cannot overwrite existing credentials.
src/lib/credential-proxy.ts Timeout raised from 120 s to 600 s in both startCredentialProxy and startClaimTokenProxy. Both proxy factories are covered; the old fix only applied to one.
src/lib/workos-management.ts setHomepageUrl now reads before writing; reports already set instead of unconditional updated. Adds describeCredentialProvenance row so the environment a key belongs to is visible.
src/lib/run-with-core.ts Replaces private readExistingCredentials (.env.local only) with readProjectEnvCredentials (all four env files). Documented as a deliberate behaviour change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["npx workos install"] --> B["applyInsecureStorage"]
    B --> C["assertNoExistingAuthKit\npreflight-authkit.ts"]
    C --> D{AuthKit SDK in\npackage.json?}
    D -->|No| G["resolveInstallCredentials"]
    D -->|Yes, force flag| G
    D -->|Yes, non-interactive| E["exit 1\nauthkit_already_installed"]
    D -->|Yes, interactive| F{User confirms?}
    F -->|No| H["exit 2 CANCELLED"]
    F -->|Yes| G
    G --> I{WORKOS_API_KEY\nin project env?}
    I -->|Yes| J["skip provisioning\nauthenticate only"]
    I -->|No| K["tryProvisionUnclaimedEnv"]
    K --> L{WORKOS_API_KEY\nalready present?}
    L -->|Yes| M["return false\nno-clobber guard"]
    L -->|No| N["provisionUnclaimedEnvironment"]
    N --> O["writeCredentialsEnv\nupsert + backup"]
    J --> P["handleInstall"]
    O --> P
Loading

Reviews (3): Last reviewed commit: "fix: align the env writer's key matching..." | Re-trigger Greptile

Comment thread src/lib/env-writer.ts Outdated
Comment thread src/lib/workos-management.ts
nicknisi added 2 commits July 24, 2026 21:27
Review found the no-clobber refusal only inspected one env file
(`.env.local` whenever package.json exists), while credential-discovery
already treats four files as WorkOS credential sources. A JS project
whose real key lives in `.env`, with no AuthKit SDK yet, therefore still
got a throwaway environment provisioned and `.env.local` written with
it. `.env.local` outranks `.env` in Next.js/Vite/Remix/SvelteKit load
order, so the app silently authenticated against an empty environment
while the real key sat on disk looking correct: the read-back-your-own-
write bug, surviving in the dashboard-first setup flow.

`readProjectEnvCredentials` now scans all four files in framework
precedence order and tolerates `export ` prefixes and indentation.
`resolveProjectEnvPath` is unchanged: it is the write target, and
conflating "which file would I clobber" with "does this project already
have credentials" was the defect.

Also from review:

- Gate the preflight guard on `isJsonMode()` as well as
  `isPromptAllowed()`. `--json` on a real TTY took the interactive
  branch, printing prose into the machine-readable stream and failing as
  `prompt_unavailable` rather than `authkit_already_installed`.
- Mirror the source file's permission bits onto the env backup, and birth
  new env files 0600. A `chmod 600 .env.local` was getting a
  world-readable twin holding a live API key, outside the `.env*` glob
  most secret scanners watch.
- Preserve CRLF in env upserts instead of rewriting a Windows file to LF.
- Explain the refusal on the path that actually fires. It previously
  logged to the debug file only, so the user saw a bare exit-4
  `auth_required` indistinguishable from a provisioning failure.
- Don't promise to rewrite an env file whose credentials will be kept.
- Thread the API key into the provenance row, so a `--api-key` run stops
  naming a stored environment that the writes never touched.
- Register `--force` in the machine-readable command tree, since the new
  error tells agents to pass it.
- Change installer-core's empty-message fallback, which was byte-
  identical to the classifier's deterministic signature and would have
  rendered a git or filesystem failure as an AI-service failure.

Test hardening for three assertions that passed with the fix reverted:
the headless adapter had no coverage of the shared classifier at all, and
the upstream_timeout and cli-adapter cases asserted only pre-existing
behavior.
Three review findings from Greptile on #205.

`upsertEnvLines` extracted the key with `trimmed.slice(0, eq)`, so any
assignment the no-clobber reader recognizes but that form did not —
`WORKOS_API_KEY = x` (spaces around `=`) or `export WORKOS_API_KEY=x` —
failed the `pending` lookup. The old line kept its stale value and the new
one was appended below it, leaving the file holding the key twice with
conflicting values. The reader and writer now recognize the same shapes,
and an `export ` prefix is preserved rather than dropped, since removing it
would stop the var being exported when the file is sourced.

The refusal message in `tryProvisionUnclaimedEnv` named
`resolveProjectEnvPath` — the file the CLI *would* write — instead of the
file the key was actually found in. In a JS project whose key lives in
`.env`, that pointed the user at a `.env.local` that may not exist. Matches
the idiom already used at `resolve-install-credentials.ts:60`.

`workosRequest` sent `Content-Type: application/json` on every request,
including the bodyless GET added for the homepage-URL read.

Verified load-bearing by reverting each fix and observing the new tests go
red: the two key-shape cases, the message-path case, and the header case.
The indentation and commented-out-assignment cases are regression guards
for behavior the refactor had to preserve, and pass either way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant