Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,47 @@ Add to your `openclaw.json`:
| `maxOutputSize` | number | `10485760` | Maximum output size in bytes (10MB default, 0 = unlimited) |
| `notifyWebhookUrl` | string | `http://localhost:18789/hooks/agent` | OpenClaw webhook URL for notifications |
| `hooksToken` | string | `""` | Webhook auth token (must match OpenClaw `hooks.token` to enable notifications) |
| `extraEnvPassthrough` | string[] | `[]` | Extra env var names forwarded to the sandbox. Appended to the built-in list (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`). Values resolved from `process.env` first, then `envFallback`. |
| `envFallback` | object | `{}` | Fallback values for any key listed in `extraEnvPassthrough` when `process.env` has none. Useful when the host app does not inject its config env vars into `process.env`. |
| `extraMounts` | array | `[]` | Extra `-v` mounts appended to every sandbox container. Each entry is `{ hostPath, containerPath, mode? }` (`mode` defaults to `rw`). |
| `hostPathTranslations` | object | `{}` | Prefix rewrites applied to every mount source path before being handed to the container runtime. Required for docker-out-of-docker setups (see below). |

### Docker-out-of-docker / mount path translation

When the plugin runs inside a container that has the host's docker socket bind-mounted (`-v /var/run/docker.sock:/var/run/docker.sock`), every `-v` mount the plugin builds is interpreted by the **host** daemon, not the container-local filesystem. Paths like `/home/user/.claude` or `~/.openclaw/workspaces` visible to the plugin typically don't exist on the host — they're themselves bind-mounts from some other host path.

Set `hostPathTranslations` to a map of prefix rewrites so the plugin hands the host daemon real paths:

```json
{
"hostPathTranslations": {
"/root/.claude": "/var/lib/myapp/bot-01/claude-credentials",
"/root/.openclaw": "/var/lib/myapp/bot-01"
}
}
```

The longest matching key wins. Rewrites apply to `hostClaudeDir`, `workspaceDir`, and every `extraMounts[i].hostPath`.

### Extra mounts & env passthrough example

Pass an SSH key into every session and let the sandbox pick up OAuth + GitHub tokens from the plugin host's config store:

```json
{
"extraMounts": [
{ "hostPath": "/var/lib/myapp/bot-01/ssh-keys", "containerPath": "/home/claude/.ssh", "mode": "rw" }
],
"extraEnvPassthrough": ["CLAUDE_CODE_OAUTH_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
"envFallback": {
"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-...",
"GH_TOKEN": "ghp_...",
"GITHUB_TOKEN": "ghp_..."
}
}
```

`process.env` still wins when both sources have a value, so you can override `envFallback` with a real env var for local development.

## Authentication

Expand Down
19 changes: 18 additions & 1 deletion src/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { execFile } from "node:child_process";
import * as path from "node:path";
import { homedir } from "node:os";
import { SessionManager, type JobState } from "./session-manager.js";
import { PodmanRunner, type ErrorType } from "./podman-runner.js";
import { PodmanRunner, type ErrorType, type ExtraMount } from "./podman-runner.js";
import { notifyJobCompletion, type JobCompletionEvent } from "./notification.js";
import {
parseStreamLine,
Expand Down Expand Up @@ -36,6 +36,19 @@ export interface ClaudeCodePluginConfig {
maxOutputSize: number; // Maximum output size in bytes (0 = unlimited)
notifyWebhookUrl: string; // OpenClaw webhook URL (default: http://localhost:18789/hooks/agent)
hooksToken: string; // Webhook authentication token (from OpenClaw hooks.token)
/** Extra env var names forwarded to the sandbox via `-e`.
* Appended to the built-in list (`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`,
* `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`). Values sourced from
* `process.env` first, then `envFallback`. */
extraEnvPassthrough?: string[];
/** Fallback values for env passthrough keys when `process.env` has none. */
envFallback?: Record<string, string>;
/** Extra `-v` mounts appended to every sandbox container. */
extraMounts?: ExtraMount[];
/** Prefix rewrites applied to every mount source path. Required when
* running the plugin inside a container with a mounted docker/podman
* socket: the runtime daemon needs real host paths. */
hostPathTranslations?: Record<string, string>;
}

/**
Expand Down Expand Up @@ -110,6 +123,10 @@ export default function register(api: PluginApi): void {
network: config.network,
apparmorProfile: config.apparmorProfile,
maxOutputSize: config.maxOutputSize,
extraEnvPassthrough: config.extraEnvPassthrough,
envFallback: config.envFallback,
extraMounts: config.extraMounts,
hostPathTranslations: config.hostPathTranslations,
});

// Helper to check authentication
Expand Down
203 changes: 202 additions & 1 deletion src/podman-runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { PodmanConfig } from "./podman-runner";
import { PodmanRunner } from "./podman-runner";
import { PodmanRunner, translateHostPath } from "./podman-runner";
import type { ChildProcess } from "node:child_process";
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
Expand Down Expand Up @@ -621,6 +621,207 @@ describe("PodmanRunner", () => {

vi.useFakeTimers();
});

it("applies hostPathTranslations to mount source paths", async () => {
vi.useRealTimers();

const translatingRunner = new PodmanRunner({
...config,
hostPathTranslations: {
"/root/.claude": "/host/bot-01/claude-credentials",
"/root/.openclaw": "/host/bot-01",
},
});

const mockKillProc = createMockProcess();
const mockRmProc = createMockProcess();
const mockRunProc = createMockProcess();
mockSpawn
.mockReturnValueOnce(mockKillProc)
.mockReturnValueOnce(mockRmProc)
.mockReturnValueOnce(mockRunProc);

const promise = translatingRunner.startDetached({
sessionKey: "xlate",
prompt: "p",
hostClaudeDir: "/root/.claude",
workspaceDir: "/root/.openclaw/workspace/xlate",
});
mockKillProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
mockRmProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
(mockRunProc.stdout as EventEmitter).emit("data", Buffer.from("cid\n"));
mockRunProc.emit("close", 0);
await promise;

const args = mockSpawn.mock.calls[2][1] as string[];
expect(args).toContain("/host/bot-01/claude-credentials:/home/claude/.claude:rw");
expect(args).toContain("/host/bot-01/workspace/xlate:/workspace:rw");

vi.useFakeTimers();
});

it("appends extraMounts with default and explicit modes", async () => {
vi.useRealTimers();

const withMountsRunner = new PodmanRunner({
...config,
extraMounts: [
{ hostPath: "/host/ssh-keys", containerPath: "/home/claude/.ssh" },
{ hostPath: "/host/gpg", containerPath: "/home/claude/.gnupg", mode: "ro" },
],
});

const mockKillProc = createMockProcess();
const mockRmProc = createMockProcess();
const mockRunProc = createMockProcess();
mockSpawn
.mockReturnValueOnce(mockKillProc)
.mockReturnValueOnce(mockRmProc)
.mockReturnValueOnce(mockRunProc);

const promise = withMountsRunner.startDetached({
sessionKey: "em",
prompt: "p",
hostClaudeDir: "/path/.claude",
workspaceDir: "/path/workspace",
});
mockKillProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
mockRmProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
(mockRunProc.stdout as EventEmitter).emit("data", Buffer.from("cid\n"));
mockRunProc.emit("close", 0);
await promise;

const args = mockSpawn.mock.calls[2][1] as string[];
expect(args).toContain("/host/ssh-keys:/home/claude/.ssh:rw");
expect(args).toContain("/host/gpg:/home/claude/.gnupg:ro");

vi.useFakeTimers();
});

it("passes through extraEnvPassthrough keys from envFallback when process.env is empty", async () => {
vi.useRealTimers();
const saved = process.env.CLAUDE_CODE_OAUTH_TOKEN;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;

const withEnvRunner = new PodmanRunner({
...config,
extraEnvPassthrough: ["CLAUDE_CODE_OAUTH_TOKEN", "GH_TOKEN"],
envFallback: {
CLAUDE_CODE_OAUTH_TOKEN: "oat-from-config",
GH_TOKEN: "ghp-from-config",
},
});

const mockKillProc = createMockProcess();
const mockRmProc = createMockProcess();
const mockRunProc = createMockProcess();
mockSpawn
.mockReturnValueOnce(mockKillProc)
.mockReturnValueOnce(mockRmProc)
.mockReturnValueOnce(mockRunProc);

const promise = withEnvRunner.startDetached({
sessionKey: "envfb",
prompt: "p",
hostClaudeDir: "/path/.claude",
workspaceDir: "/path/workspace",
});
mockKillProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
mockRmProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
(mockRunProc.stdout as EventEmitter).emit("data", Buffer.from("cid\n"));
mockRunProc.emit("close", 0);
await promise;

const args = mockSpawn.mock.calls[2][1] as string[];
expect(args).toContain("CLAUDE_CODE_OAUTH_TOKEN=oat-from-config");
expect(args).toContain("GH_TOKEN=ghp-from-config");

if (saved !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = saved;
vi.useFakeTimers();
});

it("process.env wins over envFallback when both present", async () => {
vi.useRealTimers();
process.env.GH_TOKEN = "ghp-from-env";

const withEnvRunner = new PodmanRunner({
...config,
extraEnvPassthrough: ["GH_TOKEN"],
envFallback: { GH_TOKEN: "ghp-from-config" },
});

const mockKillProc = createMockProcess();
const mockRmProc = createMockProcess();
const mockRunProc = createMockProcess();
mockSpawn
.mockReturnValueOnce(mockKillProc)
.mockReturnValueOnce(mockRmProc)
.mockReturnValueOnce(mockRunProc);

const promise = withEnvRunner.startDetached({
sessionKey: "envwin",
prompt: "p",
hostClaudeDir: "/path/.claude",
workspaceDir: "/path/workspace",
});
mockKillProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
mockRmProc.emit("close", 0);
await new Promise((r) => setImmediate(r));
(mockRunProc.stdout as EventEmitter).emit("data", Buffer.from("cid\n"));
mockRunProc.emit("close", 0);
await promise;

const args = mockSpawn.mock.calls[2][1] as string[];
expect(args).toContain("GH_TOKEN=ghp-from-env");
expect(args).not.toContain("GH_TOKEN=ghp-from-config");

delete process.env.GH_TOKEN;
vi.useFakeTimers();
});
});

describe("translateHostPath", () => {
it("returns the path unchanged when translations is undefined", () => {
expect(translateHostPath("/root/.claude", undefined)).toBe("/root/.claude");
});

it("returns the path unchanged when no prefix matches", () => {
expect(translateHostPath("/var/lib/foo", { "/root": "/host" })).toBe("/var/lib/foo");
});

it("rewrites an exact prefix match", () => {
expect(translateHostPath("/root/.claude", { "/root/.claude": "/host/claude" })).toBe(
"/host/claude"
);
});

it("rewrites a prefix with a suffix", () => {
expect(
translateHostPath("/root/.openclaw/workspace/proj", {
"/root/.openclaw": "/host/bot/data",
})
).toBe("/host/bot/data/workspace/proj");
});

it("prefers the longest matching prefix", () => {
const tr = {
"/root": "/host/generic",
"/root/.claude": "/host/specific",
};
expect(translateHostPath("/root/.claude/creds", tr)).toBe("/host/specific/creds");
expect(translateHostPath("/root/.other", tr)).toBe("/host/generic/.other");
});

it("does not rewrite a path that only shares a partial non-boundary prefix", () => {
expect(translateHostPath("/rootless", { "/root": "/host" })).toBe("/rootless");
});
});

describe("getContainerStatus", () => {
Expand Down
Loading