diff --git a/packages/adapters/grok-local/src/server/execute.test.ts b/packages/adapters/grok-local/src/server/execute.test.ts index bbdb340d72..bce2f7f8d5 100644 --- a/packages/adapters/grok-local/src/server/execute.test.ts +++ b/packages/adapters/grok-local/src/server/execute.test.ts @@ -4,29 +4,70 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {})); -const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); -const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({ - workspaceRemoteDir: null, - restoreWorkspace: async () => {}, -}))); -const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok")); -const runProcessMock = vi.hoisted(() => vi.fn()); +// Bundles the remote-lane mock state and every mocked execution-target +// function behind one hoisted object, so the `vi.mock` factory below (which +// runs before the top-level `import`s) can close over it. `state.isRemote` +// lets a test force the remote lane on; `state.prepareRuntimeResult` lets a +// test override what `prepareAdapterExecutionTargetRuntime` hands back +// (`workspaceRemoteDir` / `assetDirs`) without a fresh `vi.mock` per case. +const mocks = vi.hoisted(() => { + const state: { + isRemote: boolean; + prepareRuntimeResult: { workspaceRemoteDir?: string | null; assetDirs?: Record } | null; + } = { isRemote: false, prepareRuntimeResult: null }; + return { + state, + ensureRuntimeInstalledMock: vi.fn(async () => {}), + ensureCommandMock: vi.fn(async () => {}), + resolveCommandForLogsMock: vi.fn(async () => "grok"), + runProcessMock: vi.fn(), + prepareRuntimeMock: vi.fn( + async (input: { assets?: Array<{ key: string; localDir: string; followSymlinks?: boolean }> }) => { + const override = state.prepareRuntimeResult; + const assetDirs = + override?.assetDirs ?? + Object.fromEntries( + (input.assets ?? []).map((asset) => [asset.key, `/remote/workspace/.paperclip-runtime/grok/${asset.key}`]), + ); + const workspaceRemoteDir = override && "workspaceRemoteDir" in override + ? override.workspaceRemoteDir + : "/remote/workspace"; + return { workspaceRemoteDir, assetDirs, restoreWorkspace: async () => {} }; + }, + ), + }; +}); + +const { + state: remoteState, + ensureRuntimeInstalledMock, + ensureCommandMock, + resolveCommandForLogsMock, + runProcessMock, + prepareRuntimeMock, +} = mocks; vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ - adapterExecutionTargetIsRemote: () => false, - adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd, + adapterExecutionTargetIsRemote: () => mocks.state.isRemote, + adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => + mocks.state.isRemote ? "/remote/workspace" : cwd, overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target, - adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }), + adapterExecutionTargetSessionIdentity: () => ({ kind: mocks.state.isRemote ? "remote" : "local" }), adapterExecutionTargetSessionMatches: () => true, - describeAdapterExecutionTarget: () => "local", - ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, - ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock, - prepareAdapterExecutionTargetRuntime: prepareRuntimeMock, - readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" }, - resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock, + describeAdapterExecutionTarget: () => (mocks.state.isRemote ? "remote" : "local"), + ensureAdapterExecutionTargetCommandResolvable: (...args: unknown[]) => + (mocks.ensureCommandMock as (...args: unknown[]) => unknown)(...args), + ensureAdapterExecutionTargetRuntimeCommandInstalled: (...args: unknown[]) => + (mocks.ensureRuntimeInstalledMock as (...args: unknown[]) => unknown)(...args), + prepareAdapterExecutionTargetRuntime: (...args: unknown[]) => + (mocks.prepareRuntimeMock as (...args: unknown[]) => unknown)(...args), + readAdapterExecutionTarget: () => + mocks.state.isRemote ? { kind: "remote", transport: "ssh" } : { kind: "local" }, + resolveAdapterExecutionTargetCommandForLogs: (...args: unknown[]) => + (mocks.resolveCommandForLogsMock as (...args: unknown[]) => unknown)(...args), resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec, - runAdapterExecutionTargetProcess: runProcessMock, + runAdapterExecutionTargetProcess: (...args: unknown[]) => + (mocks.runProcessMock as (...args: unknown[]) => unknown)(...args), })); import { execute } from "./execute.js"; @@ -44,8 +85,43 @@ async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } +function makeSuccessfulRunResult(overrides: Partial<{ sessionId: string }> = {}) { + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: JSON.stringify({ + type: "end", + stopReason: "EndTurn", + sessionId: overrides.sessionId ?? "sess-1", + requestId: "req-1", + }), + stderr: "", + }; +} + +async function makeCtx(runId: string, cwd: string): Promise { + return { + runId, + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { cwd }, + context: {}, + authToken: "run-token", + onLog: async () => {}, + }; +} + describe("grok_local execute", () => { beforeEach(() => { + mocks.state.isRemote = false; + mocks.state.prepareRuntimeResult = null; ensureRuntimeInstalledMock.mockClear(); ensureCommandMock.mockClear(); prepareRuntimeMock.mockClear(); @@ -159,22 +235,6 @@ describe("grok_local execute", () => { stderr: "", })); - const makeCtx = async (runId: string): Promise => ({ - runId, - agent: { - id: "agent-1", - companyId: "company-1", - name: "Grok Agent", - adapterType: "grok_local", - adapterConfig: {}, - }, - runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, - config: { cwd: await makeTempRoot() }, - context: {}, - authToken: "run-token", - onLog: async () => {}, - }); - const previousApiKey = process.env.XAI_API_KEY; try { // Subscription billing (no XAI_API_KEY): token usage is populated, but @@ -182,7 +242,7 @@ describe("grok_local execute", () => { // explicitly so the ambient environment (dev machine or CI with provider // secrets) cannot flip this branch to API billing. delete process.env.XAI_API_KEY; - const subscriptionResult = await execute(await makeCtx("run-subscription")); + const subscriptionResult = await execute(await makeCtx("run-subscription", await makeTempRoot())); expect(subscriptionResult).toMatchObject({ usage: { inputTokens: 2384, outputTokens: 261, cachedInputTokens: 23040 }, usageBasis: "per_run", @@ -192,7 +252,7 @@ describe("grok_local execute", () => { // API-key billing: same token usage, plus the real dollar cost. process.env.XAI_API_KEY = "test-key"; - const apiResult = await execute(await makeCtx("run-api")); + const apiResult = await execute(await makeCtx("run-api", await makeTempRoot())); expect(apiResult).toMatchObject({ usage: { inputTokens: 2384, outputTokens: 261, cachedInputTokens: 23040 }, usageBasis: "per_run", @@ -209,42 +269,20 @@ describe("grok_local execute", () => { let seenEnv: Record = {}; runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { seenEnv = options.env; - return { - exitCode: 0, - signal: null, - timedOut: false, - stdout: JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), - stderr: "", - }; - }); - - const makeCtx = async (runId: string): Promise => ({ - runId, - agent: { - id: "agent-1", - companyId: "company-1", - name: "Grok Agent", - adapterType: "grok_local", - adapterConfig: {}, - }, - runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, - config: { cwd: await makeTempRoot() }, - context: {}, - authToken: "run-token", - onLog: async () => {}, + return makeSuccessfulRunResult(); }); const previousApiKey = process.env.XAI_API_KEY; try { delete process.env.XAI_API_KEY; - await execute(await makeCtx("run-subscription-home")); + await execute(await makeCtx("run-subscription-home", await makeTempRoot())); expect(seenEnv.GROK_HOME).toBe(resolveManagedGrokHomeDir(process.env, "company-1")); // The XAI_API_KEY path stays unchanged: no GROK_HOME is set when the key // exists, because the CLI authenticates via the environment variable // directly, not from the company Grok home's auth.json. process.env.XAI_API_KEY = "test-key"; - await execute(await makeCtx("run-api-home")); + await execute(await makeCtx("run-api-home", await makeTempRoot())); expect(seenEnv.GROK_HOME).toBeUndefined(); } finally { if (previousApiKey === undefined) delete process.env.XAI_API_KEY; @@ -256,13 +294,7 @@ describe("grok_local execute", () => { let seenArgs: string[] = []; runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { seenArgs = args; - return { - exitCode: 0, - signal: null, - timedOut: false, - stdout: JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), - stderr: "", - }; + return makeSuccessfulRunResult(); }); const ctx: AdapterExecutionContext = { @@ -334,4 +366,202 @@ describe("grok_local execute", () => { expect(await pathExists(path.join(root, "Agents.md"))).toBe(false); expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false); }); + + describe("remote lane credential staging", () => { + let previousApiKey: string | undefined; + let previousPaperclipHome: string | undefined; + let paperclipHomeRoot: string; + + beforeEach(async () => { + previousApiKey = process.env.XAI_API_KEY; + previousPaperclipHome = process.env.PAPERCLIP_HOME; + // Point the managed Grok home at a private tmp root, so staging never + // touches a real developer or CI-host `~/.paperclip` tree. + paperclipHomeRoot = await makeTempRoot(); + process.env.PAPERCLIP_HOME = paperclipHomeRoot; + }); + + afterEach(() => { + if (previousApiKey === undefined) delete process.env.XAI_API_KEY; + else process.env.XAI_API_KEY = previousApiKey; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + }); + + async function seedHostGrokAuth(contents: string): Promise { + const hostGrokHome = resolveManagedGrokHomeDir(process.env, "company-1"); + await fs.mkdir(hostGrokHome, { recursive: true }); + await fs.writeFile(path.join(hostGrokHome, "auth.json"), contents, "utf8"); + return hostGrokHome; + } + + it("passes one home asset that carries the staged directory in a remote subscription run", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + await seedHostGrokAuth(JSON.stringify({ live: "token" })); + runProcessMock.mockImplementation(async () => makeSuccessfulRunResult()); + + // Read the staged asset while `prepareAdapterExecutionTargetRuntime` still + // holds it — the run's `finally` removes the staged dir once `execute()` + // returns, so any read after that point sees it already gone. + let stagedAuthContents = ""; + let homeAssetShape: { key: string; followSymlinks?: boolean; provision?: unknown; restore?: unknown } | null = null; + let assetCount = -1; + prepareRuntimeMock.mockImplementationOnce( + async (input: { assets?: Array<{ key: string; localDir: string; followSymlinks?: boolean; provision?: unknown; restore?: unknown }> }) => { + const assets = input.assets ?? []; + assetCount = assets.length; + const [homeAsset] = assets; + homeAssetShape = homeAsset + ? { key: homeAsset.key, followSymlinks: homeAsset.followSymlinks, provision: homeAsset.provision, restore: homeAsset.restore } + : null; + if (homeAsset) { + stagedAuthContents = await fs.readFile(path.join(homeAsset.localDir, "auth.json"), "utf8"); + } + return { + workspaceRemoteDir: "/remote/workspace", + assetDirs: { home: "/remote/workspace/.paperclip-runtime/grok/home" }, + restoreWorkspace: async () => {}, + }; + }, + ); + + await execute(await makeCtx("run-remote-subscription-asset", await makeTempRoot())); + + expect(assetCount).toBe(1); + expect(homeAssetShape).toMatchObject({ key: "home", followSymlinks: true }); + expect((homeAssetShape as { provision?: unknown } | null)?.provision).toBeUndefined(); + expect((homeAssetShape as { restore?: unknown } | null)?.restore).toBeUndefined(); + expect(stagedAuthContents).toBe(JSON.stringify({ live: "token" })); + }); + + it("sets GROK_HOME from assetDirs.home in a remote subscription run", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + await seedHostGrokAuth("{}"); + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return makeSuccessfulRunResult(); + }); + + await execute(await makeCtx("run-remote-subscription-home", await makeTempRoot())); + + expect(seenEnv.GROK_HOME).toBe("/remote/workspace/.paperclip-runtime/grok/home"); + }); + + it("uses the fallback remote path when assetDirs.home is absent", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + mocks.state.prepareRuntimeResult = { workspaceRemoteDir: "/remote/fallback-workspace", assetDirs: {} }; + await seedHostGrokAuth("{}"); + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return makeSuccessfulRunResult(); + }); + + await execute(await makeCtx("run-remote-subscription-fallback", await makeTempRoot())); + + expect(seenEnv.GROK_HOME).toBe( + "/remote/fallback-workspace/.paperclip-runtime/grok/home", + ); + }); + + it("passes no home asset and sets no GROK_HOME in a remote API-key run", async () => { + process.env.XAI_API_KEY = "test-key"; + mocks.state.isRemote = true; + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return makeSuccessfulRunResult(); + }); + + await execute(await makeCtx("run-remote-api-key", await makeTempRoot())); + + expect(prepareRuntimeMock).toHaveBeenCalledTimes(1); + const { assets } = prepareRuntimeMock.mock.calls[0][0] as { assets?: unknown[] }; + expect(assets).toBeUndefined(); + expect(seenEnv.GROK_HOME).toBeUndefined(); + }); + + it("passes no home asset in a local run", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = false; + runProcessMock.mockImplementation(async () => makeSuccessfulRunResult()); + + await execute(await makeCtx("run-local-no-asset", await makeTempRoot())); + + expect(prepareRuntimeMock).not.toHaveBeenCalled(); + }); + + it("removes the staged home after a successful remote subscription run", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + await seedHostGrokAuth("{}"); + let stagedDir = ""; + runProcessMock.mockImplementation(async () => makeSuccessfulRunResult()); + prepareRuntimeMock.mockImplementationOnce(async (input: { assets?: Array<{ localDir: string }> }) => { + stagedDir = input.assets?.[0]?.localDir ?? ""; + return { + workspaceRemoteDir: "/remote/workspace", + assetDirs: { home: "/remote/workspace/.paperclip-runtime/grok/home" }, + restoreWorkspace: async () => {}, + }; + }); + + await execute(await makeCtx("run-remote-cleanup-success", await makeTempRoot())); + + expect(stagedDir).not.toBe(""); + expect(await pathExists(stagedDir)).toBe(false); + }); + + it("removes the staged home after a setup failure in a remote subscription run", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + await seedHostGrokAuth("{}"); + let stagedDir = ""; + prepareRuntimeMock.mockImplementationOnce(async (input: { assets?: Array<{ localDir: string }> }) => { + stagedDir = input.assets?.[0]?.localDir ?? ""; + return { + workspaceRemoteDir: "/remote/workspace", + assetDirs: { home: "/remote/workspace/.paperclip-runtime/grok/home" }, + restoreWorkspace: async () => {}, + }; + }); + ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed remotely")); + + await expect(execute(await makeCtx("run-remote-cleanup-fail", await makeTempRoot()))).rejects.toThrow( + "grok not installed remotely", + ); + + expect(stagedDir).not.toBe(""); + expect(await pathExists(stagedDir)).toBe(false); + }); + + it("removes the staged home when the workspace restore rejects during teardown", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + await seedHostGrokAuth("{}"); + let stagedDir = ""; + runProcessMock.mockImplementation(async () => makeSuccessfulRunResult()); + prepareRuntimeMock.mockImplementationOnce(async (input: { assets?: Array<{ localDir: string }> }) => { + stagedDir = input.assets?.[0]?.localDir ?? ""; + return { + workspaceRemoteDir: "/remote/workspace", + assetDirs: { home: "/remote/workspace/.paperclip-runtime/grok/home" }, + restoreWorkspace: async () => { + throw new Error("restore failed"); + }, + }; + }); + + await expect(execute(await makeCtx("run-remote-teardown-restore-reject", await makeTempRoot()))).rejects.toThrow( + "restore failed", + ); + + expect(stagedDir).not.toBe(""); + expect(await pathExists(stagedDir)).toBe(false); + }); + }); }); diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index 6db577db0d..063d4b373f 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -41,7 +41,7 @@ import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; -import { resolveManagedGrokHomeDir } from "./grok-home.js"; +import { resolveManagedGrokHomeDir, stageGrokHomeForSync } from "./grok-home.js"; import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -245,6 +245,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise Promise) | null = null; + // Declared here (not inside the try) so the outer `finally` can remove it on + // every exit path (teardown and setup failure alike), mirroring the Codex + // adapter's `stagedCodexHomeDir` handling. + let stagedGrokHomeDir: string | null = null; try { const envConfig = parseObject(config.env); @@ -302,11 +306,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise, "XAI_API_KEY")) { - env.GROK_HOME = resolveManagedGrokHomeDir(process.env, agent.companyId); + const isGrokSubscriptionMode = + !hasNonEmptyEnvValue(env, "XAI_API_KEY") && !hasNonEmptyEnvValue(process.env as Record, "XAI_API_KEY"); + if (isGrokSubscriptionMode) { + env.GROK_HOME = hostGrokHome; } const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( @@ -331,6 +340,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise onLog("stdout", line), onRuntimeProgress: ctx.onRuntimeProgress, + assets: stagedGrokHomeDir + ? [{ key: "home", localDir: stagedGrokHomeDir, followSymlinks: true }] + : undefined, }); restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace((line) => onLog("stdout", line)); @@ -358,6 +377,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + await onLog( + "stderr", + `[paperclip] Failed to remove staged Grok home "${stagedGrokHomeDir}": ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + }); + } await Promise.all([ restoreRemoteWorkspace?.(), stagedAssets.cleanup(), diff --git a/packages/adapters/grok-local/src/server/grok-home.test.ts b/packages/adapters/grok-local/src/server/grok-home.test.ts new file mode 100644 index 0000000000..77524dde08 --- /dev/null +++ b/packages/adapters/grok-local/src/server/grok-home.test.ts @@ -0,0 +1,137 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GROK_SYNC_ALLOWLIST, stageGrokHomeForSync } from "./grok-home.js"; + +describe("GROK_SYNC_ALLOWLIST", () => { + it("holds exactly one name: auth.json", () => { + expect(GROK_SYNC_ALLOWLIST).toEqual(["auth.json"]); + }); +}); + +describe("stageGrokHomeForSync", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.restoreAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + async function makeRoot(prefix: string): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + cleanupDirs.push(root); + return root; + } + + it("stages auth.json and stages no other file", async () => { + const root = await makeRoot("paperclip-grok-stage-"); + const home = path.join(root, "grok-home"); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(path.join(home, "auth.json"), JSON.stringify({ ok: true }), "utf8"); + // A decoy runtime file the allowlist must not pick up. + await fs.writeFile(path.join(home, "sessions.sqlite"), "decoy", "utf8"); + + const staged = await stageGrokHomeForSync(home, { runId: "run-1" }); + cleanupDirs.push(staged); + + expect(await fs.readdir(staged)).toEqual(["auth.json"]); + expect(await fs.readFile(path.join(staged, "auth.json"), "utf8")).toBe( + JSON.stringify({ ok: true }), + ); + }); + + it("creates the staged directory with mode 0700", async () => { + const root = await makeRoot("paperclip-grok-stage-dir-"); + const home = path.join(root, "grok-home"); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(path.join(home, "auth.json"), "{}", "utf8"); + + const staged = await stageGrokHomeForSync(home, { runId: "run-dir" }); + cleanupDirs.push(staged); + + const mode = (await fs.stat(staged)).mode & 0o777; + expect(mode).toBe(0o700); + }); + + it("writes the staged auth.json with mode 0600", async () => { + const root = await makeRoot("paperclip-grok-stage-mode-"); + const home = path.join(root, "grok-home"); + await fs.mkdir(home, { recursive: true }); + // Source file at a world-readable mode; the staged copy must still land 0600. + await fs.writeFile(path.join(home, "auth.json"), "{}", { mode: 0o644 }); + + const staged = await stageGrokHomeForSync(home, { runId: "run-mode" }); + cleanupDirs.push(staged); + + const mode = (await fs.stat(path.join(staged, "auth.json"))).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("dereferences an auth.json symlink to bytes", async () => { + const root = await makeRoot("paperclip-grok-stage-symlink-"); + const home = path.join(root, "grok-home"); + await fs.mkdir(home, { recursive: true }); + const authSource = path.join(root, "shared-auth.json"); + await fs.writeFile(authSource, JSON.stringify({ live: "token" }), "utf8"); + await fs.symlink(authSource, path.join(home, "auth.json")); + + const staged = await stageGrokHomeForSync(home, { runId: "run-symlink" }); + cleanupDirs.push(staged); + + const stagedAuthPath = path.join(staged, "auth.json"); + expect(await fs.readFile(stagedAuthPath, "utf8")).toBe(JSON.stringify({ live: "token" })); + // The staged copy is a real file, not a symlink into the shared source. + expect((await fs.lstat(stagedAuthPath)).isSymbolicLink()).toBe(false); + }); + + it("treats an absent auth.json as absent", async () => { + const root = await makeRoot("paperclip-grok-stage-absent-"); + const home = path.join(root, "grok-home"); + await fs.mkdir(home, { recursive: true }); + + const staged = await stageGrokHomeForSync(home, { runId: "run-absent" }); + cleanupDirs.push(staged); + + expect(await fs.readdir(staged)).toEqual([]); + }); + + it("treats a dangling auth.json symlink as absent", async () => { + const root = await makeRoot("paperclip-grok-stage-dangling-"); + const home = path.join(root, "grok-home"); + await fs.mkdir(home, { recursive: true }); + await fs.symlink(path.join(root, "gone", "auth.json"), path.join(home, "auth.json")); + + const staged = await stageGrokHomeForSync(home, { runId: "run-dangling" }); + cleanupDirs.push(staged); + + expect(await fs.readdir(staged)).toEqual([]); + }); + + it("removes the staged directory on an unexpected error", async () => { + const root = await makeRoot("paperclip-grok-stage-fail-"); + const home = path.join(root, "grok-home"); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(path.join(home, "auth.json"), "{}", "utf8"); + + let createdDir: string | null = null; + const realMkdtemp = fs.mkdtemp.bind(fs); + vi.spyOn(fs, "mkdtemp").mockImplementation(async (prefix: string, ...rest: unknown[]) => { + const dir = await (realMkdtemp as typeof fs.mkdtemp)(prefix, ...(rest as [])); + createdDir = dir as string; + return dir; + }); + vi.spyOn(fs, "readFile").mockRejectedValue( + Object.assign(new Error("boom"), { code: "EACCES" }), + ); + + await expect(stageGrokHomeForSync(home, { runId: "run-fail" })).rejects.toThrow("boom"); + expect(createdDir).not.toBeNull(); + await expect(fs.access(createdDir as unknown as string)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/adapters/grok-local/src/server/grok-home.ts b/packages/adapters/grok-local/src/server/grok-home.ts index 1a98399294..8a31e28f23 100644 --- a/packages/adapters/grok-local/src/server/grok-home.ts +++ b/packages/adapters/grok-local/src/server/grok-home.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; @@ -6,11 +7,21 @@ import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-uti // `auth.json`. Unlike Codex, a Grok `auth.json` has no fixed top-level key: it // holds exactly one key, and that key is a composite `::` value. // This module resolves the company-scoped home path and reads its usable-auth -// shape. It never writes the file; {@link promoteGrokDeviceLoginCredential} in -// `adapter-auth-promotion.ts` owns the write. +// shape. It never writes to the managed home itself; {@link +// promoteGrokDeviceLoginCredential} in `adapter-auth-promotion.ts` owns that +// write. {@link stageGrokHomeForSync} writes only to a fresh, private staging +// directory it creates for a sandbox run. const AUTH_FILE_NAME = "auth.json"; +/** + * The allowlist of managed `GROK_HOME` entries that the grok-local adapter + * stages into the sandbox `home` asset (see {@link stageGrokHomeForSync}). + * Paperclip writes instructions and skills under the workspace, not under the + * Grok home, so the credential file is the only entry a sandbox run needs. + */ +export const GROK_SYNC_ALLOWLIST = ["auth.json"] as const; + // Matches the composite `::` top-level key. The issuer is a // non-empty string — an OIDC issuer URL such as `https://issuer.x.ai` holds // colons of its own — so this anchors on the LAST `::` before a standard @@ -93,3 +104,65 @@ export function resolveManagedGrokHomeDir( ? path.resolve(instanceRoot, "companies", companyId, "grok-home") : path.resolve(instanceRoot, "grok-home"); } + +export interface StageGrokHomeForSyncOptions { + /** Run id, used only to make the staged temp-dir name traceable in logs. */ + runId?: string; +} + +/** + * Reads `candidate` and dereferences a symlink to its target bytes. Returns + * null for a missing file or a dangling symlink (both resolve to `ENOENT`); + * any other read error propagates to the caller. + */ +async function readFileBytesIfPresent(candidate: string): Promise { + try { + return await fs.readFile(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +/** + * Stages exactly {@link GROK_SYNC_ALLOWLIST} from `effectiveGrokHome` into a + * fresh private temp dir and returns its path, for registration as the sandbox + * `home` asset. Mirrors `stageCodexHomeForSync` in the codex-local adapter. + * + * - **The symlink is dereferenced to bytes** — the single-use `auth.json` + * credential (a symlink into the shared source home) lands as a real file, + * never a dangling link. + * - **A missing `auth.json` is not an error** — a company with no completed + * device login yet stages an empty directory. + * - **`mkdtemp` guarantees the staged dir is `0700`** on POSIX, and the staged + * `auth.json` is written `0600` (least privilege). + * - **Fail-closed** — any *unexpected* I/O error removes the partial temp dir + * and re-throws, so a run never proceeds with a partial staged home. + * + * The caller owns removing the returned dir on run teardown. + */ +export async function stageGrokHomeForSync( + effectiveGrokHome: string, + options: StageGrokHomeForSyncOptions = {}, +): Promise { + const runIdPart = nonEmpty(options.runId ?? undefined); + const stagedHome = await fs.mkdtemp( + path.join(os.tmpdir(), `paperclip-grok-home-sync-${runIdPart ? `${runIdPart}-` : ""}`), + ); + try { + for (const entry of GROK_SYNC_ALLOWLIST) { + const bytes = await readFileBytesIfPresent(path.join(effectiveGrokHome, entry)); + if (bytes === null) continue; + const target = path.join(stagedHome, entry); + await fs.writeFile(target, bytes, { mode: 0o600 }); + // Explicit chmod so the mode is 0600 regardless of the process umask. + await fs.chmod(target, 0o600); + } + return stagedHome; + } catch (error) { + // Fail-closed: never hand back a partial staged home. Remove the temp dir + // we created before propagating the failure. + await fs.rm(stagedHome, { recursive: true, force: true }).catch(() => {}); + throw error; + } +}