feat(grok-local): stage a curated Grok home into remote subscription runs (#12618)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Local adapters run agents in local or remote sandboxes.
> - A remote Grok subscription run needs its credential file inside the
sandbox.
> - The adapter did not stage the Grok home, so the sandbox had no
credential file.
> - This pull request stages only the allowed Grok credential file and
sets the reported home path.
> - The adapter removes the temporary staged home before teardown
completes.
> - The benefit is reliable Grok subscription authentication with
limited credential exposure.

## Linked Issues or Issue Description

**What happened?**

A remote Grok subscription run had no credential file in its sandbox.
The adapter sent no Grok home asset.

**Expected behavior**

The adapter should stage the allowed Grok credential file and set
`GROK_HOME` to the reported sandbox path.

**Steps to reproduce**

1. Start a remote Grok run in subscription mode.
2. Inspect the sandbox environment and home asset.
3. Confirm that the run has `GROK_HOME` and `auth.json`.

**Paperclip version or commit**

`3df33b5b8f49063a5d1ab608f8ce372572ef09d1`

**Deployment mode**

Remote sandbox run.

## What Changed

- Stage a private temporary Grok home for remote subscription runs.
- Copy only the allowed `auth.json` file and set its mode to `0600`.
- Pass the staged directory as the remote `home` asset.
- Set `GROK_HOME` to the path that the remote runtime reports.
- Remove the staged directory before awaited teardown calls.
- Keep the API-key lane free of credential staging.
- Add tests for the allowlist, file mode, empty source home, run lanes,
and teardown cleanup.

## Verification

- `pnpm vitest run packages/adapters/grok-local` passes.
- `pnpm --filter @paperclipai/adapter-grok-local typecheck` passes.
- `grok-home.test.ts` covers the allowlist, mode `0600`, and empty
source home.
- `execute.test.ts` covers the remote subscription lane, the API-key
lane, and cleanup after restore failure.

## Risks

- The change affects only remote Grok subscription runs that use a
credential file.
- The allowlist limits the staged content to `auth.json`.
- The API-key lane does not stage a home or set `GROK_HOME`.
- CI must confirm adapter behavior across the supported runtime matrix.

## Model Used

- Codex, GPT-5, current 2026 model version, large context window,
reasoning mode, and tool use assisted the repository handoff and pull
request management. The implementation author supplied the code and
local verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-09-01 10:28:05 -07:00 committed by GitHub
parent 0cc40037ac
commit ccf3355b2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 557 additions and 73 deletions

View File

@ -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<string, string> } | 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<boolean> {
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<AdapterExecutionContext> {
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<AdapterExecutionContext> => ({
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<string, string> = {};
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<AdapterExecutionContext> => ({
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<string> {
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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);
});
});
});

View File

@ -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<AdapterExec
onLog,
});
let restoreRemoteWorkspace: (() => Promise<void>) | 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<AdapterExec
if (authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
// Held before the remote block below, so the remote lane can stage this
// same host home into the sandbox without re-resolving it.
const hostGrokHome = resolveManagedGrokHomeDir(process.env, agent.companyId);
// Subscription mode (no XAI_API_KEY): point the run at the company-scoped
// Grok home a completed device login wrote. Leaves the API-key path below
// (`resolveBillingType`) unchanged when the key exists.
if (!hasNonEmptyEnvValue(env, "XAI_API_KEY") && !hasNonEmptyEnvValue(process.env as Record<string, string>, "XAI_API_KEY")) {
env.GROK_HOME = resolveManagedGrokHomeDir(process.env, agent.companyId);
const isGrokSubscriptionMode =
!hasNonEmptyEnvValue(env, "XAI_API_KEY") && !hasNonEmptyEnvValue(process.env as Record<string, string>, "XAI_API_KEY");
if (isGrokSubscriptionMode) {
env.GROK_HOME = hostGrokHome;
}
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
@ -331,6 +340,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
"stdout",
`[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
// Stage only the credential file the remote lane needs into a curated
// temp dir and ship THAT as the `home` asset, the same curated-snapshot
// approach the Codex adapter uses. Subscription mode only: an API-key
// run authenticates from the environment variable and needs no home.
if (isGrokSubscriptionMode) {
stagedGrokHomeDir = await stageGrokHomeForSync(hostGrokHome, { runId });
}
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
@ -341,6 +357,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command,
onProgress: (line) => 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<AdapterExec
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
// Set GROK_HOME after the refresh above, so the refresh cannot overwrite
// it. The fixed fallback path mirrors `prepareAdapterExecutionTargetRuntime`'s
// own `home` asset layout, in case `assetDirs.home` is absent.
if (isGrokSubscriptionMode) {
env.GROK_HOME =
preparedExecutionTargetRuntime.assetDirs.home ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "grok", "home");
}
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
@ -601,6 +628,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
// Remove the staged GROK_HOME allowlist temp dir first, before the
// `Promise.all` below. A rejecting member of that `Promise.all` (for
// example a failed workspace restore) throws out of this `finally` and
// skips every statement after it, so the removal must run before that
// await to hold on every exit path (teardown AND error), never only the
// happy path. Cleanup failure is logged, not fatal — a leaked temp dir
// must not crash the run.
if (stagedGrokHomeDir) {
await fs.rm(stagedGrokHomeDir, { recursive: true, force: true }).catch(async (error) => {
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(),

View File

@ -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<string> {
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" });
});
});

View File

@ -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 `<issuer>::<uuid>` 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 `<issuer>::<uuid>` 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<Buffer | null> {
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<string> {
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;
}
}