diff --git a/Dockerfile b/Dockerfile index 57c0cd9..c0d3338 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ ARG OPENCODE_VERSION=2.1.3 ARG OPENCHAMBER_VERSION=2.5.0 ARG TYPESCRIPT_VERSION=7.0.2 ARG PNPM_VERSION=12.15.0 +ARG CLAUDE_CODE_VERSION=2.1.309 ARG TARGETARCH ENV PNPM_HOME=/opt/pnpm ENV PATH=${PNPM_HOME}/bin:${PATH} @@ -42,10 +43,12 @@ RUN case "$TARGETARCH" in amd64) arch=x64 ;; arm64) arch=arm64 ;; *) exit 1 ;; e "https://registry.npmjs.org/@pnpm/exe.linux-${arch}/-/exe.linux-${arch}-${PNPM_VERSION}.tgz" \ | tar -xz --strip-components=1 -C /opt/pnpm-cli \ && ln -s /opt/pnpm-cli/pnpm /usr/local/bin/pnpm \ - && XDG_CONFIG_HOME=/opt/pnpm-config pnpm --allow-build=@opencode/cli --allow-build=node-pty add --global \ + && XDG_CONFIG_HOME=/opt/pnpm-config pnpm --allow-build=@opencode/cli --allow-build=node-pty --allow-build=@anthropic-ai/claude-code add --global \ "@opencode/cli@${OPENCODE_VERSION}" "@openchamber/web@${OPENCHAMBER_VERSION}" "typescript@${TYPESCRIPT_VERSION}" \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ && opencode --version \ - && openchamber --version + && openchamber --version \ + && claude --version FROM tools AS config WORKDIR /build @@ -66,11 +69,13 @@ ENV HOME=/data \ XDG_CACHE_HOME=/data/.cache \ OPENCODE_CONFIG=/etc/opencode/opencode.json \ OPENCODE_CLIENT=app \ + DISABLE_AUTOUPDATER=1 \ OPENCHAMBER_CHATS_DIR=/data/workspace/.chats COPY --from=config /build/runtime.json /etc/opencode/opencode.json COPY AGENTS.md /etc/opencode/AGENTS.md COPY config/quota-toast.jsonc /etc/opencode/quota-toast.jsonc +COPY config/openchamber-policy.json /etc/openchamber/policy.json COPY skills /etc/opencode/skills COPY --chmod=755 entrypoint.sh healthcheck.sh openchamber-healthcheck.sh /usr/local/bin/ RUN mkdir -p /data/workspace /data/.config/opencode "$PNPM_HOME" \ diff --git a/README.md b/README.md index cd26c6d..a49d296 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,14 @@ docker compose exec opencode opencode auth login github-copilot --server http:// docker compose exec opencode opencode auth login google --server http://127.0.0.1:4096 docker compose exec opencode opencode auth login anthropic --method key --server http://127.0.0.1:4096 docker compose exec opencode opencode auth login openai --server http://127.0.0.1:4096 +docker compose exec opencode claude auth login --claudeai ``` - **GitHub Copilot:** complete device login with a subscribed account. - **Gemini:** choose the API-key method or set `GOOGLE_GENERATIVE_AI_API_KEY`. Organization-backed Code Assist OAuth also needs `OPENCODE_GEMINI_PROJECT_ID`; see the [Gemini plugin's account requirements](https://github.com/jenslys/opencode-gemini-auth). -- **Anthropic:** enter a [Console API key](https://console.anthropic.com/settings/keys) or set `ANTHROPIC_API_KEY`. Claude Pro and Max subscriptions do not work outside Claude Code. +- **Anthropic:** enter a [Console API key](https://console.anthropic.com/settings/keys) or set `ANTHROPIC_API_KEY`. For a Claude Pro or Max plan, use the Claude Code provider below instead. - **OpenAI:** choose the API-key method with a [platform key](https://platform.openai.com/api-keys) or set `OPENAI_API_KEY`. +- **Claude Code:** sign in with `claude auth login --claudeai` to use a Claude Pro or Max plan through [opencode-claude](https://github.com/openchamber/opencode-claude); the login persists in `/data/.claude`. Plan usage only, not Console API keys. - **Git access:** set `GH_TOKEN` or run `docker compose exec opencode gh auth login --hostname github.com --git-protocol https --web`. This is separate from Copilot login; stored credentials persist in `/data/.config/gh`. - **WakaTime:** put your API key in `/data/.wakatime.cfg`, owned by `1000:1000` with mode `600`, using the [plugin's configuration format](https://github.com/angristan/opencode-wakatime#prerequisites). @@ -66,6 +68,7 @@ Set the variables below before creating the container. For remote servers, open | Integration | Setup | | --- | --- | | [Context7](https://mcp.context7.com/mcp) | Remote library documentation. | +| [Gmail](https://developers.google.com/workspace/gmail/api/guides/configure-mcp-server) | Remote server at `https://gmailmcp.googleapis.com/mcp/v1` (Developer Preview). Create a Web application OAuth client with redirect URI `http://127.0.0.1:19876/callback` and set `GMAIL_MCP_CLIENT_ID` and `GMAIL_MCP_CLIENT_SECRET`; read and draft only, no send. The callback listener is on the container loopback, so after consent copy the failed `127.0.0.1:19876` URL from the browser and run `docker compose exec opencode curl -fsS ""`. | | [Grafana](https://github.com/grafana/mcp-grafana) | `GRAFANA_URL` and `GRAFANA_SERVICE_ACCOUNT_TOKEN`; bundled tools are read-only. | | [Karakeep](https://docs.karakeep.app/integrations/mcp) | `KARAKEEP_API_KEY` and `KARAKEEP_API_ADDR` (defaults to `https://hoard.natwelch.com`); includes write tools. | | [Lunch Money](https://github.com/akutishevsky/lunchmoney-mcp) | `LUNCHMONEY_API_TOKEN` from [developer settings](https://my.lunchmoney.app/developers); required to start its MCP server. | @@ -82,7 +85,7 @@ Grafana and Karakeep can connect before their credentials are validated; check a ## Agents and configuration -- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands whose text contains `.config`, `.local`, `HOME`, `~`, or `/data/.`, or ends in `/data`. Tool permissions do not sandbox shell access. +- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Gmail, Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands whose text contains `.config`, `.local`, `HOME`, `~`, or `/data/.`, or ends in `/data`. Tool permissions do not sandbox shell access. - [Superpowers](https://github.com/obra/superpowers) supplies planning, debugging, testing, and review skills. The image also ships [`commit-push-pr` and `go-repo`](skills/). [`AGENTS.md`](AGENTS.md) requires repository edits in `/data/workspace/.worktrees//`. - [OpenCode Quota](https://github.com/slkiser/opencode-quota) provides `/quota` and `/quota_status`. - Defaults and pinned plugins live in [`opencode.jsonc`](opencode.jsonc). The image converts it to `/etc/opencode/opencode.json`, which overrides matching settings in global and project `opencode.json(c)` files. See the [V2 configuration guide](https://opencode.ai/v2/docs/config). @@ -103,7 +106,7 @@ code.example.com { } ``` -`/health` is unauthenticated and reports diagnostics, so keep it off the internet. OpenChamber runs in [enterprise mode](https://docs.openchamber.dev/security/#enterprise-mode), which disables the relay and untrusted extensions. Login is rate limited; add a passkey under Settings after first sign-in. +`/health` is unauthenticated and reports diagnostics, so keep it off the internet. OpenChamber runs in [enterprise mode](https://docs.openchamber.dev/security/#enterprise-mode), which disables the relay and untrusted extensions. [Resource Monitor](https://github.com/openchamber/openchamber-resource-monitor) is allowed in `/etc/openchamber/policy.json`; install it once from Settings, Extensions with that URL. Login is rate limited; add a passkey under Settings after first sign-in. The container runs as UID 1000 with dropped capabilities and resource limits. Login grants code execution and access to stored credentials; this is a trusted-user service, not a multi-tenant sandbox. diff --git a/compose.yml b/compose.yml index 944057a..bc2c302 100644 --- a/compose.yml +++ b/compose.yml @@ -19,6 +19,8 @@ services: GRAFANA_SERVICE_ACCOUNT_TOKEN: ${GRAFANA_SERVICE_ACCOUNT_TOKEN:-} KARAKEEP_API_ADDR: ${KARAKEEP_API_ADDR:-} KARAKEEP_API_KEY: ${KARAKEEP_API_KEY:-} + GMAIL_MCP_CLIENT_ID: ${GMAIL_MCP_CLIENT_ID:-} + GMAIL_MCP_CLIENT_SECRET: ${GMAIL_MCP_CLIENT_SECRET:-} OPENCODE_PASSWORD: ${OPENCODE_PASSWORD:?Set OPENCODE_PASSWORD} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} diff --git a/config/openchamber-policy.json b/config/openchamber-policy.json new file mode 100644 index 0000000..361f3a5 --- /dev/null +++ b/config/openchamber-policy.json @@ -0,0 +1,5 @@ +{ + "allowedExtensions": [ + "https://github.com/openchamber/openchamber-resource-monitor" + ] +} diff --git a/opencode.jsonc b/opencode.jsonc index 2e8b28a..c93c39c 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -28,6 +28,7 @@ { "action": "grafana_*", "resource": "*", "effect": "deny" }, { "action": "lunchmoney_*", "resource": "*", "effect": "deny" }, { "action": "karakeep_*", "resource": "*", "effect": "deny" }, + { "action": "gmail_*", "resource": "*", "effect": "deny" }, { "action": "notion_*", "resource": "*", "effect": "deny" }, { "action": "robinhood_*", "resource": "*", "effect": "deny" }, { "action": "subagent", "resource": "*", "effect": "deny" }, @@ -50,6 +51,7 @@ { "action": "grafana_*", "resource": "*", "effect": "deny" }, { "action": "lunchmoney_*", "resource": "*", "effect": "deny" }, { "action": "karakeep_*", "resource": "*", "effect": "deny" }, + { "action": "gmail_*", "resource": "*", "effect": "deny" }, { "action": "notion_*", "resource": "*", "effect": "deny" }, { "action": "robinhood_*", "resource": "*", "effect": "deny" }, { "action": "subagent", "resource": "*", "effect": "deny" }, @@ -71,6 +73,16 @@ "type": "remote", "url": "https://mcp.notion.com/mcp" }, + "gmail": { + "type": "remote", + "url": "https://gmailmcp.googleapis.com/mcp/v1", + "oauth": { + "client_id": "{env:GMAIL_MCP_CLIENT_ID}", + "client_secret": "{env:GMAIL_MCP_CLIENT_SECRET}", + "redirect_uri": "http://127.0.0.1:19876/callback", + "scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose" + } + }, "robinhood": { "type": "remote", "url": "https://agent.robinhood.com/mcp/trading" @@ -127,6 +139,7 @@ "opencode-wakatime@1.4.0", "superpowers@git+https://github.com/obra/superpowers.git#2ce8aa15d05bd57213e6f746f953aafb7ded7a8b", "@slkiser/opencode-quota@5.0.3", + "@openchamber/opencode-claude@1.5.0", { "package": "opencode-runtime-fallback@0.3.0", "options": { @@ -173,6 +186,11 @@ "action": "provider.use", "resource": "openai", "effect": "allow" + }, + { + "action": "provider.use", + "resource": "claude-code", + "effect": "allow" } ] } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e59990b..6561d40 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,14 @@ minimumReleaseAgeStrict: true minimumReleaseAgeExclude: + - '@anthropic-ai/claude-code-darwin-arm64@2.1.309' + - '@anthropic-ai/claude-code-darwin-x64@2.1.309' + - '@anthropic-ai/claude-code-linux-arm64-musl@2.1.309' + - '@anthropic-ai/claude-code-linux-arm64@2.1.309' + - '@anthropic-ai/claude-code-linux-x64-musl@2.1.309' + - '@anthropic-ai/claude-code-linux-x64@2.1.309' + - '@anthropic-ai/claude-code-win32-arm64@2.1.309' + - '@anthropic-ai/claude-code-win32-x64@2.1.309' + - '@anthropic-ai/claude-code@2.1.309' - '@openchamber/sdk@2.5.0' - '@openchamber/web@2.5.0' - '@opencode/ai@2.1.3' diff --git a/scripts/test-router-integration.mjs b/scripts/test-router-integration.mjs index 8f6cf5c..d2fba26 100644 --- a/scripts/test-router-integration.mjs +++ b/scripts/test-router-integration.mjs @@ -135,13 +135,13 @@ try { for (const name of external ? ["lunchmoney", "grafana", "karakeep"] : ["lunchmoney", "karakeep"]) { assert.equal(data.find(server => server.name === name)?.status.status, "connected", JSON.stringify(data)); } - for (const name of ["context7", "notion"]) + for (const name of ["context7", "gmail", "notion"]) assert.ok(data.some(server => server.name === name), `missing ${name}`); return true; }); // Without credentials, a dropped provider policy shows up as OpenCode's free models. const models = (await request("/api/model")).data; - assert.ok(models.every(m => ["google", "github-copilot", "anthropic", "openai"].includes(m.providerID)), `policies hide other providers' models: ${models.map(m => m.providerID)}`); + assert.ok(models.every(m => ["google", "github-copilot", "anthropic", "openai", "claude-code"].includes(m.providerID)), `policies hide other providers' models: ${models.map(m => m.providerID)}`); session = (await request("/api/session", { title: "V2 integration test", agent: "build", location: { directory } })).data; await request(`/api/session/${session.id}/command`, { name: "quota_announcements", text: "" }); diff --git a/tests/routing.test.mjs b/tests/routing.test.mjs index 6e0f9fb..6238c94 100644 --- a/tests/routing.test.mjs +++ b/tests/routing.test.mjs @@ -40,10 +40,10 @@ test("image-owned skills are discoverable", async () => { }); test("existing providers, MCP integrations and quota are preserved", () => { - assert.deepEqual(Object.keys(config.mcp.servers).sort(), ["context7", "grafana", "karakeep", "lunchmoney", "notion", "robinhood"]); + assert.deepEqual(Object.keys(config.mcp.servers).sort(), ["context7", "gmail", "grafana", "karakeep", "lunchmoney", "notion", "robinhood"]); assert.ok(config.plugins.includes("@slkiser/opencode-quota@5.0.3")); const allowed = config.policies.filter(p => p.action === "provider.use" && p.effect === "allow").map(p => p.resource); - assert.deepEqual(allowed.sort(), ["anthropic", "github-copilot", "google", "openai"]); + assert.deepEqual(allowed.sort(), ["anthropic", "claude-code", "github-copilot", "google", "openai"]); assert.ok(!config.permissions?.some(rule => rule.action === "*" && rule.effect === "allow")); assert.deepEqual(config.agents.build.permissions[0], { action: "*", resource: "*", effect: "allow" }); for (const name of ["general", "explore"])