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
18 changes: 18 additions & 0 deletions src/auth/oauth-scope-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,24 @@ describe("checkOAuthProviderScope", () => {
commandName,
);
expect(result.status).toBe("unavailable");
// The inconclusive outcome names the inconclusive evidence: the HTTP
// status is the only thing this check ever surfaces about the body.
if (result.status === "unavailable")
expect(result.message).toContain("provider HTTP 500");
});

test("a timed-out probe says timed out, not network down", async () => {
stubFetch(() => {
throw new DOMException("The operation timed out.", "TimeoutError");
});
const result = await checkOAuthProviderScope(
"codex",
codexTokens,
commandName,
);
expect(result.status).toBe("unavailable");
if (result.status === "unavailable")
expect(result.message).toContain("probe timed out");
});

test("xai: builds the probe from staged tokens", async () => {
Expand Down
25 changes: 20 additions & 5 deletions src/auth/oauth-scope-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,22 @@ function insufficientScope(providerLabel: string): OAuthScopeCheckResult {
};
}

function unavailable(providerLabel: string): OAuthScopeCheckResult {
function unavailable(
providerLabel: string,
cause?: string,
): OAuthScopeCheckResult {
const why = cause === undefined ? "" : ` (${cause})`;
return {
status: "unavailable",
message: `Couldn't confirm ${providerLabel} API access right now — continuing without blocking setup.`,
message: `Couldn't confirm ${providerLabel} API access right now${why} — continuing without blocking setup.`,
};
}

/** AbortSignal.timeout's rejection, so "network down" and "probe hung" differ. */
function isTimeout(err: unknown): boolean {
return err instanceof Error && err.name === "TimeoutError";
}

function invalidCredentials(providerLabel: string): OAuthScopeCheckResult {
return {
status: "blocked",
Expand Down Expand Up @@ -94,7 +103,7 @@ function classifyStatus(
providerLabel: string,
): OAuthScopeCheckResult {
if (status === 401 || status === 403) return insufficientScope(providerLabel);
return unavailable(providerLabel);
return unavailable(providerLabel, `provider HTTP ${status}`);
}

async function checkCodexScope(
Expand All @@ -117,7 +126,10 @@ async function checkCodexScope(
} catch (err) {
if (isDefinitiveRefreshAuthRejection(err))
return invalidCredentials(providerLabel);
return unavailable(providerLabel);
return unavailable(
providerLabel,
isTimeout(err) ? "probe timed out" : undefined,
);
}
}

Expand All @@ -138,7 +150,10 @@ async function checkXaiScope(
} catch (err) {
if (isDefinitiveRefreshAuthRejection(err))
return invalidCredentials(providerLabel);
return unavailable(providerLabel);
return unavailable(
providerLabel,
isTimeout(err) ? "probe timed out" : undefined,
);
}
}

Expand Down
25 changes: 25 additions & 0 deletions src/mcp/auth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,29 @@ describe("mcp auth-store", () => {
final.tokens === undefined || final.tokens.access_token === "v1",
).toBe(true);
});

test("a corrupt auth file is backed up before the next overwrite", async () => {
const home = await tempHome();
const path = authFilePath(linear, home);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, "{corrupt", { mode: 0o600 });

// Corrupt state reads as absent (a fresh authorization is offered), but
// the corrupt bytes survive for diagnosis.
expect(await loadAuthState(linear, home)).toEqual({});
expect(await readFile(`${path}.corrupt`, "utf8")).toBe("{corrupt");

// The overwrite behind a fresh authorization cannot consume the last copy.
await updateAuthState(
linear,
(state) => {
state.tokens = { access_token: "tok", token_type: "bearer" };
},
home,
);
expect((await loadAuthState(linear, home)).tokens?.access_token).toBe(
"tok",
);
expect(await readFile(`${path}.corrupt`, "utf8")).toBe("{corrupt");
});
});
34 changes: 26 additions & 8 deletions src/mcp/auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ import {
unlink,
writeFile,
} from "node:fs/promises";
import { readFileSync } from "node:fs";
import { readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { getLogger } from "@intx/log";
import type {
OAuthClientInformationFull,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { LOG_NAMESPACE_ROOT } from "../branding.js";
import { SETTINGS_DIR_NAME } from "../branding.js";

const logger = getLogger([LOG_NAMESPACE_ROOT, "mcp", "auth-store"]);

// Per-server OAuth state. The durable pair — registered client info and
// tokens — reaches disk; the PKCE verifier stays instance-local in the
// provider. Legacy codeVerifier keys are stripped on write.
Expand Down Expand Up @@ -74,20 +78,32 @@ export function isEnoent(err: unknown): boolean {
);
}

function parseAuthState(raw: string): MCPAuthState | undefined {
// Corrupt credentials are backed up (never logged and never discarded with
// the next overwrite) so an operator can diagnose a half-written tokens file.
// The backup keeps owner-only mode like the auth file itself.
function preserveCorruptAuthFile(path: string, raw: string): void {
try {
writeFileSync(`${path}.corrupt`, raw, { mode: 0o600 });
} catch {
// Backing up is best-effort; the fresh authorization proceeds either way.
}
logger.warn`corrupt MCP auth file read as no state path=${path}`;
}

function parseAuthState(raw: string, path: string): MCPAuthState | undefined {
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null)
return parsed as MCPAuthState;
} catch {
// Corrupt auth file: treat as no state; a fresh authorization overwrites it.
preserveCorruptAuthFile(path, raw);
}
return undefined;
}

