fix(connections): secure Grok history and repair no-auth and login cancellation

Store Grok history encrypted on the authorized task checkpoint. Restore regular files into a temporary home without a shared host symlink. Permit no-auth routes without a vault credential and keep their session identity stable. Abort pending Claude browser-code input so cancellation and timeout can dispose the login process.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 09:47:34 -05:00
1 parent 88e35f27fe
commit c00dbd8ffe
7 files changed
+183 -38

No files matched your search

@@ -26,7 +26,7 @@ export async function grokHomeHasSession(home: string, sessionId: string): Promi
throw error;
}
for await (const entry of entries) {
if (--remaining < 0) throw new Error("Grok session history exceeds its lookup bound");
if (--remaining < 0) return false;
if (entry.isSymbolicLink()) continue;
if ((entry.isFile() || entry.isDirectory()) &&
(entry.name === sessionId || entry.name.startsWith(`${sessionId}.`))) return true;
+50 -11
View File
@@ -4,11 +4,11 @@ import { issueRecoveryActionService } from "../services/issue-recovery-actions.j
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat, readlink, readdir } from "node:fs/promises";
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat, readdir, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
import { createDb, companies, agents, agentTaskSessions, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { aiConnectionService } from "../services/ai-connections.js";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
@@ -50,29 +50,68 @@ beforeAll(async () => {
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it("retains only Grok sessions across private runtime homes and isolates agents and accounts", async () => {
it.each([
["openai", "codex_local", "responses"],
["anthropic", "claude_local", "anthropic_messages"],
["openrouter", "opencode_local", "chat_completions"],
] as const)("prepares a no-auth %s endpoint without a vault secret", async (provider, adapterType, protocol) => {
const account = await service.save(companyId, "alice", {
provider, method: "api_key", ownership: "personal", name: `No-auth ${provider}`,
agentIds: [], allAgents: true,
routing: { kind: "local", protocol, baseUrl: "http://127.0.0.1:9000/v1", auth: "none" },
}, "");
const binding = { provider, method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const;
const config = { model: "fixture-model", env: { OPENAI_API_KEY: "host-key", ANTHROPIC_API_KEY: "host-key" } };
const first = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
const second = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
try {
expect(first.sessionIdentity).toBe(second.sessionIdentity);
expect(first.sessionIdentity).toContain("no-auth");
expect(JSON.stringify(first.config)).not.toContain("host-key");
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
expect(grant.credentialSecretRefs).toEqual([]);
} finally { await Promise.all([first.cleanup(), second.cleanup()]); }
});
it("encrypts retained Grok history and restores only the authorized agent, task, and account", async () => {
const save = (name: string) => service.save(companyId, "alice", { provider: "xai", method: "api_key", ownership: "personal", name, agentIds: [], allAgents: true }, `fixture-${name}`);
const account = await save("Grok history");
const other = await save("Other Grok history");
const runInput = { ...input, adapterType: "grok_local", responsibleUserId: "alice", config: {}, binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
const taskKey = `grok-history-${randomUUID()}`;
await db.insert(agentTaskSessions).values({ companyId, agentId, adapterType: "grok_local", taskKey, sessionParamsJson: {} });
const runInput = { ...input, taskKey, adapterType: "grok_local", responsibleUserId: "alice", config: {}, binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
const first = await prepareManagedAiRuntime(db, runInput);
const sessions = await readlink(path.join(String(first.config.env.GROK_HOME), "sessions"));
const sessions = path.join(String(first.config.env.GROK_HOME), "sessions");
expect((await stat(sessions)).isDirectory()).toBe(true);
await writeFile(path.join(sessions, "history.json"), '{"fixture":"history"}', { mode: 0o600 });
// Never retain a symlink to another agent's transcript or a credential file.
const outside = path.join(home, "sibling-transcript.json");
await writeFile(outside, "sibling-private-history", { mode: 0o600 });
await symlink(outside, path.join(sessions, "sibling-link.json"));
await writeFile(path.join(String(first.config.env.GROK_HOME), "auth.json"), "disposable-auth", { mode: 0o600 });
const checkpoint = await first.checkpointSessionHistory!();
await db.update(agentTaskSessions).set({ sessionParamsJson: { sessionId: "history", ...checkpoint } }).where(eq(agentTaskSessions.taskKey, taskKey));
await first.cleanup();
await expect(access(first.home!)).rejects.toThrow();
expect(await readdir(sessions)).toEqual(["history.json"]);
expect((await stat(sessions)).mode & 0o777).toBe(0o700);
await expect(access(sessions)).rejects.toThrow();
const [stored] = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.taskKey, taskKey));
expect(JSON.stringify(stored.sessionParamsJson)).not.toContain("fixture");
expect(JSON.stringify(stored.sessionParamsJson)).not.toContain("sibling-private-history");
expect(stored.sessionParamsJson?.paperclipGrokHistory).toMatchObject({ material: { scheme: "local_encrypted_v1" } });
const resumed = await prepareManagedAiRuntime(db, runInput);
const otherAgent = await prepareManagedAiRuntime(db, { ...runInput, agentId: randomUUID() });
const otherAccount = await prepareManagedAiRuntime(db, { ...runInput, binding: { ...runInput.binding, connectionId: other.connectionId, grantId: other.grantId } });
const otherTask = await prepareManagedAiRuntime(db, { ...runInput, taskKey: "another-task" });
try {
expect(resumed.home).not.toBe(first.home);
expect(await readlink(path.join(String(resumed.config.env.GROK_HOME), "sessions"))).toBe(sessions);
expect(await readlink(path.join(String(otherAgent.config.env.GROK_HOME), "sessions"))).not.toBe(sessions);
expect(await readlink(path.join(String(otherAccount.config.env.GROK_HOME), "sessions"))).not.toBe(sessions);
const restored = path.join(String(resumed.config.env.GROK_HOME), "sessions");
expect(await readdir(restored)).toEqual(["history.json"]);
expect(await readFile(path.join(restored, "history.json"), "utf8")).toBe('{"fixture":"history"}');
expect((await stat(restored)).mode & 0o777).toBe(0o700);
for (const runtime of [otherAgent, otherAccount, otherTask]) {
expect(await readdir(path.join(String(runtime.config.env.GROK_HOME), "sessions"))).toEqual([]);
}
await expect(access(path.join(String(resumed.config.env.GROK_HOME), "auth.json"))).rejects.toThrow();
} finally { await Promise.all([resumed.cleanup(), otherAgent.cleanup(), otherAccount.cleanup()]); }
} finally { await Promise.all([resumed.cleanup(), otherAgent.cleanup(), otherAccount.cleanup(), otherTask.cleanup()]); }
});
it("authenticates local Gemini probes and runs with the saved key in an isolated home", async () => {
const root = await mkdtemp(path.join(home, "gemini-auth-"));
+17 -22
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { HttpError, unprocessable } from "../errors.js";
import { mkdtemp, mkdir, symlink, writeFile, readFile, rm } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
@@ -17,7 +17,7 @@ import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
import { prepareGrokSessionHistory } from "./grok-session-history.js";
export function isAiConnectionBusy(error: unknown): error is HttpError {
return error instanceof HttpError && error.status === 422 &&
@@ -219,6 +219,7 @@ export async function prepareManagedAiRuntime(
allowUninstalledShared?: boolean;
allowLegacyValidation?: boolean;
config: Record<string, unknown>;
taskKey?: string | null;
},
) {
const configuredEnv =
@@ -269,13 +270,15 @@ export async function prepareManagedAiRuntime(
"The selected default changed. Retry this execution.",
);
const credentialRef = selection.grant.credentialSecretRefs.find((ref) => ref.configPath === "ai.credential");
if (!credentialRef) throw unprocessable("The selected AI credential is unavailable");
const readFreshness = async () => (await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
.from(companySecrets).where(and(eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, credentialRef.secretId))).limit(1))[0];
const freshness = await readFreshness();
const value = await service.credential(selection);
const afterRead = await readFreshness();
if (!freshness || freshness.version !== afterRead?.version || freshness.epoch !== afterRead.epoch) throw unprocessable("The AI credential changed during preparation; retry this execution");
const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing;
const noAuth = routing?.auth === "none";
if (!noAuth && !credentialRef) throw unprocessable("The selected AI credential is unavailable");
const readFreshness = async () => credentialRef ? (await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
.from(companySecrets).where(and(eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, credentialRef.secretId))).limit(1))[0] : undefined;
const freshness = noAuth ? undefined : await readFreshness();
const value = noAuth ? "" : await service.credential(selection);
const afterRead = noAuth ? undefined : await readFreshness();
if (!noAuth && (!freshness || freshness.version !== afterRead?.version || freshness.epoch !== afterRead.epoch)) throw unprocessable("The AI credential changed during preparation; retry this execution");
home = await mkdtemp(
path.join(
os.tmpdir(),
@@ -289,7 +292,6 @@ export async function prepareManagedAiRuntime(
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
...managedAiHomeEnvironment(home),
};
const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing;
const capability =
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
selection.attribution.method
@@ -339,18 +341,10 @@ export async function prepareManagedAiRuntime(
.digest("hex")
.slice(0, 16);
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${credentialRef.secretId}:${freshness.epoch}`;
if (input.adapterType === "grok_local") {
// Retain only transcripts across disposable credential homes. Scope them
// to the company, agent and credential identity, including rotations.
// Removing the runtime home removes the link, never its private target.
const scope = createHash("sha256")
.update(JSON.stringify([input.companyId, input.agentId, sessionIdentity]))
.digest("hex");
const sessions = path.join(resolvePaperclipInstanceRootForAdapter(), "companies", input.companyId, "grok-sessions", scope);
await mkdir(sessions, { recursive: true, mode: 0o700 });
await symlink(sessions, path.join(providerHome, "sessions"), "dir");
}
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${noAuth ? "no-auth" : `${credentialRef!.secretId}:${freshness!.epoch}`}`;
const retainGrokHistory = input.adapterType === "grok_local"
? await prepareGrokSessionHistory(db, { ...input, sessionIdentity, providerHome })
: undefined;
return {
sessionIdentity,
config: {
@@ -365,6 +359,7 @@ export async function prepareManagedAiRuntime(
accountOwnerUserId: selection.grant.subjectUserId,
identity,
home,
checkpointSessionHistory: retainGrokHistory,
cleanup: async () => {
try {
if (subscriptionFile) {
@@ -0,0 +1,87 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { mkdir, open, readdir, realpath, writeFile } from "node:fs/promises";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { agentTaskSessions, type Db } from "@paperclipai/db";
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
const MAX_BYTES = 16 * 1024 * 1024;
const MAX_ENTRIES = 5000;
const FIELD = "paperclipGrokHistory";
type Entry = { name: string; bytes: string };
function validName(name: string) {
return name.split("/").length <= 3 && name.split("/").every(part =>
/^[a-zA-Z0-9_.-]+$/.test(part) && part !== "." && part !== "..");
}
/** Called only after managed connection authorization. No retained host path is exposed to a CLI. */
export async function prepareGrokSessionHistory(db: Db, input: {
companyId: string; agentId: string; taskKey?: string | null;
sessionIdentity: string; providerHome: string;
}) {
const directory = path.join(input.providerHome, "sessions");
await mkdir(directory, { mode: 0o700 });
if (!input.taskKey) return async (): Promise<Record<string, unknown> | undefined> => undefined;
const scope = createHash("sha256").update(JSON.stringify([
input.companyId, input.agentId, input.taskKey, input.sessionIdentity,
])).digest("hex");
const where = and(eq(agentTaskSessions.companyId, input.companyId),
eq(agentTaskSessions.agentId, input.agentId), eq(agentTaskSessions.adapterType, "grok_local"),
eq(agentTaskSessions.taskKey, input.taskKey));
const [row] = await db.select({ params: agentTaskSessions.sessionParamsJson }).from(agentTaskSessions).where(where).limit(1);
const retained = row?.params?.[FIELD] as { scope?: unknown; material?: StoredSecretVersionMaterial } | undefined;
if (retained?.scope === scope && retained.material) {
const value = await localEncryptedProvider.resolveVersion({ material: retained.material, externalRef: null });
if (Buffer.byteLength(value) > MAX_BYTES * 2) throw new Error("Grok history exceeds its storage bound");
const archive = JSON.parse(value) as { scope: string; entries: Entry[] };
// Authenticate the scope inside the ciphertext as well as in the row metadata.
if (archive.scope !== scope || !Array.isArray(archive.entries) || archive.entries.length > MAX_ENTRIES)
throw new Error("Grok history scope is invalid");
let size = 0;
for (const entry of archive.entries) {
if (typeof entry.name !== "string" || !validName(entry.name) || typeof entry.bytes !== "string")
throw new Error("Grok history entry is invalid");
const bytes = Buffer.from(entry.bytes, "base64");
size += bytes.length;
if (size > MAX_BYTES) throw new Error("Grok history exceeds its storage bound");
const target = path.join(directory, entry.name);
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
await writeFile(target, bytes, { mode: 0o600, flag: "wx" });
}
}
return async (): Promise<Record<string, unknown> | undefined> => {
const root = await realpath(directory);
const entries: Entry[] = [];
let size = 0;
let count = 0;
async function collect(relative = "") {
for (const entry of await readdir(path.join(root, relative), { withFileTypes: true })) {
if (++count > MAX_ENTRIES) throw new Error("Grok history exceeds its entry bound");
const name = relative ? `${relative}/${entry.name}` : entry.name;
if (!validName(name) || entry.isSymbolicLink()) continue;
const source = path.join(root, name);
if (entry.isDirectory()) { await collect(name); continue; }
if (!entry.isFile() || !(await realpath(source)).startsWith(`${root}${path.sep}`)) continue;
const file = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.nlink !== 1) continue;
if (size + stat.size > MAX_BYTES) throw new Error("Grok history exceeds its storage bound");
const bytes = await file.readFile();
size += bytes.length;
if (size > MAX_BYTES) throw new Error("Grok history exceeds its storage bound");
entries.push({ name, bytes: bytes.toString("base64") });
} finally { await file.close(); }
}
}
await collect();
if (entries.length === 0) return;
const prepared = await localEncryptedProvider.createVersion({ value: JSON.stringify({ scope, entries }) });
// The heartbeat writes this with the provider session's checkpoint before
// releasing execution ownership. Cleanup must not race a subsequent resume.
return { [FIELD]: { scope, material: prepared.material } };
};
}
+5 -3
View File
@@ -21764,7 +21764,7 @@ export function heartbeatService(
readFailureReportSecrets = () => collectRunFailureSecretValues(resolvedConfig.env, secretKeys);
if (aiBinding) {
try {
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig });
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig, taskKey });
} catch (error) {
// Only fresh executions can receive a pre-provider wait receipt. A
// persisted native input may already have provider effects to recover.
@@ -26069,12 +26069,14 @@ export function heartbeatService(
agentId: agent.id,
adapterType: agent.adapterType,
taskKey,
sessionParamsJson:
attachPaperclipSessionMetadataToSessionParams(
sessionParamsJson: {
...attachPaperclipSessionMetadataToSessionParams(
nextSessionState.params,
configuredModel,
sessionConfigMetadata,
),
...await managedAiRuntime?.checkpointSessionHistory?.(),
},
sessionDisplayId: nextSessionState.displayId,
lastRunId: finalizedRun.id,
lastError: runErrorMessage,
@@ -65,6 +65,23 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow());
});
it("cancels Claude while waiting for its browser code and terminates the provider", async () => {
const home = await fakeCli("claude", [
'echo $$ > "$CLAUDE_CONFIG_DIR/login.pid"',
'printf "Welcome to Claude Code\\nOpening browser to sign in…\\nBrowser didn\x27t open? Use the url below to sign in (c to copy)\\n"',
'printf "https://claude.com/cai/oauth/authorize?client_id=cid&code=abcdefgh&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=https%%3A%%2F%%2Fplatform.claude.com%%2Foauth%%2Fcode%%2Fcallback&response_type=code&scope=user&state=0123456789abcdef\\n"',
'printf "Paste code here if prompted >\\n"',
'read -r entered',
].join("\n"));
const login = startLocalBrowserLogin("anthropic", home);
await vi.waitFor(() => expect(login.authorizationUrl).toBeDefined());
const pid = Number(await readFile(path.join(home, "login.pid"), "utf8"));
login.abort();
await vi.waitFor(() => expect(login.outcome).toBe("failure"));
await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow());
await expect(readFile(path.join(home, ".credentials.json"))).rejects.toThrow();
});
it("returns a fixed failure without exposing provider error output", async () => {
const home = await fakeCli("codex", 'echo "private-provider-error" >&2\nexit 1');
const login = startLocalBrowserLogin("openai", home);
@@ -80,7 +80,12 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s
void runSetupTokenLogin(driver, {
timeoutMs: 300_000, signal: controller.signal,
onPrompt: (prompt) => { state.authorizationUrl = prompt.url; },
provideCode: () => codeReady,
provideCode: (signal) => new Promise<string>((resolve, reject) => {
const abort = () => reject(new Error("Local sign-in cancelled"));
if (signal.aborted) { abort(); return; }
signal.addEventListener("abort", abort, { once: true });
codeReady.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
}),
onCredential: async (bytes) => {
const file = await open(path.join(home, ".credentials.json"),
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);