diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4e09dca..4999558 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -16,6 +16,15 @@ npm run ci If `npm run package:check` fails, inspect `package.json`, `bin/`, and `dist/` after running `npm run build`. +## Managed Storage Blockers + +If setup reports `secret_storage_failed` with +`Managed secret and state files must not be repository-local.`, check the +launch environment. `HOME` on POSIX/WSL or `USERPROFILE` on native Windows must +point to the user's real profile directory, not to the current repository. The +installer intentionally stops instead of writing the GonkaGate API key or +install state into a project-local path. + ## Future Runtime Blockers The implemented installer should report blockers for: diff --git a/src/install/contracts.ts b/src/install/contracts.ts index 25fe87f..87bf444 100644 --- a/src/install/contracts.ts +++ b/src/install/contracts.ts @@ -87,6 +87,7 @@ export type InstallerErrorCategory = | "detection" | "version" | "secret_intake" + | "storage" | "config_parse" | "config_write" | "rollback" diff --git a/src/install/managed-files.ts b/src/install/managed-files.ts index 0479059..ac3f918 100644 --- a/src/install/managed-files.ts +++ b/src/install/managed-files.ts @@ -1,4 +1,5 @@ -import { join, relative, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { InstallerError } from "./errors.js"; import type { RuntimePlatform } from "./platform-path.js"; import { isNativeWindowsProfilePath } from "./platform-path.js"; @@ -20,18 +21,43 @@ export function resolveManagedPaths(homeDir: string): ManagedPaths { }; } +export function resolveManagedHomeDir(env: NodeJS.ProcessEnv): string { + const homeDir = [env.HOME, env.USERPROFILE].find( + (value): value is string => value !== undefined && value.trim().length > 0, + ); + + if (homeDir === undefined) { + throw new InstallerError({ + category: "storage", + code: "secret_storage_failed", + message: + "Cannot resolve GonkaGate managed storage without HOME or USERPROFILE.", + }); + } + + return homeDir; +} + export function assertManagedPathOutsideProject( managedPath: string, projectRoot: string, ): void { - const relativePath = relative(resolve(projectRoot), resolve(managedPath)); + const resolvedProjectRoot = resolve(projectRoot); + const resolvedManagedPath = resolve(managedPath); + const relativePath = relative(resolvedProjectRoot, resolvedManagedPath); + if ( relativePath === "" || - (!relativePath.startsWith("..") && !relativePath.startsWith("/")) + (relativePath !== ".." && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath)) ) { - throw new Error( - "Managed secret and state files must not be repository-local.", - ); + throw new InstallerError({ + category: "storage", + code: "secret_storage_failed", + detail: `Resolved managed path ${resolvedManagedPath} is inside project root ${resolvedProjectRoot}.`, + message: "Managed secret and state files must not be repository-local.", + }); } } @@ -44,8 +70,12 @@ export function assertNativeWindowsProfileManagedPath( platform === "windows" && !isNativeWindowsProfilePath(managedPath, userProfile) ) { - throw new Error( - "Managed Windows files must stay inside the current user profile.", - ); + throw new InstallerError({ + category: "storage", + code: "secret_storage_failed", + detail: `Resolved managed path ${managedPath} is outside user profile ${userProfile}.`, + message: + "Managed Windows files must stay inside the current user profile.", + }); } } diff --git a/src/install/session.ts b/src/install/session.ts index 5290480..e1c05b8 100644 --- a/src/install/session.ts +++ b/src/install/session.ts @@ -10,7 +10,7 @@ import type { InstallerDeps } from "./deps.js"; import { toInstallerError } from "./errors.js"; import { detectMimoCode } from "./mimocode.js"; import { fetchGonkaGateModelCatalog } from "./model-catalog.js"; -import { resolveManagedPaths } from "./managed-files.js"; +import { resolveManagedHomeDir, resolveManagedPaths } from "./managed-files.js"; import { resolveMimoGlobalPaths, resolveProjectRoot, @@ -63,10 +63,7 @@ export async function runInstallSession( effectiveDeps, effectiveDeps.cwd(), ); - const homeDir = - effectiveDeps.env().HOME ?? - effectiveDeps.env().USERPROFILE ?? - effectiveDeps.cwd(); + const homeDir = resolveManagedHomeDir(effectiveDeps.env()); const managedPaths = resolveManagedPaths(homeDir); const secret = await collectGonkaGateApiKey( { apiKeyStdin: request.apiKeyStdin }, @@ -254,7 +251,9 @@ export async function runInstallSession( ? "model_registry" : installerError.category === "secret_intake" ? "secret" - : "cli", + : installerError.category === "storage" + ? "storage" + : "cli", }, ], errorCode: installerError.code, diff --git a/test/cli.test.ts b/test/cli.test.ts index d7a74f0..c44178c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -217,7 +217,7 @@ test("CLI can render JSON success and human Next command with injected registry" } }); -test("CLI JSON renders failed and unexpected-error outcomes without secrets", async () => { +test("CLI JSON renders failed and storage-blocker outcomes without secrets", async () => { const failedDeps = createTestDeps(); failedDeps.setCwd(`${failedDeps.root}/project`); failedDeps.setEnv({ @@ -256,35 +256,36 @@ test("CLI JSON renders failed and unexpected-error outcomes without secrets", as failedDeps.cleanup(); } - const unexpectedDeps = createTestDeps(); - unexpectedDeps.setCwd(`${unexpectedDeps.root}/project`); - unexpectedDeps.setEnv({ GONKAGATE_API_KEY: "gp-secret-value" }); - unexpectedDeps.queueCommand({ + const storageDeps = createTestDeps(); + storageDeps.setCwd(`${storageDeps.root}/project`); + storageDeps.setEnv({ GONKAGATE_API_KEY: "gp-secret-value" }); + storageDeps.queueCommand({ exitCode: 0, stderr: "", stdout: "mimo 0.1.0\n", }); - unexpectedDeps.queueCommand({ + storageDeps.queueCommand({ exitCode: 0, stderr: "", stdout: JSON.stringify({ - config: `${unexpectedDeps.root}/project/.config/mimocode`, + config: `${storageDeps.root}/project/.config/mimocode`, }), }); try { const stdout = createBufferWriter(); const result = await run( ["--yes", "--scope", "user", "--model", "alpha", "--json"], - { deps: unexpectedDeps, registry: validatedRegistry, stdout }, + { deps: storageDeps, registry: validatedRegistry, stdout }, ); const parsed = JSON.parse(stdout.contents) as { status: string; errorCode: string; }; - assert.equal(result.status, "failed"); - assert.equal(parsed.status, "failed"); - assert.equal(parsed.errorCode, "unexpected_error"); + assert.equal(result.status, "blocked"); + assert.equal(parsed.status, "blocked"); + assert.equal(parsed.errorCode, "secret_storage_failed"); + assert.doesNotMatch(stdout.contents, /gp-secret-value/); } finally { - unexpectedDeps.cleanup(); + storageDeps.cleanup(); } }); diff --git a/test/install/storage.test.ts b/test/install/storage.test.ts index e5c1810..4e50273 100644 --- a/test/install/storage.test.ts +++ b/test/install/storage.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { join } from "node:path"; import test from "node:test"; -import { resolveManagedPaths } from "../../src/install/managed-files.js"; +import { + resolveManagedHomeDir, + resolveManagedPaths, +} from "../../src/install/managed-files.js"; import { verifyManagedSecret, writeManagedSecret, @@ -64,25 +67,64 @@ test("managed secret storage repairs POSIX permissions without rewriting unchang test("managed secret storage rejects repository-local and out-of-profile Windows paths", async () => { const repoLocal = createTestDeps(); const projectRoot = join(repoLocal.root, "project"); - await assert.rejects(() => - writeManagedSecret(repoLocal, "gp-secret-value", { - homeDir: projectRoot, - platform: "posix", - projectRoot, - }), + await assert.rejects( + () => + writeManagedSecret(repoLocal, "gp-secret-value", { + homeDir: projectRoot, + platform: "posix", + projectRoot, + }), + { + code: "secret_storage_failed", + message: "Managed secret and state files must not be repository-local.", + name: "InstallerError", + }, + ); + await assert.rejects( + () => + writeManagedSecret(repoLocal, "gp-secret-value", { + homeDir: join(projectRoot, "..managed"), + platform: "posix", + projectRoot, + }), + { + code: "secret_storage_failed", + message: "Managed secret and state files must not be repository-local.", + name: "InstallerError", + }, ); repoLocal.cleanup(); const windows = createTestDeps(); const managed = resolveManagedPaths("D:/OtherUser"); assert.match(managed.secretPath, /api-key/); - await assert.rejects(() => - writeManagedSecret(windows, "gp-secret-value", { - homeDir: "D:/OtherUser", - platform: "windows", - projectRoot: "C:/repo", - userProfile: "C:/Users/Current", - }), + await assert.rejects( + () => + writeManagedSecret(windows, "gp-secret-value", { + homeDir: "D:/OtherUser", + platform: "windows", + projectRoot: "C:/repo", + userProfile: "C:/Users/Current", + }), + { + code: "secret_storage_failed", + message: + "Managed Windows files must stay inside the current user profile.", + name: "InstallerError", + }, ); windows.cleanup(); }); + +test("managed secret storage rejects missing profile home before falling back to cwd", () => { + assert.equal( + resolveManagedHomeDir({ HOME: "", USERPROFILE: "C:/Users/Current" }), + "C:/Users/Current", + ); + assert.throws(() => resolveManagedHomeDir({}), { + code: "secret_storage_failed", + message: + "Cannot resolve GonkaGate managed storage without HOME or USERPROFILE.", + name: "InstallerError", + }); +});