function stateFromRaw(raw: string | undefined): MCPAuthState {
function stateFromRaw(raw: string | undefined, path: string): MCPAuthState {
if (raw === undefined) return {};
return parseAuthState(raw) ?? {};
return parseAuthState(raw, path) ?? {};
}

function readAuthFileSync(path: string): string | undefined {
Expand All @@ -112,7 +128,8 @@ export async function loadAuthState(
identity: MCPAuthIdentity,
home: string = homedir(),
): Promise<MCPAuthState> {
return stateFromRaw(await readAuthFile(authFilePath(identity, home)));
const path = authFilePath(identity, home);
return stateFromRaw(await readAuthFile(path), path);
}

// Cache refresh: missing/unreadable/corrupt files return undefined so the
Expand All @@ -121,14 +138,15 @@ export function tryLoadAuthStateSync(
identity: MCPAuthIdentity,
home: string = homedir(),
): MCPAuthState | undefined {
const path = authFilePath(identity, home);
let raw: string | undefined;
try {
raw = readAuthFileSync(authFilePath(identity, home));
raw = readAuthFileSync(path);
} catch {
return undefined;
}
if (raw === undefined) return undefined;
return parseAuthState(raw);
return parseAuthState(raw, path);
}

function isEexist(err: unknown): boolean {
Expand Down
23 changes: 19 additions & 4 deletions src/mcp/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AgentTool } from "@intx/agent";
import type { ToolCall, ToolResult } from "@intx/types/runtime";
import type { PermissionGate } from "../permission/gate.js";
import { gateAgentTools } from "../plugins/permission-plugin.js";
import { getLogger } from "@intx/log";
import {
scrubSecretShapedContent,
scrubSecretShapedValue,
Expand All @@ -20,10 +21,13 @@ import {
} from "./client.js";
import { applyMcpResultProjection } from "./result-projection.js";
import { mcpToolName } from "./tool-name.js";
import { LOG_NAMESPACE_ROOT } from "../branding.js";

export const MCP_RECONNECTING_TOOL_ERROR =
"MCP server is reconnecting; retry the call once it reports connected.";

const logger = getLogger([LOG_NAMESPACE_ROOT, "mcp", "plugin"]);

/**
* Stable marker prefixing JSON-serialized `structuredContent` (alone or
* supplementing an empty flatten); distinguishes server data from free text.
Expand Down Expand Up @@ -156,8 +160,12 @@ export function mcpClientTools(
provenance: "mcp:post-policy-pre-flatten",
});
archivedFullEnvelope = true;
} catch {
// Archive write must not fail a successful tool result.
} catch (err) {
// Archive write must not fail a successful tool result; the
// skip still gets a receipt with the call it dropped. The
// result path is secret scrubbed throughout — the receipt is too.
const message = err instanceof Error ? err.message : String(err);
logger.warn`evidence archive skipped for successful MCP tool result call=${call.id} error=${scrubSecretShapedContent(message)}`;
}
}
const projected = isError
Expand Down Expand Up @@ -229,8 +237,15 @@ export function mcpClientTools(
callId: call.id,
provenance: "mcp:error",
});
} catch {
// Archive write must not fail a successful tool result.
} catch (archiveErr) {
// archiveErr here comes from the archive writer, never the
// payload, but the result path is secret scrubbed throughout —
// the receipt is too.
const message =
archiveErr instanceof Error
? archiveErr.message
: String(archiveErr);
logger.warn`evidence archive skipped for failed MCP tool result call=${call.id} underlying=${scrubbed} error=${scrubSecretShapedContent(message)}`;
}
}
return {
Expand Down
58 changes: 57 additions & 1 deletion src/permission/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SETTINGS_DIR_NAME } from "../branding.js";
import {
loadGlobalApprovals,
loadPendingProjectApprovals,
Expand Down Expand Up @@ -188,3 +189,58 @@ describe("provider-model store", () => {
]);
});
});

// A torn (non-JSON) store file must surface, never silently report an empty
// store: the empty read is the first half of read-modify-write, so a swallowed
// corruption reconciles the file's grants away on the next write. Repairs
// always work because the write path refuses to touch the corrupt bytes.
describe("torn store recovery", () => {
const globalStorePathFor = (): string =>
join(home, SETTINGS_DIR_NAME, "permissions.json");

test("a corrupt global store surfaces on load instead of reporting zero grants", async () => {
await saveGlobalApproval({ tool: "run_shell", pattern: "git *" }, home);
await writeFile(globalStorePathFor(), "{torn");
await expect(loadGlobalApprovals(home)).rejects.toThrow(
/permissions store is corrupt/,
);
});

test("a corrupt provider-model store surfaces on load instead of reporting zero grants", async () => {
await saveProviderModelApproval(
"openai:gpt-5",
{ tool: "run_shell", pattern: "npm *" },
home,
);
await writeFile(globalStorePathFor(), "{torn");
await expect(loadProviderModelApprovals(home)).rejects.toThrow(
/permissions store is corrupt/,
);
});

test("a later grant write cannot clobber a torn store's prior grants", async () => {
await saveGlobalApproval({ tool: "run_shell", pattern: "git *" }, home);
await saveGlobalApproval({ tool: "run_shell", pattern: "npm *" }, home);
const path = globalStorePathFor();
const original = await readFile(path, "utf-8");
// Simulate a torn write: the bytes are a shortened prefix of the store.
const torn = original.slice(0, Math.max(original.length - 4, 0));
await writeFile(path, torn);

await expect(
saveGlobalApproval({ tool: "run_shell", pattern: "cargo *" }, home),
).rejects.toThrow(/permissions store is corrupt/);

// The failed write left the corrupt bytes untouched for an operator repair.
expect(await readFile(path, "utf-8")).toBe(torn);

// Repair regains the prior grants; the deferred grant re-applies on retry.
await writeFile(path, original);
await saveGlobalApproval({ tool: "run_shell", pattern: "cargo *" }, home);
expect(await loadGlobalApprovals(home)).toEqual([
{ tool: "run_shell", pattern: "git *" },
{ tool: "run_shell", pattern: "npm *" },
{ tool: "run_shell", pattern: "cargo *" },
]);
});
});
Loading
Loading