mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
feat(runner): add Grok Build native ACP transport and qualification coverage
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f447990d77
commit
78eb3f19f3
65 files changed
+1017
-111
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import { mkdtemp, mkdir, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js";
|
||||
|
||||
const { companyHome } = vi.hoisted(() => ({ companyHome: vi.fn() }));
|
||||
vi.mock("@paperclipai/adapter-grok-local/server", () => ({
|
||||
resolveManagedGrokHomeDir: companyHome,
|
||||
grokHomeHasUsableAuth: async () => false,
|
||||
}));
|
||||
const roots: string[] = [];
|
||||
async function home() { const root = await realpath(await mkdtemp(join(tmpdir(), "grok-controller-test-"))); roots.push(root); return root; }
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); vi.clearAllMocks(); });
|
||||
|
||||
describe("Grok runner company credentials", () => {
|
||||
it("requires the company login for remote runs and never borrows a configured host home", async () => {
|
||||
const root = await home();
|
||||
companyHome.mockReturnValue(join(root, "company-without-login"));
|
||||
const host = join(root, "host"); await mkdir(host, { mode: 0o700 });
|
||||
await writeFile(join(host, "auth.json"), "host-secret", { mode: 0o600 });
|
||||
await expect(prepareGrokRunnerCredentials({ companyId: "test-company", remote: true, environment: { GROK_HOME: host } })).rejects.toThrow("subscription login is unavailable");
|
||||
});
|
||||
it("uses only an explicitly selected API key and strips subscription payloads and homes", async () => {
|
||||
expect(await prepareGrokRunnerCredentials({ companyId: "test-company", remote: true, environment: { XAI_API_KEY: "selected", GROK_HOME: "/host", PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: "stale" } })).toEqual({ environment: { XAI_API_KEY: "selected" }, home: null });
|
||||
});
|
||||
it("reads the controller-selected managed connection privately", async () => {
|
||||
const root = await home(); companyHome.mockReturnValue(join(root, "unused"));
|
||||
await writeFile(join(root, "auth.json"), '{"private":"fixture"}', { mode: 0o600 });
|
||||
const value = await prepareGrokRunnerCredentials({ companyId: "test-company", remote: true, managedHome: root, environment: { GROK_HOME: "/untrusted", XAI_API_KEY: "" } });
|
||||
expect(value).toEqual({ home: root, environment: { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: '{"private":"fixture"}' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { grokHomeHasUsableAuth, resolveManagedGrokHomeDir } from "@paperclipai/adapter-grok-local/server";
|
||||
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
|
||||
|
||||
/** Resolve credentials on the controller. Remote runtimes receive only the
|
||||
* selected credential, never an ambient host home or an implicit API key.
|
||||
*/
|
||||
export async function prepareGrokRunnerCredentials(input: {
|
||||
companyId: string;
|
||||
environment: NodeJS.ProcessEnv;
|
||||
remote: boolean;
|
||||
managedHome?: string;
|
||||
}): Promise<{ environment: NodeJS.ProcessEnv; home: string | null }> {
|
||||
const environment = { ...input.environment };
|
||||
delete environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET;
|
||||
const configuredHome = input.managedHome?.trim();
|
||||
delete environment.GROK_HOME;
|
||||
if (environment.XAI_API_KEY?.trim()) return { environment, home: null };
|
||||
delete environment.XAI_API_KEY;
|
||||
const companyHome = resolveManagedGrokHomeDir(input.environment, input.companyId);
|
||||
const home = configuredHome ?? (await grokHomeHasUsableAuth(companyHome)
|
||||
? companyHome : input.remote ? companyHome : join(homedir(), ".grok"));
|
||||
try {
|
||||
environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET = await readLocalAiCredentialFile(join(home, "auth.json"));
|
||||
} catch {
|
||||
throw new Error("Grok subscription login is unavailable. Connect Grok Build or select an xAI API key.");
|
||||
}
|
||||
return { environment, home };
|
||||
}
|
||||
@@ -970,6 +970,9 @@ describe("remote provider pack manifest", () => {
|
||||
const lockfile = "lockfileVersion: '9.0'\n";
|
||||
const opencodeCommand = "#!/bin/sh\n";
|
||||
const opencodeExecutable = "opencode-binary\n";
|
||||
const grokExecutable = "grok-binary\n";
|
||||
await mkdir(join(root, "node_modules/@paperclipai/grok-acp/bin"), { recursive: true });
|
||||
await writeFile(join(root, "node_modules/@paperclipai/grok-acp/bin/grok"), grokExecutable);
|
||||
await writeFile(
|
||||
join(root, "dist", "cli", "opencode-app-server-proxy.cjs"),
|
||||
proxy,
|
||||
@@ -1000,18 +1003,21 @@ describe("remote provider pack manifest", () => {
|
||||
acpx: "0.13.1",
|
||||
claudeAcp: "0.73.0",
|
||||
codexAcp: "1.6.2",
|
||||
grok: "1.0.13",
|
||||
},
|
||||
target: { platform: "linux", architecture: "x64" },
|
||||
runnerSourceRevision: "1".repeat(40),
|
||||
distDigest: sha256DirectoryTree(join(root, "dist")),
|
||||
bridgeDigest: "",
|
||||
acpxProfileDigests: {
|
||||
grok: "sha256:42fe296ec6fc0715c3509cec9671451bcd5bfdc7f185041101c8aac1e9ac8718",
|
||||
claude:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
codex:
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
},
|
||||
artifacts: {
|
||||
grokExecutable: { path: "node_modules/@paperclipai/grok-acp/bin/grok", sha256: digest(grokExecutable) },
|
||||
nodeCommand: {
|
||||
path: "node_modules/node/bin/node",
|
||||
sha256: digest(node),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
|
||||
import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js";
|
||||
import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server";
|
||||
import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js";
|
||||
import { resolveGitHubOperationCredentials } from "../github-operation-credentials.js";
|
||||
import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } from "./managed-native-credentials.js";
|
||||
@@ -5250,7 +5253,10 @@ export function resolveNativeHarnessPersistenceProfile(
|
||||
// nor necessary to restore. Credentials and launch-time config
|
||||
// are also re-materialized in the replacement sandbox.
|
||||
excludeEntries:
|
||||
execution.provider.agent === "codex"
|
||||
execution.provider.agent === "grok"
|
||||
? ["auth.json", "auth-refresh.json", "auth-refresh.json.tmp", "config.toml"].map((entry) =>
|
||||
`acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/grok-home/${entry}`)
|
||||
: execution.provider.agent === "codex"
|
||||
? CODEX_HOME_NON_PERSISTENT_ENTRIES.map(
|
||||
(entry) =>
|
||||
`acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/codex-home/${entry}`,
|
||||
@@ -9132,14 +9138,17 @@ const REMOTE_PROVIDER_PACK_PINS = {
|
||||
acpx: "0.13.1",
|
||||
claudeAcp: "0.73.0",
|
||||
codexAcp: "1.6.2",
|
||||
grok: "1.0.13",
|
||||
} as const;
|
||||
const REMOTE_PROVIDER_PACK_PROFILE_DIGESTS = {
|
||||
grok: "sha256:42fe296ec6fc0715c3509cec9671451bcd5bfdc7f185041101c8aac1e9ac8718",
|
||||
claude:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
codex:
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
} as const;
|
||||
const REMOTE_PROVIDER_PACK_ARTIFACT_PATHS = {
|
||||
grokExecutable: "node_modules/@paperclipai/grok-acp/bin/grok",
|
||||
nodeCommand: "node_modules/node/bin/node",
|
||||
productionLock: "pnpm-lock.yaml",
|
||||
opencodeCommand: "node_modules/.bin/opencode",
|
||||
@@ -9159,6 +9168,7 @@ type RemoteProviderPackManifest = {
|
||||
bridgeDigest: string;
|
||||
acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS;
|
||||
artifacts: {
|
||||
grokExecutable: { path: string; sha256: string };
|
||||
nodeCommand: { path: string; sha256: string };
|
||||
productionLock: { path: string; sha256: string };
|
||||
opencodeCommand: { path: string; sha256: string };
|
||||
@@ -9257,6 +9267,7 @@ export function readRemoteProviderPackManifest(
|
||||
);
|
||||
}
|
||||
const artifactEntries = [
|
||||
["Grok executable", payload.artifacts?.grokExecutable, REMOTE_PROVIDER_PACK_ARTIFACT_PATHS.grokExecutable],
|
||||
[
|
||||
"provider Node",
|
||||
payload.artifacts?.nodeCommand,
|
||||
@@ -10680,14 +10691,14 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
"if(canonical(manifest)!==expected)throw new Error('manifest mismatch')",
|
||||
"const hash=(p)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex')",
|
||||
"const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}",
|
||||
"for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
|
||||
"for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokExecutable']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
|
||||
"if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')",
|
||||
"const version=process.versions.node.split('.').map(Number)",
|
||||
"const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)",
|
||||
"if(version[0]<minimum[0]||(version[0]===minimum[0]&&(version[1]<minimum[1]||(version[1]===minimum[1]&&version[2]<minimum[2]))))throw new Error('Node version incompatible')",
|
||||
"if(process.platform!==manifest.payload.target.platform||process.arch!==manifest.payload.target.architecture)throw new Error('provider pack target mismatch')",
|
||||
"const packageVersion=(pkg)=>JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
|
||||
"const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
|
||||
"const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode,'@paperclipai/grok-acp':manifest.payload.pins.grok}",
|
||||
"for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
|
||||
].join(";");
|
||||
const verified = await remoteCommandRunner.execute({
|
||||
@@ -11975,9 +11986,15 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
},
|
||||
}
|
||||
: input.execution;
|
||||
const effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = {
|
||||
...(input.runnerEnvironment ?? process.env),
|
||||
const isGrok = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "grok";
|
||||
let effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = {
|
||||
...(input.runnerEnvironment ?? (isGrok ? {} : process.env)),
|
||||
};
|
||||
const grokCredential = isGrok ? await prepareGrokRunnerCredentials({
|
||||
companyId: input.execution.binding.companyId, environment: effectiveRunnerEnvironmentBase, remote: Boolean(remoteTarget),
|
||||
managedHome: input.managedAiCredentialHome,
|
||||
}) : null;
|
||||
if (grokCredential) effectiveRunnerEnvironmentBase = grokCredential.environment;
|
||||
// This authority bit is derived only from the selected execution target.
|
||||
// Never let an agent, environment binding, or host variable disable the
|
||||
// Codex sandbox for a local runner by supplying the same key.
|
||||
@@ -12547,6 +12564,38 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
});
|
||||
const boundManagedSessions = new WeakSet<NativeSession>();
|
||||
const wrapManagedSession = (session: NativeSession): NativeSession => {
|
||||
if (isGrok && grokCredential?.home && !boundManagedSessions.has(session)) {
|
||||
boundManagedSessions.add(session);
|
||||
const relativeHome = `acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(input.execution))}/grok-home`;
|
||||
const localHome = join(root, "acpx", relativeHome);
|
||||
const remoteHome = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "acpx", relativeHome) : null;
|
||||
const readAuth = async (name: string): Promise<Buffer> => {
|
||||
if (!remoteHome || !remoteCommandRunner) return Buffer.from(await readLocalAiCredentialFile(join(localHome, name)));
|
||||
const script = `const fs=require('node:fs'),path=require('node:path');let fd;try{const file=process.argv[1];let parent=path.dirname(file);while(true){if(!fs.lstatSync(parent).isDirectory())throw Error('directory');const next=path.dirname(parent);if(next===parent)break;parent=next;}fd=fs.openSync(file,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);const st=fs.fstatSync(fd);if(!st.isFile()||st.uid!==process.getuid()||(st.mode&511)!==384||st.size>65536)throw Error('credential');const b=Buffer.alloc(65537);let n=0;while(n<b.length){const k=fs.readSync(fd,b,n,b.length-n,n);if(!k)break;n+=k;}if(n>65536)throw Error('size');process.stdout.write(b.subarray(0,n).toString('base64'));b.fill(0);}catch(e){process.exitCode=e.code==='ENOENT'?66:1;}finally{if(fd!==undefined)fs.closeSync(fd);}`;
|
||||
const result = await remoteCommandRunner.execute({ command: "node", args: ["-e", script, posix.join(remoteHome, name)], bypassSession: true, timeoutMs: 10000 });
|
||||
if (result.exitCode !== 0 || result.timedOut) throw Object.assign(new Error("Grok credential refresh handoff unavailable"), { code: result.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" });
|
||||
return Buffer.from(result.stdout, "base64");
|
||||
};
|
||||
return bindManagedNativeCredentialTurn(session, {
|
||||
copyBack: async () => {
|
||||
await copyBackGrokAuth({ hostHomeDir: grokCredential.home!, log: () => {},
|
||||
readSandboxAuth: () => readAuth("auth.json").catch((error) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return readAuth("auth-refresh.json");
|
||||
}),
|
||||
});
|
||||
},
|
||||
remove: async () => {
|
||||
for (const name of ["auth.json", "auth-refresh.json", "auth-refresh.json.tmp"]) {
|
||||
rmSync(join(localHome, name), { force: true });
|
||||
if (remoteHome && remoteCommandRunner) {
|
||||
const result = await remoteCommandRunner.execute({ command: "rm", args: ["-f", "--", posix.join(remoteHome, name)], bypassSession: true, timeoutMs: 10000 });
|
||||
if (result.exitCode !== 0 || result.timedOut) throw new Error("Grok credential cleanup failed");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!input.managedAiCredentialHome || input.execution.provider.kind !== "codex" || boundManagedSessions.has(session)) return session;
|
||||
boundManagedSessions.add(session);
|
||||
const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", "auth.json") : null;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const DEFAULT_OPENCODE_RUNNER_MODEL =
|
||||
export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
|
||||
|
||||
export const QUALIFIED_ACPX_RUNNER_MODELS = {
|
||||
grok: "grok-4.7",
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-sol",
|
||||
} as const;
|
||||
@@ -404,7 +405,7 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
}
|
||||
|
||||
const acpxAgent = config.acpxAgent ?? "claude";
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex") {
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok") {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_acpx_agent_unavailable",
|
||||
"Paperclip Runner ACPX requires the qualified Claude or Codex agent profile; Pi is not available.",
|
||||
|
||||
Reference in new issue
Block a user