From 5b5fb12559f126155f355f312da8770f41a481ff Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 5 Jun 2026 16:36:16 -0400 Subject: [PATCH 1/2] feat: add grok harness support --- packages/cli/README.md | 45 +++--- packages/cli/src/cli.test.ts | 23 +++ packages/cli/src/cli.ts | 79 +++++---- packages/cli/src/launch-metadata.test.ts | 1 + packages/cli/src/launch-metadata.ts | 4 +- .../persona-kit/schemas/persona.schema.json | 11 +- packages/persona-kit/src/constants.ts | 5 +- packages/persona-kit/src/index.test.ts | 17 ++ .../persona-kit/src/interactive-spec.test.ts | 151 +++++++++++++++++- packages/persona-kit/src/interactive-spec.ts | 111 +++++++++++-- packages/persona-kit/src/plan.test.ts | 28 +++- packages/persona-kit/src/plan.ts | 6 +- packages/persona-kit/src/skills.ts | 3 +- packages/persona-kit/src/types.ts | 9 +- 14 files changed, 404 insertions(+), 89 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 9e4579b4..ac926118 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,7 +1,7 @@ # agentworkforce CLI A thin command-line front end for the workload-router. Spawns the harness CLI -(`claude`, `codex`, `opencode`) configured by a selected **persona** from the +(`claude`, `codex`, `opencode`, `grok`) configured by a selected **persona** from the project-local layer, configured source directories, or the small internal built-in system catalog. @@ -37,7 +37,7 @@ agentworkforce --version - `integrations` — discover available integrations, known trigger events, and connection status for the active workspace. - `sources` — list, add, or remove persona source directories. -- `harness check` — probe which harnesses (`claude`, `codex`, `opencode`) +- `harness check` — probe which harnesses (`claude`, `codex`, `opencode`, `grok`) are installed. See [`## Harness check`](#harness-check) below. - `destroy` — tear down a deployed cloud agent: cancels all relaycron schedules and marks the agent as destroyed. Accepts either a persona @@ -425,7 +425,7 @@ agentworkforce harness check ``` Probes your PATH for each supported harness binary (`claude`, `codex`, -`opencode`) and prints a table with status (`ok` / `missing`), resolved +`opencode`, `grok`) and prints a table with status (`ok` / `missing`), resolved version, and the resolved path (or the error, for missing ones). Exit code is always `0` — this command is diagnostic, not a gate. @@ -825,10 +825,11 @@ mount rules (`.agentignore` / `.agentreadonly`) for that. - **Tool patterns** are passed through verbatim; use the harness's native grammar. For Claude Code: `Bash()`, `mcp__` (all tools from that server), `mcp____` (specific tool). -- **Harness support today:** only `claude` is wired for `permissions` (flags: - `--allowedTools`, `--disallowedTools`, `--permission-mode`). codex and - opencode emit a warning and fall back to their defaults when `permissions` - is set. +- **Harness support today:** `claude` is wired for allow/deny/mode flags + (`--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` is + wired for `mode` via `--permission-mode`; allow/deny lists warn and are + ignored. `codex` and `opencode` emit a warning and fall back to their + defaults when `permissions` is set. - **Cascade merge:** `allow` and `deny` are unions across layers (deduped on merge); `mode` is replaced by the topmost layer that sets it. So the library can declare the minimum-viable allow list, a user or configured @@ -913,6 +914,7 @@ verbatim. Three transport types: | claude | yes (via `--mcp-config` + `--strict-mcp-config`) | not yet — SDK workflow path doesn't thread MCP | | codex | yes (via `--config mcp_servers....`) | not yet — SDK workflow path doesn't thread MCP | | opencode | not yet — warns and proceeds without MCP | not yet | +| grok | not yet — warns and proceeds without MCP | not yet | For a persona that needs MCP today, pick `claude` or `codex` as the harness for that tier. @@ -934,14 +936,15 @@ persona session, add it to the persona's `mcpServers` block. agentworkforce agent [--install-in-repo] [--no-launch-metadata] [@] ``` -By default, claude and opencode sessions run inside a sandbox mount — see +By default, interactive harness sessions run inside a sandbox mount — see [**Sandbox mount**](#sandbox-mount) below. `--install-in-repo` opts out. 1. Resolves the persona, walks the cascade, resolves `$VAR` refs. 2. **Stages skills outside the repo by default** (claude interactive only — - see **Skill staging** below). For codex / opencode, or when + see **Skill staging** below). For codex / opencode / grok, or when `--install-in-repo` is passed, falls back to the legacy repo-relative - install path (`.claude/skills/`, `.agents/skills/`, `.skills/`). + install path (`.claude/skills/`, `.agents/skills/`, `.skills/`, + `.grok/skills/`). 3. Runs skill install (`prpm install …`) if the persona declares any skills, using the computed target (stage dir or repo). 4. Execs the harness binary with stdio inherited: @@ -954,8 +957,10 @@ By default, claude and opencode sessions run inside a sandbox mount — see isolation** above). - `codex`: `codex -m ` with the system prompt as the initial positional `[PROMPT]`. (codex has no `--system-prompt` flag today.) - - `opencode`: `opencode --model ` with the system prompt as the - initial argument. + - `opencode`: `opencode --agent ` with a generated + `opencode.json` in the sandbox carrying the persona model and prompt. + - `grok`: `grok --no-auto-update --model `. In one-shot paths, the + CLI uses Grok Build's `--single` mode and passes cwd/output flags. 5. Runs the skill cleanup command on exit, regardless of exit status. In stage-dir mode this is a single `rm -rf `. 6. Records launch metadata for the session and refreshes harness session logs @@ -1042,7 +1047,7 @@ stage dir conflicts with something else (network filesystem, read-only **Caveats for V1:** -- **Claude harness only.** codex and opencode continue to install into their +- **Claude harness only.** codex, opencode, and grok continue to install into their conventional repo-relative directories. The SDK throws if `installRoot` is passed with a non-claude harness. - **No cache layer yet.** Every interactive session runs a fresh prpm install @@ -1051,13 +1056,13 @@ stage dir conflicts with something else (network filesystem, read-only ## Sandbox mount -By default, claude and opencode interactive sessions run inside a +By default, interactive harness sessions run inside a [`@relayfile/local-mount`](https://www.npmjs.com/package/@relayfile/local-mount) mount that hides repo-level harness configuration from the session, applies the persona `mount` block plus Relayfile `.agentignore` / `.agentreadonly` rules, and routes skill-install writes into the sandbox — so the model sees persona context + user-level context, and only the project files the mount -exposes. Codex sessions never mount (no harness-side support). +exposes. `--install-in-repo` opts out and runs against the real cwd. @@ -1081,12 +1086,12 @@ For claude: | `.claude` | Repo Claude Code config dir (settings, agents, skills, commands) | | `.mcp.json` | Repo-declared MCP servers | -For opencode (skill-install pollution that would otherwise leak back to -the repo): +For non-claude harnesses (skill-install pollution that would otherwise leak +back to the repo): | Pattern | Rationale | | --- | --- | -| `.agents`, `.claude/skills`, `.factory/skills`, `.kiro/skills`, `skills` | skill.sh universal install root + per-harness symlink farms | +| `.agents`, `.claude/skills`, `.factory/skills`, `.grok/skills`, `.kiro/skills`, `skills` | skill.sh universal install root + per-harness symlink farms | | `.opencode`, `.skills` | prpm `--as ` output roots | | `prpm.lock`, `skills-lock.json` | provider lockfiles | @@ -1098,8 +1103,8 @@ the repo): - **Persona skills.** For claude, the `--plugin-dir` passed to the harness resolves to an absolute path *outside* the mount, so staged skills from `~/.agentworkforce/workforce/sessions//claude/plugin/` load normally. For - opencode, the install runs inside the mount so the writes land in the - sandbox. + codex, opencode, and grok, the install runs inside the mount so the writes + land in the sandbox. - **Keychain auth.** The mount does not pass `--bare`; it only hides files. Claude Code's macOS keychain login stays active. - **Persona `mcpServers`.** Still passed via `--mcp-config` — unaffected diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index dae47384..9108fc15 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -404,6 +404,11 @@ test('decideCleanMode: codex defaults to mount (parity with claude/opencode)', ( assert.deepEqual(decideCleanMode('codex', true), { useClean: false }); }); +test('decideCleanMode: grok defaults to mount', () => { + assert.deepEqual(decideCleanMode('grok'), { useClean: true }); + assert.deepEqual(decideCleanMode('grok', true), { useClean: false }); +}); + test('formatSandboxMountReadyMessage: appends mount metrics when available', () => { assert.equal( formatSandboxMountReadyMessage('/tmp/mount', { @@ -535,6 +540,7 @@ test('SKILL_INSTALL_IGNORED_PATTERNS: keeps skill-install artifacts out of the r '.agents', '.claude/skills', '.factory/skills', + '.grok/skills', '.kiro/skills', 'skills', '.opencode', @@ -1092,6 +1098,23 @@ test('loadSidecarForSelection: opencode picks agentsMd, not claudeMd', () => { assert.equal(sidecar?.personaContent, '# agents\n'); }); +test('loadSidecarForSelection: grok picks agentsMd, not claudeMd', () => { + const selection = { + personaId: 'p', + harness: 'grok' as const, + model: 'grok-build-0.1', + systemPrompt: 'X', + harnessSettings: { reasoning: 'medium' as const, timeoutSeconds: 300 }, + skills: [], + rationale: 'test', + claudeMdContent: '# claude\n', + agentsMdContent: '# agents\n' + }; + const { sidecar } = loadSidecarForSelection(selection); + assert.equal(sidecar?.mountFile, 'AGENTS.md'); + assert.equal(sidecar?.personaContent, '# agents\n'); +}); + test('main: codex sessions engage the sandbox mount by default', async () => { const root = mkdtempSync(join(tmpdir(), 'aw-cli-mount-')); try { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 12b662bc..bfca7960 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -129,16 +129,16 @@ Commands: install skills into the repo's harness-conventional directory (.claude/skills, .opencode/skills, - .agents/skills, etc.). By default, - interactive claude and opencode + .agents/skills, .grok/skills, etc.). + By default, interactive harness sessions run inside a @relayfile/local-mount sandbox so npx prpm install / npx skills add writes never touch the real repo and CLAUDE.md / .claude / .mcp.json - are hidden from the session. Codex - sessions never mount and ignore - this flag. + (or AGENTS.md / skill roots for + non-claude harnesses) are hidden + from the session. --no-launch-metadata Disable launch metadata recording. Also disabled by @@ -217,7 +217,7 @@ Commands: integrations [provider] [--all] [--json] Discover workspace integrations, connection status, and known trigger events. - harness check Probe which harnesses (claude, codex, opencode) are + harness check Probe which harnesses (${HARNESS_VALUES.join(', ')}) are installed and runnable on this machine. pick "" Pick the best-fit persona for a free-text task description using a cheap LLM call (Claude Haiku via the local @@ -949,10 +949,11 @@ async function writeMarkerWithUpstream( * (typically a relayfile mount root). Skips the cache marker file so it * never lands inside the harness's view. * - * Used by the mount-harness branch to give opencode / codex the same + * Used by the mount-harness branch to give non-claude harnesses the same * "install once, reuse forever" behaviour that claude gets for free via * `--plugin-dir `. The destination is expected to be the mount - * root: skill artifacts under `.skills/`, `.opencode/`, `.agents/`, etc. are + * root: skill artifacts under `.skills/`, `.opencode/`, `.agents/`, `.grok/skills`, + * etc. are * declared as mount-ignored patterns so the mirror does not sync back to the * user's real repo on session exit. */ @@ -1034,7 +1035,7 @@ export const CLEAN_IGNORED_PATTERNS = [ '.claude', '.mcp.json', // Per-persona AGENTS.md sidecars get materialized into the mount when - // running under opencode; without this the user's real-cwd AGENTS.md + // running under a non-claude harness; without this the user's real-cwd AGENTS.md // would copy in (masking the persona content) and writes from // onBeforeLaunch would sync back out. 'AGENTS.md' @@ -1055,6 +1056,7 @@ export const SKILL_INSTALL_IGNORED_PATTERNS = [ '.agents', '.claude/skills', '.factory/skills', + '.grok/skills', '.kiro/skills', 'skills', // prpm `--as ` output roots @@ -1063,7 +1065,7 @@ export const SKILL_INSTALL_IGNORED_PATTERNS = [ // provider lockfiles written at the repo root 'prpm.lock', 'skills-lock.json', - // Per-persona AGENTS.md sidecars (opencode harness) get materialized + // Per-persona AGENTS.md sidecars (non-claude harnesses) get materialized // into the mount; hide so the real-cwd AGENTS.md isn't copied in and // the persona-written copy doesn't sync back out. 'AGENTS.md' @@ -1184,10 +1186,10 @@ export function configureGitForMount(mountDir: string, patterns: readonly string * Persona-supplied sidecar markdown materialized into a sandbox mount. * Pure data carrier — `runInteractive` translates it into the on-disk * write inside `onBeforeLaunch` (mount path) and warns/skips when the - * harness has no mount (codex / `--install-in-repo`). + * harness has no mount (`--install-in-repo`). */ export interface ResolvedSidecar { - /** Filename inside the mount: `CLAUDE.md` (claude) or `AGENTS.md` (opencode). */ + /** Filename inside the mount: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex/grok). */ mountFile: 'CLAUDE.md' | 'AGENTS.md'; /** Persona-author content. Already inlined for built-ins; read from disk for local. */ personaContent: string; @@ -1206,7 +1208,14 @@ export function loadSidecarForSelection( selection: PersonaSelection ): { sidecar?: ResolvedSidecar; warning?: string } { const harness = selection.harness; - if (harness !== 'claude' && harness !== 'opencode' && harness !== 'codex') return {}; + if ( + harness !== 'claude' && + harness !== 'opencode' && + harness !== 'codex' && + harness !== 'grok' + ) { + return {}; + } if (harness === 'claude') { if (selection.claudeMdContent) { return { @@ -1233,10 +1242,8 @@ export function loadSidecarForSelection( } return {}; } - // opencode and codex both read AGENTS.md from cwd. For codex, the mount - // only engages when a sidecar is declared (see decideCleanMode); without - // a mount the materialization warning fires. The resolution rule is - // identical for both harnesses here. + // opencode, codex, and grok all read AGENTS.md from cwd. The resolution + // rule is identical for these harnesses here. if (selection.agentsMdContent) { return { sidecar: { @@ -1295,12 +1302,12 @@ export function buildSidecarBody( * Decide whether to run the interactive session inside a * `@relayfile/local-mount` sandbox. * - * All three harnesses (claude, codex, opencode) default to the mount. + * All interactive harnesses default to the mount. * The mount hides CLAUDE.md / .claude / .mcp.json (claude) or the - * skill-install patterns + AGENTS.md (codex / opencode) so persona-supplied - * sidecars and any per-session writes stay sandboxed and don't leak into - * the user's real repo. `--install-in-repo` is the single opt-out that - * disengages the mount across all harnesses. + * skill-install patterns + AGENTS.md (codex / opencode / grok) so + * persona-supplied sidecars and any per-session writes stay sandboxed and + * don't leak into the user's real repo. `--install-in-repo` is the single + * opt-out that disengages the mount across all harnesses. * * Pure — no side effects, trivially testable. */ @@ -1308,7 +1315,7 @@ export function decideCleanMode( harness: Harness, installInRepo = false ): { useClean: boolean } { - if (harness === 'claude' || harness === 'opencode' || harness === 'codex') { + if (harness === 'claude' || harness === 'opencode' || harness === 'codex' || harness === 'grok') { return { useClean: !installInRepo }; } return { useClean: false }; @@ -1336,12 +1343,16 @@ export function buildSpawnSummary(input: { if (input.harness === 'claude') { const servers = Object.keys(input.spec.mcpServers ?? {}); summary.push(`mcp-strict=${servers.length ? servers.join(',') : '(none)'}`); + } + if (input.harness === 'claude') { if (input.permissions?.allow?.length) { summary.push(`allow=${input.permissions.allow.length} rule(s)`); } if (input.permissions?.deny?.length) { summary.push(`deny=${input.permissions.deny.length} rule(s)`); } + } + if (input.harness === 'claude' || input.harness === 'grok') { if (input.permissions?.mode) { summary.push(`mode=${input.permissions.mode}`); } @@ -1578,7 +1589,7 @@ async function runInteractive( const { personaId, harness, model, harnessSettings, systemPrompt } = effectiveSelection; // `installRoot` (out-of-repo skill staging via `--plugin-dir`) is currently // claude-only; the workload-router SDK throws if it's set for other - // harnesses. For opencode, we instead keep installs out of the repo by + // harnesses. For other harnesses, we instead keep installs out of the repo by // running them inside a @relayfile/local-mount sandbox (see `useClean` // below). The --install-in-repo flag forces legacy in-repo installs // across the board. @@ -1602,8 +1613,8 @@ async function runInteractive( ); } // A session dir is needed whenever we either (a) stage skills out-of-repo - // via claude's installRoot, or (b) open a mount. Both engage for claude/ - // opencode by default; --install-in-repo disengages both. + // via claude's installRoot, or (b) open a mount. Both engage by default; + // --install-in-repo disengages both. const useSessionDir = !options.installInRepo && (harness === 'claude' || useClean); const sessionRoot = useSessionDir ? generateSessionRoot(personaId) : undefined; @@ -1755,9 +1766,9 @@ async function runInteractive( ? `Staging session plugin dir${installRoot ? ` → ${installRoot}` : ''}` : `Installing skills: ${skillIds}${installRoot ? ` → ${installRoot}` : ''}`; // When useClean engages on a non-claude harness, the install must run - // INSIDE the mount so `.opencode/skills/`, `.agents/skills/`, prpm.lock, - // etc. land in the sandbox rather than the real repo. We defer it to - // `onBeforeLaunch` below instead of pre-running here. + // INSIDE the mount so harness skill roots, prpm.lock, etc. land in the + // sandbox rather than the real repo. We defer it to `onBeforeLaunch` + // below instead of pre-running here. const deferInstallToMount = useClean && harness !== 'claude' && install.commandString !== ':'; if ( @@ -1819,7 +1830,7 @@ async function runInteractive( for (const w of spec.warnings) process.stderr.write(`warning: ${w}\n`); // Config-file materialization strategy: - // - Mount path (claude/opencode default): write each configFile into the + // - Mount path (default): write each configFile into the // mount dir via onBeforeLaunch, so it lives only in the sandbox and is // torn down with the session. // - Non-mount path: today the only configFile producer is opencode @@ -1859,11 +1870,11 @@ async function runInteractive( // Mount branch: delegate process lifecycle (spawn, signal forwarding, // syncback, cleanup) to @relayfile/local-mount. // - // For claude and opencode: mount engages by default (unless + // For interactive harnesses: mount engages by default (unless // `--install-in-repo`). For claude this hides CLAUDE.md / .claude / - // .mcp.json; for opencode it routes `npx prpm install` / `npx skills - // add` writes into the sandbox (skill plugin dir for claude lives - // outside the mount at an absolute path, so claude still resolves + // .mcp.json; for non-claude harnesses it routes `npx prpm install` / + // `npx skills add` writes into the sandbox (skill plugin dir for claude + // lives outside the mount at an absolute path, so claude still resolves // `--plugin-dir` normally). // // The install itself runs inside the mount via `onBeforeLaunch` so that diff --git a/packages/cli/src/launch-metadata.test.ts b/packages/cli/src/launch-metadata.test.ts index 89d3ee65..abf6e00c 100644 --- a/packages/cli/src/launch-metadata.test.ts +++ b/packages/cli/src/launch-metadata.test.ts @@ -207,4 +207,5 @@ test('launchMetadataIngestHarness maps AgentWorkforce claude to backend claude-c assert.equal(launchMetadataIngestHarness('claude'), 'claude-code'); assert.equal(launchMetadataIngestHarness('codex'), 'codex'); assert.equal(launchMetadataIngestHarness('opencode'), 'opencode'); + assert.equal(launchMetadataIngestHarness('grok'), 'grok'); }); diff --git a/packages/cli/src/launch-metadata.ts b/packages/cli/src/launch-metadata.ts index d7a0764c..03a803c5 100644 --- a/packages/cli/src/launch-metadata.ts +++ b/packages/cli/src/launch-metadata.ts @@ -17,7 +17,7 @@ const LAUNCH_METADATA_BACKEND_CALL_TIMEOUT_MS = 5_000; */ const LAUNCH_METADATA_INGEST_FAILURE_WARN_AFTER = 3; -export type LaunchMetadataIngestHarness = 'claude-code' | 'codex' | 'opencode'; +export type LaunchMetadataIngestHarness = 'claude-code' | 'codex' | 'opencode' | 'grok'; export type LaunchMetadataPendingStampHarness = Harness; export interface LaunchMetadataPendingStampOptions { @@ -122,6 +122,8 @@ export function launchMetadataSessionDirHint(harness: Harness): string | undefin return join(home, '.codex', 'sessions'); case 'opencode': return join(home, '.local', 'share', 'opencode', 'storage', 'session'); + case 'grok': + return undefined; default: { const _exhaustive: never = harness; return _exhaustive; diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 28f82e0c..2f990d91 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -38,7 +38,7 @@ }, "harness": { "$ref": "#/definitions/Harness", - "description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`). Required for interactive personas. Optional for handler-style personas ( {@link onEvent } set): only consumed when the handler calls `ctx.harness.run(...)`; pure orchestrators omit it." + "description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`, `grok`). Required for interactive personas. Optional for handler-style personas ( {@link onEvent } set): only consumed when the handler calls `ctx.harness.run(...)`; pure orchestrators omit it." }, "model": { "type": "string", @@ -64,11 +64,11 @@ "additionalProperties": { "$ref": "#/definitions/McpServerSpec" }, - "description": "MCP servers to attach to the harness session.\n- `claude`: passed via `--mcp-config`\n- `codex`: translated into `--config mcp_servers....` overrides\n- `opencode`: currently warns and skips" + "description": "MCP servers to attach to the harness session.\n- `claude`: passed via `--mcp-config`\n- `codex`: translated into `--config mcp_servers....` overrides\n- `opencode` / `grok`: currently warn and skip" }, "permissions": { "$ref": "#/definitions/PersonaPermissions", - "description": "Permission policy (allow/deny lists, mode) for the harness session. Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, `--permission-mode`); other harnesses warn and skip." + "description": "Permission policy (allow/deny lists, mode) for the harness session. Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` wires `mode` via `--permission-mode`; other fields/harnesses warn and skip." }, "sandbox": { "type": "boolean", @@ -88,7 +88,7 @@ }, "agentsMd": { "type": "string", - "description": "Author-supplied path to an `AGENTS.md` sidecar that should be applied when the persona runs under the opencode harness. Same resolution rules as {@link claudeMd } ." + "description": "Author-supplied path to an `AGENTS.md` sidecar that should be applied when the persona runs under the opencode/codex/grok harnesses. Same resolution rules as {@link claudeMd } ." }, "agentsMdMode": { "$ref": "#/definitions/SidecarMdMode", @@ -228,7 +228,8 @@ "enum": [ "opencode", "codex", - "claude" + "claude", + "grok" ] }, "HarnessSettings": { diff --git a/packages/persona-kit/src/constants.ts b/packages/persona-kit/src/constants.ts index 767a0c9a..f03ddbad 100644 --- a/packages/persona-kit/src/constants.ts +++ b/packages/persona-kit/src/constants.ts @@ -1,6 +1,6 @@ import type { Harness, HarnessSkillTarget } from './types.js'; -export const HARNESS_VALUES = ['opencode', 'codex', 'claude'] as const; +export const HARNESS_VALUES = ['opencode', 'codex', 'claude', 'grok'] as const; /** * The closed persona-tag vocabulary. The cloud deploy endpoint validates * `tags` against exactly this set and rejects anything else with @@ -97,5 +97,6 @@ export const SKILL_SOURCE_KINDS = ['prpm', 'skill.sh', 'local'] as const; export const HARNESS_SKILL_TARGETS: Record = { claude: { asFlag: 'claude', dir: '.claude/skills' }, codex: { asFlag: 'codex', dir: '.agents/skills' }, - opencode: { asFlag: 'opencode', dir: '.skills' } + opencode: { asFlag: 'opencode', dir: '.skills' }, + grok: { asFlag: 'grok', dir: '.grok/skills' } }; diff --git a/packages/persona-kit/src/index.test.ts b/packages/persona-kit/src/index.test.ts index 2d1a8aae..d11b8bee 100644 --- a/packages/persona-kit/src/index.test.ts +++ b/packages/persona-kit/src/index.test.ts @@ -44,6 +44,10 @@ test('claude is a recognized harness value', () => { assert.ok(HARNESS_VALUES.includes('claude')); }); +test('grok is a recognized harness value', () => { + assert.ok(HARNESS_VALUES.includes('grok')); +}); + test('HARNESS_SKILL_TARGETS covers every harness value', () => { for (const harness of HARNESS_VALUES) { const target = HARNESS_SKILL_TARGETS[harness]; @@ -87,6 +91,18 @@ test('materializeSkills routes claude skills to .claude/skills via --as claude', assert.equal(install.installedDir, '.claude/skills/npm-trusted-publishing'); }); +test('materializeSkills routes grok skills to .grok/skills via --as grok', () => { + const plan = materializeSkills([prpmSkill], 'grok'); + const [install] = plan.installs; + assert.deepEqual( + [...install.installCommand], + ['npx', '-y', 'prpm', 'install', '@prpm/npm-trusted-publishing', '--as', 'grok'] + ); + assert.equal(install.installedDir, '.grok/skills/npm-trusted-publishing'); + assert.equal(install.installedManifest, '.grok/skills/npm-trusted-publishing/SKILL.md'); + assert.deepEqual([...install.cleanupPaths], ['.grok/skills/npm-trusted-publishing']); +}); + test('materializeSkills emits a skill.sh install for a github#skill source', () => { const plan = materializeSkills([skillShSkill], 'claude'); @@ -115,6 +131,7 @@ test('materializeSkills emits a skill.sh install for a github#skill source', () '.agents/skills/find-skills', '.claude/skills/find-skills', '.factory/skills/find-skills', + '.grok/skills/find-skills', '.kiro/skills/find-skills', 'skills/find-skills' ] diff --git a/packages/persona-kit/src/interactive-spec.test.ts b/packages/persona-kit/src/interactive-spec.test.ts index 8b8e2f16..cf33ddd6 100644 --- a/packages/persona-kit/src/interactive-spec.test.ts +++ b/packages/persona-kit/src/interactive-spec.test.ts @@ -315,7 +315,116 @@ test('opencode configFiles carries a well-formed opencode.json with the agent de }); }); -test('claude and codex emit an empty configFiles array', () => { +test('grok launches the Grok Build CLI with model and no config file', () => { + const result = buildInteractiveSpec({ + harness: 'grok', + personaId: 'test-persona', + model: 'grok-build-0.1', + systemPrompt: 'you are a test' + }); + assert.equal(result.bin, 'grok'); + assert.deepEqual(result.args, ['--no-auto-update', '--model', 'grok-build-0.1']); + assert.equal(result.initialPrompt, null); + assert.deepEqual(result.configFiles, []); + assert.deepEqual(result.warnings, []); +}); + +test('grok wires permission mode and plugin dirs to native flags', () => { + const result = buildInteractiveSpec({ + harness: 'grok', + personaId: 'test-persona', + model: 'grok-build-0.1', + systemPrompt: 'x', + permissions: { mode: 'acceptEdits' }, + pluginDirs: ['/tmp/session/grok/plugin'] + }); + assert.deepEqual(result.args, [ + '--no-auto-update', + '--model', + 'grok-build-0.1', + '--permission-mode', + 'acceptEdits', + '--plugin-dir', + '/tmp/session/grok/plugin' + ]); + assert.deepEqual(result.warnings, []); +}); + +test('grok warns for permission allow/deny lists but still accepts mode', () => { + const result = buildInteractiveSpec({ + harness: 'grok', + personaId: 'test-persona', + model: 'grok-build-0.1', + systemPrompt: 'x', + permissions: { + allow: ['Bash(git *)'], + deny: ['Bash(rm -rf *)'], + mode: 'acceptEdits' + } + }); + assert.deepEqual(result.args, [ + '--no-auto-update', + '--model', + 'grok-build-0.1', + '--permission-mode', + 'acceptEdits' + ]); + assert.deepEqual(result.warnings, [ + 'persona declares permission allow/deny lists but the grok harness only supports permission mode injection; proceeding without allow/deny rules.' + ]); +}); + +test('grok non-interactive spec uses single-shot mode with cwd and always-approve', () => { + const result = buildNonInteractiveSpec({ + harness: 'grok', + personaId: 'daily-ship', + model: 'grok-build-0.1', + systemPrompt: 'Reply pong.', + task: 'say pong', + workingDirectory: '/tmp/project' + }); + + assert.equal(result.bin, 'grok'); + assert.deepEqual(result.args, [ + '--no-auto-update', + '--model', + 'grok-build-0.1', + '--output-format', + 'plain', + '--cwd', + '/tmp/project', + '--always-approve', + '--single', + 'Reply pong.\n\nUser task:\nsay pong' + ]); + assert.deepEqual(result.configFiles, []); + assert.deepEqual(result.warnings, []); +}); + +test('grok non-interactive spec does not add always-approve when permission mode is explicit', () => { + const result = buildNonInteractiveSpec({ + harness: 'grok', + personaId: 'daily-ship', + model: 'grok-build-0.1', + systemPrompt: '', + task: 'say pong', + permissions: { mode: 'plan' } + }); + + assert.deepEqual(result.args, [ + '--no-auto-update', + '--model', + 'grok-build-0.1', + '--permission-mode', + 'plan', + '--output-format', + 'plain', + '--single', + 'say pong' + ]); +}); + +test('claude, codex, and grok emit an empty configFiles array', () => { const claude = buildInteractiveSpec({ harness: 'claude', personaId: 'test-persona', @@ -331,6 +440,14 @@ test('claude and codex emit an empty configFiles array', () => { systemPrompt: 'x' }); assert.deepEqual(codex.configFiles, []); + + const grok = buildInteractiveSpec({ + harness: 'grok', + personaId: 'test-persona', + model: 'grok-build-0.1', + systemPrompt: 'x' + }); + assert.deepEqual(grok.configFiles, []); }); test('claude branch omits --append-system-prompt when systemPrompt is empty', () => { @@ -440,7 +557,7 @@ test('claude branch omits --plugin-dir when pluginDirs is empty or absent', () = assert.ok(!without.args.includes('--plugin-dir')); }); -test('non-claude harnesses warn and ignore pluginDirs', () => { +test('codex and opencode warn and ignore pluginDirs', () => { const codex = buildInteractiveSpec({ harness: 'codex', personaId: 'test-persona', @@ -593,6 +710,20 @@ test('relayMcp under opencode warns that MCP injection is unsupported', () => { assert.equal(result.mcpServers?.relaycast?.type, 'stdio'); }); +test('relayMcp under grok warns that MCP injection is unsupported', () => { + const result = buildInteractiveSpec({ + harness: 'grok', + personaId: 'p', + model: 'grok-build-0.1', + systemPrompt: 'x', + relayMcp: { apiKey: 'wk_live_abc', agentName: 'Grok1' } + }); + assert.deepEqual(result.warnings, [ + 'broker requested relaycast MCP injection but the grok harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ]); + assert.equal(result.mcpServers?.relaycast?.type, 'stdio'); +}); + test('opencode warning names both persona and broker MCP sources when both are present', () => { const result = buildInteractiveSpec({ harness: 'opencode', @@ -608,3 +739,19 @@ test('opencode warning names both persona and broker MCP sources when both are p 'persona declares mcpServers and broker requested relaycast MCP injection, but the opencode harness is not yet wired for runtime MCP injection; proceeding without MCP.' ]); }); + +test('grok warning names both persona and broker MCP sources when both are present', () => { + const result = buildInteractiveSpec({ + harness: 'grok', + personaId: 'p', + model: 'grok-build-0.1', + systemPrompt: 'x', + mcpServers: { + posthog: { type: 'http', url: 'https://mcp.posthog.com/mcp' } + }, + relayMcp: { apiKey: 'wk_live_abc', agentName: 'Grok1' } + }); + assert.deepEqual(result.warnings, [ + 'persona declares mcpServers and broker requested relaycast MCP injection, but the grok harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ]); +}); diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index ba9a1973..3309ab79 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -19,7 +19,7 @@ export interface InteractiveConfigFile { /** Result of translating a persona's runtime into a spawnable command. */ export interface InteractiveSpec { - /** Binary to exec (e.g. `claude`, `codex`, `opencode`). */ + /** Binary to exec (e.g. `claude`, `codex`, `opencode`, `grok`). */ bin: string; /** Argv for the binary, in order. Callers should `spawn(bin, args)`. */ args: readonly string[]; @@ -27,9 +27,9 @@ export interface InteractiveSpec { * If set, the caller should append this as the final positional argument * — used by harnesses that don't support a separate system-prompt flag * to carry the persona's system prompt as the initial user prompt. - * Currently only codex takes this path; claude uses `--append-system-prompt` - * and opencode writes the prompt into `opencode.json` (see `configFiles`), - * so both return `null` here. + * Currently only codex takes this path; claude uses `--append-system-prompt`, + * opencode writes the prompt into `opencode.json` (see `configFiles`), and + * grok consumes the rendered system prompt only in one-shot mode. */ initialPrompt: string | null; /** @@ -41,7 +41,7 @@ export interface InteractiveSpec { * Config files the caller must write (relative to the harness cwd) before * launch. Opencode uses this to materialize an `opencode.json` carrying * the persona's agent definition (model + system prompt) so `--agent` can - * resolve it; claude and codex return an empty array. + * resolve it; claude, codex, and grok return an empty array. */ configFiles: InteractiveConfigFile[]; /** @@ -84,7 +84,7 @@ export interface RelayMcpConfig { export interface BuildInteractiveSpecInput { harness: Harness; /** - * Persona id — used as the opencode agent name. Claude and codex ignore + * Persona id — used as the opencode agent name. Claude, codex, and grok ignore * this field today; keeping it required here keeps call sites honest and * lets future harnesses consume it without another type change. */ @@ -97,18 +97,19 @@ export interface BuildInteractiveSpecInput { * When set, a `relaycast` MCP server is merged into {@link mcpServers} so a * persona running under an Agent Relay broker can talk to the team. A * persona-declared server literally named `relaycast` takes precedence (it - * is not overwritten). Wired for claude and codex; opencode still warns that - * MCP injection is unsupported. + * is not overwritten). Wired for claude and codex; opencode/grok still warn + * that MCP injection is unsupported. */ relayMcp?: RelayMcpConfig; permissions?: PersonaPermissions; harnessSettings?: HarnessSettings; /** - * Absolute paths of directories to load as Claude Code plugins for this - * session (`--plugin-dir ` per entry). Used to wire in out-of-repo + * Absolute paths of directories to load as harness plugins for this + * session (`--plugin-dir ` per entry where supported). Used to wire in out-of-repo * skill stages produced by * {@link SkillMaterializationOptions.installRoot}. - * Claude-only: other harnesses emit a warning and ignore the field. + * Currently supported by Claude and Grok. Codex/opencode emit a warning and + * ignore the field. */ pluginDirs?: readonly string[]; } @@ -210,6 +211,10 @@ function appendCodexMcpServerArgs( * codex has no dedicated system-prompt flag today — callers append it as * the final positional `[PROMPT]`. * + * The grok branch launches the Grok Build CLI (`grok`) with the persona model. + * Grok reads AGENTS.md and .grok/skills from the working tree; one-shot mode + * uses `--single` because Grok has no separate system-prompt flag. + * * The opencode branch routes model + system prompt through opencode's * agent abstraction (see https://opencode.ai/config.json: `agent..{ * model, prompt, mode }`). It emits an `opencode.json` via `configFiles` @@ -223,11 +228,10 @@ function appendCodexMcpServerArgs( * * The codex branch translates persona `mcpServers` into repeated * `--config mcp_servers....` TOML overrides so codex sessions receive - * the same declared MCP servers as the persona. Permission wiring remains - * claude-only for now. + * the same declared MCP servers as the persona. * - * The opencode branch emits a warning if the persona declares `mcpServers` - * or `permissions` — those features aren't wired for opencode yet. + * The opencode/grok branches emit a warning if the persona declares `mcpServers`. + * Grok wires `permissions.mode`; opencode and other Grok permission fields warn. */ /** * Build the stdio MCP server spec for relaycast, mirroring the env block the @@ -461,6 +465,63 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact mcpServers }; } + case 'grok': { + if (hasPersonaMcpServers && injectsRelaycast) { + warnings.push( + 'persona declares mcpServers and broker requested relaycast MCP injection, but the grok harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } else if (hasPersonaMcpServers) { + warnings.push( + 'persona declares mcpServers but the grok harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } else if (injectsRelaycast) { + warnings.push( + 'broker requested relaycast MCP injection but the grok harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } + if (permissions?.allow?.length || permissions?.deny?.length) { + warnings.push( + 'persona declares permission allow/deny lists but the grok harness only supports permission mode injection; proceeding without allow/deny rules.' + ); + } + const args = ['--no-auto-update', '--model', model]; + if (permissions?.mode) { + args.push('--permission-mode', permissions.mode); + } + if (hasPluginDirs) { + for (const dir of pluginDirs!) { + args.push('--plugin-dir', dir); + } + } + if ( + harnessSettings?.dangerouslyBypassApprovalsAndSandbox && + !args.includes('--permission-mode') + ) { + args.push('--always-approve'); + } + if (harnessSettings?.sandboxMode) { + warnings.push('grok harnessSettings.sandboxMode is not yet wired; proceeding with Grok defaults.'); + } + if (harnessSettings?.approvalPolicy) { + warnings.push( + 'grok harnessSettings.approvalPolicy is not supported; use permissions.mode or dangerouslyBypassApprovalsAndSandbox.' + ); + } + if (harnessSettings?.workspaceWriteNetworkAccess !== undefined) { + warnings.push('grok harnessSettings.workspaceWriteNetworkAccess is not yet wired; proceeding with Grok defaults.'); + } + if (harnessSettings?.webSearch) { + warnings.push('grok harnessSettings.webSearch is not wired to a Grok CLI flag; proceeding with Grok defaults.'); + } + return { + bin: 'grok', + args, + initialPrompt: null, + warnings, + configFiles: [], + mcpServers + }; + } default: { // Exhaustiveness guard: if `Harness` gains a new variant, this // assertion will fail to compile and force the maintainer to handle @@ -491,6 +552,9 @@ export interface NonInteractiveSpec { * built from any `initialPrompt` joined with the user task. * - `opencode`: prefixes `run`, appends `--model --format default * [--dir ] [--title ] `. + * - `grok`: appends `--output-format plain [--cwd ] --always-approve + * --single `, where prompt includes the persona system prompt plus + * the one-shot task. */ export function buildNonInteractiveSpec( input: BuildInteractiveSpecInput & { @@ -534,6 +598,23 @@ export function buildNonInteractiveSpec( warnings: interactive.warnings }; } + case 'grok': { + const prompt = input.systemPrompt + ? `${input.systemPrompt}\n\nUser task:\n${input.task}` + : input.task; + const args = [...interactive.args, '--output-format', 'plain']; + if (input.workingDirectory) args.push('--cwd', input.workingDirectory); + if (!args.includes('--permission-mode') && !args.includes('--always-approve')) { + args.push('--always-approve'); + } + args.push('--single', prompt); + return { + bin: interactive.bin, + args, + configFiles: interactive.configFiles, + warnings: interactive.warnings + }; + } default: { const _exhaustive: never = input.harness; throw new Error(`Unhandled harness: ${String(_exhaustive)}`); diff --git a/packages/persona-kit/src/plan.test.ts b/packages/persona-kit/src/plan.test.ts index cd6d442b..02c16650 100644 --- a/packages/persona-kit/src/plan.test.ts +++ b/packages/persona-kit/src/plan.test.ts @@ -82,6 +82,18 @@ test('buildPersonaSpawnPlan resolves sidecars from claudeMdContent / agentsMdCon assert.equal(opencodePlan.sidecars.length, 1); assert.equal(opencodePlan.sidecars[0].filename, 'AGENTS.md'); assert.equal(opencodePlan.sidecars[0].mode, 'extend'); + + const grokPlan = buildPersonaSpawnPlan( + persona({ + agentsMdContent: '# grok agents sidecar', + harness: 'grok', + model: 'grok-build-0.1' + }), + { processEnv: cleanEnv } + ); + assert.equal(grokPlan.sidecars.length, 1); + assert.equal(grokPlan.sidecars[0].filename, 'AGENTS.md'); + assert.equal(grokPlan.sidecars[0].contents, '# grok agents sidecar'); }); test('buildPersonaSpawnPlan threads mount policy through when patterns present', () => { @@ -193,7 +205,7 @@ test('buildPersonaSpawnPlan emits sourcePath when only claudeMd path is set', () assert.equal(plan.sidecars[0].mode, 'extend'); }); -test('buildPersonaSpawnPlan emits sourcePath for opencode/codex agentsMd path', () => { +test('buildPersonaSpawnPlan emits sourcePath for AGENTS.md harness agentsMd path', () => { const plan = buildPersonaSpawnPlan( persona({ harness: 'opencode', @@ -205,6 +217,18 @@ test('buildPersonaSpawnPlan emits sourcePath for opencode/codex agentsMd path', ); assert.equal(plan.sidecars.length, 1); assert.equal(plan.sidecars[0].sourcePath, '/abs/path/to/AGENTS.md'); + + const grokPlan = buildPersonaSpawnPlan( + persona({ + harness: 'grok', + model: 'grok-build-0.1', + systemPrompt: 's', + agentsMd: '/abs/path/to/AGENTS.md' + }), + { processEnv: cleanEnv } + ); + assert.equal(grokPlan.sidecars.length, 1); + assert.equal(grokPlan.sidecars[0].sourcePath, '/abs/path/to/AGENTS.md'); }); test('buildPersonaSpawnPlan does not capture ambient env by default', () => { @@ -225,7 +249,7 @@ test('buildPersonaSpawnPlan opt-in includeProcessEnv captures process.env', () = }); test('buildPersonaSpawnPlan empty-skills case keeps installs empty', () => { - for (const harness of ['claude', 'codex', 'opencode'] as Harness[]) { + for (const harness of ['claude', 'codex', 'opencode', 'grok'] as Harness[]) { const plan = buildPersonaSpawnPlan( persona({ harness, diff --git a/packages/persona-kit/src/plan.ts b/packages/persona-kit/src/plan.ts index c88aefbb..f572261d 100644 --- a/packages/persona-kit/src/plan.ts +++ b/packages/persona-kit/src/plan.ts @@ -35,7 +35,7 @@ export interface ResolvedMountPolicy { * JSON-serializable; the executor reads the file at write time. */ export type ResolvedSidecarWrite = { - /** Filename inside the cwd: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex). */ + /** Filename inside the cwd: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex/grok). */ filename: 'CLAUDE.md' | 'AGENTS.md'; /** * `overwrite` writes verbatim; `extend` appends a `\n\n---\n\n`-joined @@ -66,7 +66,7 @@ export interface ResolvedInputBinding { export interface PersonaSpawnPlan { /** The fully resolved persona this plan was built from. */ persona: ResolvedPersona; - /** Which CLI to spawn (`claude` | `codex` | `opencode`). */ + /** Which CLI to spawn (`claude` | `codex` | `opencode` | `grok`). */ cli: Harness; /** argv (excluding the cli itself) that the harness should be spawned with. */ args: string[]; @@ -161,7 +161,7 @@ function resolveSidecarWrite( } return []; } - if (harness === 'opencode' || harness === 'codex') { + if (harness === 'opencode' || harness === 'codex' || harness === 'grok') { if (selection.agentsMdContent !== undefined) { return [ { diff --git a/packages/persona-kit/src/skills.ts b/packages/persona-kit/src/skills.ts index fb961467..69d87940 100644 --- a/packages/persona-kit/src/skills.ts +++ b/packages/persona-kit/src/skills.ts @@ -129,6 +129,7 @@ function skillShArtifactPaths(installedName: string): readonly string[] { `.agents/skills/${installedName}`, `.claude/skills/${installedName}`, `.factory/skills/${installedName}`, + `.grok/skills/${installedName}`, `.kiro/skills/${installedName}`, `skills/${installedName}` ]) as readonly string[]; @@ -310,7 +311,7 @@ export function materializeSkills( if (installRoot !== undefined && harness !== 'claude') { throw new Error( `installRoot is only supported for the claude harness (got: ${harness}). ` + - `codex and opencode still install into the harness's conventional repo-relative directory.` + `codex, opencode, and grok still install into the harness's conventional repo-relative directory.` ); } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 9c782c06..9e7b469b 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -383,7 +383,7 @@ export interface PersonaSpec { */ inputs?: Record; /** - * Harness binary used to run this persona (`claude`, `codex`, `opencode`). + * Harness binary used to run this persona (`claude`, `codex`, `opencode`, `grok`). * Required for interactive personas. Optional for handler-style personas * ({@link onEvent} set): only consumed when the handler calls * `ctx.harness.run(...)`; pure orchestrators omit it. @@ -405,13 +405,14 @@ export interface PersonaSpec { * MCP servers to attach to the harness session. * - `claude`: passed via `--mcp-config` * - `codex`: translated into `--config mcp_servers....` overrides - * - `opencode`: currently warns and skips + * - `opencode` / `grok`: currently warn and skip */ mcpServers?: Record; /** * Permission policy (allow/deny lists, mode) for the harness session. * Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, - * `--permission-mode`); other harnesses warn and skip. + * `--permission-mode`). `grok` wires `mode` via `--permission-mode`; + * other fields/harnesses warn and skip. */ permissions?: PersonaPermissions; /** @@ -451,7 +452,7 @@ export interface PersonaSpec { claudeMdMode?: SidecarMdMode; /** * Author-supplied path to an `AGENTS.md` sidecar that should be applied - * when the persona runs under the opencode harness. Same resolution + * when the persona runs under the opencode/codex/grok harnesses. Same resolution * rules as {@link claudeMd}. */ agentsMd?: string; From 422373a14492d905637cbc0556f52e08012675ec Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 5 Jun 2026 16:54:37 -0400 Subject: [PATCH 2/2] fix: address grok harness review feedback --- packages/cli/README.md | 18 +++--- packages/cli/src/cli.ts | 2 +- .../persona-kit/schemas/persona.schema.json | 2 +- packages/persona-kit/src/execute.test.ts | 49 +++++++++++---- packages/persona-kit/src/execute.ts | 16 ++--- .../persona-kit/src/interactive-spec.test.ts | 40 ++++++------ packages/persona-kit/src/interactive-spec.ts | 42 ++++++++----- packages/persona-kit/src/plan.test.ts | 16 +++++ packages/persona-kit/src/types.ts | 7 ++- packages/runtime/src/cloud-defaults.ts | 17 +++--- packages/runtime/src/runner.test.ts | 61 +++++++++++++++++++ 11 files changed, 197 insertions(+), 73 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index ac926118..319cea6d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -333,7 +333,7 @@ combination; by default only the **recommended tier per intent** is shown | `--all` | off | Show every tier of every persona. Alias: `--no-recommended`. | | `--recommended` | on | Only show the recommended tier per intent. Implicit default; mostly useful for undoing `--all` earlier in a wrapper script. | | `--filter-rating ` | — | Restrict to a single tier (`best` \| `best-value` \| `minimum`). **Implicitly turns off the recommended-only default**, so filtering by `best` shows every persona's `best` row even when that's not the recommended tier. | -| `--filter-harness ` | — | Restrict to a single harness (`claude` \| `codex` \| `opencode`). Composable with `--filter-rating` and `--all`. | +| `--filter-harness ` | — | Restrict to a single harness (`claude` \| `codex` \| `opencode` \| `grok`). Composable with `--filter-rating` and `--all`. | | `--no-display-description` | off | Hide the `DESCRIPTION` column. `--display-description` re-enables it. | | `--json` | off | Emit `{ "personas": [...] }` with one object per row. Same field set as the table, useful for scripting. | | `-h`, `--help` | — | Print a one-line usage string and exit. | @@ -780,9 +780,9 @@ persona JSON remains commit-safe as long as you only use references. ## Relayfile mount rules -Interactive `claude` and `opencode` sessions run inside a Relayfile mount by -default. File visibility and writability are controlled by the persona's -`mount` block plus project-level dotfiles: +Interactive harness sessions (`claude`, `opencode`, `grok`, `codex`) run inside +a Relayfile mount by default. File visibility and writability are controlled by +the persona's `mount` block plus project-level dotfiles: ```jsonc { @@ -826,10 +826,10 @@ mount rules (`.agentignore` / `.agentreadonly`) for that. grammar. For Claude Code: `Bash()`, `mcp__` (all tools from that server), `mcp____` (specific tool). - **Harness support today:** `claude` is wired for allow/deny/mode flags - (`--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` is - wired for `mode` via `--permission-mode`; allow/deny lists warn and are - ignored. `codex` and `opencode` emit a warning and fall back to their - defaults when `permissions` is set. + (`--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` maps + `mode: "bypassPermissions"` to `--always-approve`; other Grok permission + fields warn and are ignored. `codex` and `opencode` emit a warning and fall + back to their defaults when `permissions` is set. - **Cascade merge:** `allow` and `deny` are unions across layers (deduped on merge); `mode` is replaced by the topmost layer that sets it. So the library can declare the minimum-viable allow list, a user or configured @@ -1170,7 +1170,7 @@ If a persona uses MCP, use `claude` or `codex` tiers. auth interactively (e.g. Claude Code's MCP OAuth flow). - **`Failed to spawn "claude": binary not found on PATH.`** — Install the - harness CLI (`claude`, `codex`, or `opencode`) and ensure it's on your PATH. + harness CLI (`claude`, `codex`, `opencode`, or `grok`) and ensure it's on your PATH. - **`warning: persona declares mcpServers but the opencode harness is not yet wired …`** — Switch that tier's `harness` to `claude` or `codex`, or drop the diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index bfca7960..ddaeb60e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1352,7 +1352,7 @@ export function buildSpawnSummary(input: { summary.push(`deny=${input.permissions.deny.length} rule(s)`); } } - if (input.harness === 'claude' || input.harness === 'grok') { + if (input.harness === 'claude') { if (input.permissions?.mode) { summary.push(`mode=${input.permissions.mode}`); } diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 2f990d91..10b66a77 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -68,7 +68,7 @@ }, "permissions": { "$ref": "#/definitions/PersonaPermissions", - "description": "Permission policy (allow/deny lists, mode) for the harness session. Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` wires `mode` via `--permission-mode`; other fields/harnesses warn and skip." + "description": "Permission policy (allow/deny lists, mode) for the harness session. `claude` is wired for allow/deny/mode flags (via `--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` maps `mode: \"bypassPermissions\"` to `--always-approve`; other fields/harnesses warn and skip." }, "sandbox": { "type": "boolean", diff --git a/packages/persona-kit/src/execute.test.ts b/packages/persona-kit/src/execute.test.ts index 5ccf1229..4e8a483e 100644 --- a/packages/persona-kit/src/execute.test.ts +++ b/packages/persona-kit/src/execute.test.ts @@ -265,6 +265,32 @@ test('executePersonaSpawnPlan happy path orders side effects and disposes them i }); }); +test('executePersonaSpawnPlan lets grok AGENTS.md sidecar override generated systemPrompt file', async () => { + await withTmpDir(async (dir) => { + const plan = buildPersonaSpawnPlan( + persona({ + personaId: 'sample', + harness: 'grok', + model: 'grok-build-0.1', + systemPrompt: 'generated prompt', + agentsMdContent: '# persona agents', + agentsMdMode: 'overwrite' + }), + { processEnv: cleanEnv } + ); + assert.deepEqual(plan.configFiles, [ + { path: 'AGENTS.md', contents: 'generated prompt\n' } + ]); + assert.equal(plan.sidecars[0]?.filename, 'AGENTS.md'); + + const handle = await executePersonaSpawnPlan(plan, { cwd: dir }); + assert.equal(await readFile(join(dir, 'AGENTS.md'), 'utf8'), '# persona agents'); + + await handle.dispose(); + assert.equal(await exists(join(dir, 'AGENTS.md')), false); + }); +}); + test('executePersonaSpawnPlan empty-skills path is a no-op for skills', async () => { await withTmpDir(async (dir) => { const plan = buildPersonaSpawnPlan(persona(), { processEnv: cleanEnv }); @@ -277,11 +303,10 @@ test('executePersonaSpawnPlan empty-skills path is a no-op for skills', async () test('executePersonaSpawnPlan disposes prior handles when a later step fails', async () => { await withTmpDir(async (dir) => { - // Pre-create a stub at AGENTS.md so we can verify it gets restored after a - // later failing step. Then synthesize a plan whose configFile path is unsafe - // — it should reject after the sidecar step has already written to disk. - const target = join(dir, 'AGENTS.md'); - await writeFile(target, 'previous content', 'utf8'); + // Pre-create a stub at opencode.json so we can verify it gets restored after + // a later failing sidecar write. + const target = join(dir, 'opencode.json'); + await writeFile(target, 'previous config', 'utf8'); const plan = buildPersonaSpawnPlan( persona({ @@ -293,13 +318,13 @@ test('executePersonaSpawnPlan disposes prior handles when a later step fails', a }), { processEnv: cleanEnv } ); - // Inject an unsafe configFile to force materializePersonaConfigFiles to throw - // after the sidecar has been written. The executor must then restore the - // sidecar's prior content before the error propagates. - plan.configFiles.push({ path: '../escape.json', contents: 'x' }); + // Inject an unsafe sidecar filename to force writePersonaSidecars to throw + // after config files have been written. The executor must then restore the + // config file's prior content before the error propagates. + plan.sidecars[0] = { ...plan.sidecars[0]!, filename: '../escape.md' as 'AGENTS.md' }; - await assert.rejects(executePersonaSpawnPlan(plan, { cwd: dir }), /must not contain/); - // Sidecar must be restored to its original content. - assert.equal(await readFile(target, 'utf8'), 'previous content'); + await assert.rejects(executePersonaSpawnPlan(plan, { cwd: dir }), /must be a basename/); + // Config file must be restored to its original content. + assert.equal(await readFile(target, 'utf8'), 'previous config'); }); }); diff --git a/packages/persona-kit/src/execute.ts b/packages/persona-kit/src/execute.ts index 44ea15e3..45b79fd0 100644 --- a/packages/persona-kit/src/execute.ts +++ b/packages/persona-kit/src/execute.ts @@ -63,9 +63,9 @@ async function disposeAll(handles: readonly Disposer[]): Promise { * writes into the resulting cwd. * 2. {@link runSkillInstalls} — install before sidecars/configFiles so a * failing skill doesn't strand a half-written sidecar on disk. - * 3. {@link writePersonaSidecars} — claudeMd / agentsMd to disk with + * 3. {@link materializePersonaConfigFiles} — opencode.json and friends. + * 4. {@link writePersonaSidecars} — claudeMd / agentsMd to disk with * restore tracking. - * 4. {@link materializePersonaConfigFiles} — opencode.json and friends. * * If any step throws, prior steps' handles are disposed in LIFO order * before the original error propagates. Callers never see partial state. @@ -92,18 +92,18 @@ export async function executePersonaSpawnPlan( }); handles.push(skillsHandle); - const sidecarHandle: PersonaSidecarHandle = await writePersonaSidecars( - plan.sidecars, - { cwd: childCwd } - ); - handles.push(sidecarHandle); - const configHandle: PersonaConfigFilesHandle = await materializePersonaConfigFiles( plan.configFiles, { cwd: childCwd } ); handles.push(configHandle); + const sidecarHandle: PersonaSidecarHandle = await writePersonaSidecars( + plan.sidecars, + { cwd: childCwd } + ); + handles.push(sidecarHandle); + let disposed = false; return { cwd: childCwd, diff --git a/packages/persona-kit/src/interactive-spec.test.ts b/packages/persona-kit/src/interactive-spec.test.ts index cf33ddd6..fe09afe1 100644 --- a/packages/persona-kit/src/interactive-spec.test.ts +++ b/packages/persona-kit/src/interactive-spec.test.ts @@ -315,7 +315,7 @@ test('opencode configFiles carries a well-formed opencode.json with the agent de }); }); -test('grok launches the Grok Build CLI with model and no config file', () => { +test('grok launches the Grok Build CLI and writes systemPrompt to AGENTS.md', () => { const result = buildInteractiveSpec({ harness: 'grok', personaId: 'test-persona', @@ -325,32 +325,33 @@ test('grok launches the Grok Build CLI with model and no config file', () => { assert.equal(result.bin, 'grok'); assert.deepEqual(result.args, ['--no-auto-update', '--model', 'grok-build-0.1']); assert.equal(result.initialPrompt, null); - assert.deepEqual(result.configFiles, []); + assert.deepEqual(result.configFiles, [ + { path: 'AGENTS.md', contents: 'you are a test\n' } + ]); assert.deepEqual(result.warnings, []); }); -test('grok wires permission mode and plugin dirs to native flags', () => { +test('grok maps bypassPermissions to always-approve and wires plugin dirs', () => { const result = buildInteractiveSpec({ harness: 'grok', personaId: 'test-persona', model: 'grok-build-0.1', systemPrompt: 'x', - permissions: { mode: 'acceptEdits' }, + permissions: { mode: 'bypassPermissions' }, pluginDirs: ['/tmp/session/grok/plugin'] }); assert.deepEqual(result.args, [ '--no-auto-update', '--model', 'grok-build-0.1', - '--permission-mode', - 'acceptEdits', + '--always-approve', '--plugin-dir', '/tmp/session/grok/plugin' ]); assert.deepEqual(result.warnings, []); }); -test('grok warns for permission allow/deny lists but still accepts mode', () => { +test('grok warns for unsupported permission fields', () => { const result = buildInteractiveSpec({ harness: 'grok', personaId: 'test-persona', @@ -366,11 +367,10 @@ test('grok warns for permission allow/deny lists but still accepts mode', () => '--no-auto-update', '--model', 'grok-build-0.1', - '--permission-mode', - 'acceptEdits' ]); assert.deepEqual(result.warnings, [ - 'persona declares permission allow/deny lists but the grok harness only supports permission mode injection; proceeding without allow/deny rules.' + 'persona declares permission allow/deny lists but the grok harness is not wired for allow/deny injection; proceeding without allow/deny rules.', + 'persona declares permissions.mode "acceptEdits" but the grok harness only supports bypassPermissions via --always-approve; proceeding with Grok defaults.' ]); }); @@ -397,11 +397,13 @@ test('grok non-interactive spec uses single-shot mode with cwd and always-approv '--single', 'Reply pong.\n\nUser task:\nsay pong' ]); - assert.deepEqual(result.configFiles, []); + assert.deepEqual(result.configFiles, [ + { path: 'AGENTS.md', contents: 'Reply pong.\n' } + ]); assert.deepEqual(result.warnings, []); }); -test('grok non-interactive spec does not add always-approve when permission mode is explicit', () => { +test('grok non-interactive spec still adds always-approve for unsupported permission modes', () => { const result = buildNonInteractiveSpec({ harness: 'grok', personaId: 'daily-ship', @@ -415,16 +417,18 @@ test('grok non-interactive spec does not add always-approve when permission mode '--no-auto-update', '--model', 'grok-build-0.1', - '--permission-mode', - 'plan', '--output-format', 'plain', + '--always-approve', '--single', 'say pong' ]); + assert.deepEqual(result.warnings, [ + 'persona declares permissions.mode "plan" but the grok harness only supports bypassPermissions via --always-approve; proceeding with Grok defaults.' + ]); }); -test('claude, codex, and grok emit an empty configFiles array', () => { +test('claude and codex emit an empty configFiles array; grok does so only with an empty systemPrompt', () => { const claude = buildInteractiveSpec({ harness: 'claude', personaId: 'test-persona', @@ -445,7 +449,7 @@ test('claude, codex, and grok emit an empty configFiles array', () => { harness: 'grok', personaId: 'test-persona', model: 'grok-build-0.1', - systemPrompt: 'x' + systemPrompt: '' }); assert.deepEqual(grok.configFiles, []); }); @@ -566,7 +570,7 @@ test('codex and opencode warn and ignore pluginDirs', () => { pluginDirs: ['/tmp/session/plugin'] }); assert.ok(!codex.args.includes('--plugin-dir')); - assert.ok(codex.warnings.some((w) => /pluginDirs is currently claude-only/.test(w))); + assert.ok(codex.warnings.some((w) => /supported only for claude and grok/.test(w))); const opencode = buildInteractiveSpec({ harness: 'opencode', @@ -576,7 +580,7 @@ test('codex and opencode warn and ignore pluginDirs', () => { pluginDirs: ['/tmp/session/plugin'] }); assert.ok(!opencode.args.includes('--plugin-dir')); - assert.ok(opencode.warnings.some((w) => /pluginDirs is currently claude-only/.test(w))); + assert.ok(opencode.warnings.some((w) => /supported only for claude and grok/.test(w))); }); test('warnings are returned, not printed — library consumers route I/O themselves', () => { diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 3309ab79..76a39b4f 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -29,7 +29,7 @@ export interface InteractiveSpec { * to carry the persona's system prompt as the initial user prompt. * Currently only codex takes this path; claude uses `--append-system-prompt`, * opencode writes the prompt into `opencode.json` (see `configFiles`), and - * grok consumes the rendered system prompt only in one-shot mode. + * grok writes the prompt into `AGENTS.md` (see `configFiles`). */ initialPrompt: string | null; /** @@ -41,7 +41,8 @@ export interface InteractiveSpec { * Config files the caller must write (relative to the harness cwd) before * launch. Opencode uses this to materialize an `opencode.json` carrying * the persona's agent definition (model + system prompt) so `--agent` can - * resolve it; claude, codex, and grok return an empty array. + * resolve it. Grok uses this to materialize `AGENTS.md` when `systemPrompt` + * is non-empty. Claude and codex return an empty array. */ configFiles: InteractiveConfigFile[]; /** @@ -212,8 +213,9 @@ function appendCodexMcpServerArgs( * the final positional `[PROMPT]`. * * The grok branch launches the Grok Build CLI (`grok`) with the persona model. - * Grok reads AGENTS.md and .grok/skills from the working tree; one-shot mode - * uses `--single` because Grok has no separate system-prompt flag. + * Grok reads AGENTS.md and .grok/skills from the working tree; interactive + * mode writes the persona system prompt into AGENTS.md when present, and + * one-shot mode uses `--single` because Grok has no separate system-prompt flag. * * The opencode branch routes model + system prompt through opencode's * agent abstraction (see https://opencode.ai/config.json: `agent..{ @@ -231,7 +233,8 @@ function appendCodexMcpServerArgs( * the same declared MCP servers as the persona. * * The opencode/grok branches emit a warning if the persona declares `mcpServers`. - * Grok wires `permissions.mode`; opencode and other Grok permission fields warn. + * Grok maps `permissions.mode: "bypassPermissions"` to `--always-approve`; + * other permission fields warn. */ /** * Build the stdio MCP server spec for relaycast, mirroring the env block the @@ -324,7 +327,7 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact } if (hasPluginDirs) { warnings.push( - 'pluginDirs is currently claude-only; ignoring under the codex harness. Skills must be staged via codex conventions.' + 'pluginDirs is currently supported only for claude and grok; ignoring under the codex harness. Skills must be staged via codex conventions.' ); } const args = ['-m', stripProviderPrefix(model)]; @@ -393,7 +396,7 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact } if (hasPluginDirs) { warnings.push( - 'pluginDirs is currently claude-only; ignoring under the opencode harness. Skills must be staged via opencode conventions.' + 'pluginDirs is currently supported only for claude and grok; ignoring under the opencode harness. Skills must be staged via opencode conventions.' ); } if (hasCodexLaunchSettings(harnessSettings)) { @@ -481,12 +484,16 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact } if (permissions?.allow?.length || permissions?.deny?.length) { warnings.push( - 'persona declares permission allow/deny lists but the grok harness only supports permission mode injection; proceeding without allow/deny rules.' + 'persona declares permission allow/deny lists but the grok harness is not wired for allow/deny injection; proceeding without allow/deny rules.' ); } const args = ['--no-auto-update', '--model', model]; - if (permissions?.mode) { - args.push('--permission-mode', permissions.mode); + if (permissions?.mode === 'bypassPermissions') { + args.push('--always-approve'); + } else if (permissions?.mode && permissions.mode !== 'default') { + warnings.push( + `persona declares permissions.mode "${permissions.mode}" but the grok harness only supports bypassPermissions via --always-approve; proceeding with Grok defaults.` + ); } if (hasPluginDirs) { for (const dir of pluginDirs!) { @@ -495,7 +502,7 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact } if ( harnessSettings?.dangerouslyBypassApprovalsAndSandbox && - !args.includes('--permission-mode') + !args.includes('--always-approve') ) { args.push('--always-approve'); } @@ -504,7 +511,7 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact } if (harnessSettings?.approvalPolicy) { warnings.push( - 'grok harnessSettings.approvalPolicy is not supported; use permissions.mode or dangerouslyBypassApprovalsAndSandbox.' + 'grok harnessSettings.approvalPolicy is not supported; use permissions.mode "bypassPermissions" or dangerouslyBypassApprovalsAndSandbox for --always-approve.' ); } if (harnessSettings?.workspaceWriteNetworkAccess !== undefined) { @@ -518,7 +525,14 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact args, initialPrompt: null, warnings, - configFiles: [], + configFiles: systemPrompt + ? [ + { + path: 'AGENTS.md', + contents: systemPrompt.endsWith('\n') ? systemPrompt : `${systemPrompt}\n` + } + ] + : [], mcpServers }; } @@ -604,7 +618,7 @@ export function buildNonInteractiveSpec( : input.task; const args = [...interactive.args, '--output-format', 'plain']; if (input.workingDirectory) args.push('--cwd', input.workingDirectory); - if (!args.includes('--permission-mode') && !args.includes('--always-approve')) { + if (!args.includes('--always-approve')) { args.push('--always-approve'); } args.push('--single', prompt); diff --git a/packages/persona-kit/src/plan.test.ts b/packages/persona-kit/src/plan.test.ts index 02c16650..a3ed3c5a 100644 --- a/packages/persona-kit/src/plan.test.ts +++ b/packages/persona-kit/src/plan.test.ts @@ -59,6 +59,22 @@ test('buildPersonaSpawnPlan emits configFiles for opencode', () => { ); }); +test('buildPersonaSpawnPlan emits AGENTS.md configFile for grok systemPrompt', () => { + const plan = buildPersonaSpawnPlan( + persona({ + personaId: 'sample', + harness: 'grok', + model: 'grok-build-0.1', + systemPrompt: 'grok prompt' + }), + { processEnv: cleanEnv } + ); + assert.equal(plan.cli, 'grok'); + assert.deepEqual(plan.configFiles, [ + { path: 'AGENTS.md', contents: 'grok prompt\n' } + ]); +}); + test('buildPersonaSpawnPlan resolves sidecars from claudeMdContent / agentsMdContent', () => { const claudePlan = buildPersonaSpawnPlan( persona({ diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 9e7b469b..55e24cea 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -410,9 +410,10 @@ export interface PersonaSpec { mcpServers?: Record; /** * Permission policy (allow/deny lists, mode) for the harness session. - * Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, - * `--permission-mode`). `grok` wires `mode` via `--permission-mode`; - * other fields/harnesses warn and skip. + * `claude` is wired for allow/deny/mode flags (via `--allowedTools`, + * `--disallowedTools`, `--permission-mode`). `grok` maps + * `mode: "bypassPermissions"` to `--always-approve`; other + * fields/harnesses warn and skip. */ permissions?: PersonaPermissions; /** diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts index ce1bee88..14ac14f6 100644 --- a/packages/runtime/src/cloud-defaults.ts +++ b/packages/runtime/src/cloud-defaults.ts @@ -452,12 +452,6 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { const renderedSystemPrompt = renderPersonaInputs(personaSystemPrompt, inputResolution.values); const cwd = resolveWorkspacePath(args.workspaceRoot, run.cwd ?? args.workspaceRoot); await assertDirectory(cwd); - await materializeSidecar({ - persona: args.persona, - inputValues: inputResolution.values, - cwd, - log: args.log - }); const task = run.prompt; const spec = buildNonInteractiveSpec({ harness, @@ -477,6 +471,12 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { for (const file of spec.configFiles) { await writeWorkspaceRelativeFile(cwd, file.path, file.contents); } + await materializeSidecar({ + persona: args.persona, + inputValues: inputResolution.values, + cwd, + log: args.log + }); const startedAt = Date.now(); const childEnv = { ...callerEnv, @@ -551,7 +551,10 @@ function sidecarForPersona( mode: persona.claudeMdMode ?? 'overwrite' }; } - if ((persona.harness === 'codex' || persona.harness === 'opencode') && persona.agentsMdContent) { + if ( + (persona.harness === 'codex' || persona.harness === 'opencode' || persona.harness === 'grok') && + persona.agentsMdContent + ) { return { file: 'AGENTS.md', content: renderPersonaInputs(persona.agentsMdContent, inputValues), diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index ffeafc33..c4c5dacb 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -191,6 +191,67 @@ test('startRunner throws when workspaceId is missing from both options and env', } }); +test('cloud harness runner materializes AGENTS.md for grok personas', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'workforce-grok-cloud-')); + const binDir = path.join(root, 'bin'); + await mkdir(binDir, { recursive: true }); + await writeFile( + path.join(binDir, 'grok'), + [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const agents = fs.readFileSync(path.join(process.cwd(), "AGENTS.md"), "utf8");', + 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), agents }));' + ].join('\n'), + 'utf8' + ); + await chmod(path.join(binDir, 'grok'), 0o755); + + const envSnapshot = snapshotEnv(['PATH', 'WORKFORCE_SANDBOX_ROOT']); + process.env.PATH = `${binDir}${path.delimiter}${envSnapshot.PATH ?? ''}`; + process.env.WORKFORCE_SANDBOX_ROOT = root; + try { + const logs: Array<{ level: string; message: string; details?: unknown }> = []; + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + harness: 'grok', + model: 'grok-build-0.1', + systemPrompt: 'Grok system prompt', + agentsMdContent: 'Grok agents sidecar', + agentsMdMode: 'overwrite' + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: (level, message, details) => logs.push({ level, message, details }), + env: process.env + }); + + const result = await defaults.harnessRunner({ prompt: 'say hello' }); + assert.equal(result.exitCode, 0); + const parsed = JSON.parse(result.output) as { args: string[]; agents: string }; + assert.deepEqual(parsed.args, [ + '--no-auto-update', + '--model', + 'grok-build-0.1', + '--output-format', + 'plain', + '--cwd', + root, + '--always-approve', + '--single', + 'Grok system prompt\n\nUser task:\nsay hello' + ]); + assert.equal(parsed.agents, 'Grok agents sidecar\n'); + assert.ok(logs.find((l) => l.message === 'harness.sidecar.materialized')); + } finally { + restoreEnv(envSnapshot); + await rm(root, { recursive: true, force: true }); + } +}); + async function writeFakeHarness(binDir: string, name: string, stdout: string): Promise { await mkdir(binDir, { recursive: true }); await writeFile(