Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/install/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export type InstallerErrorCategory =
| "detection"
| "version"
| "secret_intake"
| "storage"
| "config_parse"
| "config_write"
| "rollback"
Expand Down
48 changes: 39 additions & 9 deletions src/install/managed-files.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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.",
});
}
}

Expand All @@ -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.",
});
}
}
11 changes: 5 additions & 6 deletions src/install/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -254,7 +251,9 @@ export async function runInstallSession(
? "model_registry"
: installerError.category === "secret_intake"
? "secret"
: "cli",
: installerError.category === "storage"
? "storage"
: "cli",
},
],
errorCode: installerError.code,
Expand Down
25 changes: 13 additions & 12 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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();
}
});
70 changes: 56 additions & 14 deletions test/install/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
});
});
Loading