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:
parent
0cc40037ac
commit
ccf3355b2e
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue