Skip to content
Merged
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
2 changes: 2 additions & 0 deletions app/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ docker compose up --build # http://localhost:4000
| `CG_SESSION_SECRET` | app runtime | unset (= GitHub sign-in disabled) | Encrypts the GitHub session cookie (AES-256-GCM). Required alongside the two vars above for GitHub sign-in to activate. Any long random string; rotating it signs everyone out. |
| `CG_OWNER_GITHUB_LOGIN` | app runtime | unset (= no owner-lock) | Restrict the **entire app** — every page and API route, including the normally-anonymous public bucket — to one or more GitHub logins (comma-separated, case-insensitive). Requires GitHub sign-in to be fully configured too (fails closed with a 500 otherwise). See below. |
| `ANTHROPIC_API_KEY` | app runtime | unset (= Claude AI Assistant hidden) | Enables the Editor tab's AI Assistant using Claude (Claude Agent SDK) — see below. Core product features never need this. |
| `CLAUDE_CODE_OAUTH_TOKEN` | app runtime | unset | Alternative to `ANTHROPIC_API_KEY`: authenticates Claude via a Pro/Max/Team subscription login instead of pay-per-token API billing. The only way subscription mode can work on a hosted/container deployment (no persistent home directory, no interactive terminal for `claude login`) — see below. |
| `CG_LOCAL_LLM_BASE_URL` | app runtime | unset (= local-model AI Assistant hidden) | Base URL of an OpenAI-compatible chat-completions endpoint (Ollama, LM Studio, llama.cpp `server`, vLLM, …), e.g. `http://localhost:11434/v1`. Required alongside `CG_LOCAL_LLM_MODEL` — see below. |
| `CG_LOCAL_LLM_MODEL` | app runtime | unset | Model name/tag to request from that endpoint, e.g. `qwen2.5-coder:7b`. Required alongside `CG_LOCAL_LLM_BASE_URL`. |
| `CG_LOCAL_LLM_API_KEY` | app runtime | unset (sends `local`) | Optional bearer token if your local server's endpoint requires one; most (Ollama, LM Studio) don't. |
Expand Down Expand Up @@ -93,6 +94,7 @@ Runs in-process against the repo's live workspace directory.

1. Set `ANTHROPIC_API_KEY=<your key>` on the deployment (and/or in a local `.env.local` for dev). Restart/redeploy.
2. `npm install` already pulls the correct platform binary via `@anthropic-ai/claude-agent-sdk`'s `optionalDependencies` (Linux glibc/musl x64+arm64, macOS, Windows), so this works in the Docker image with no extra install step.
3. **Subscription mode (`CLAUDE_CODE_OAUTH_TOKEN` / "Use my Claude subscription" toggle) requires real evidence of a working login on this exact server process** — either `CLAUDE_CODE_OAUTH_TOKEN` set as an env var, or a `~/.claude/.credentials.json` file from having run `claude login` on that same host. On Render (and most container hosts), the home directory is NOT on the persistent disk and there's no interactive terminal to run `claude login` in the first place, so a credentials file can never exist there — `CLAUDE_CODE_OAUTH_TOKEN` is the only way subscription mode can work on this deployment model. CodeGraph checks for this evidence server-side (`claudeSubscriptionCredentialsAvailable()` in `lib/settings.ts`) before ever offering Claude via the subscription path — toggling the Settings checkbox alone, with no real credentials, correctly leaves the Claude tab unavailable instead of producing a broken chat session ("Not logged in — Please run /login", followed by "/login isn't available in this environment", since the SDK session is headless and can't prompt interactively).

### Local model (any OpenAI-compatible server)
Talks over plain HTTP to a model server running on your own hardware — no data leaves the machine running CodeGraph. Tested against the OpenAI-compatible `/v1/chat/completions` shape that Ollama, LM Studio, llama.cpp's `server`, vLLM, and text-generation-webui all implement; tool-calling quality (and therefore how well the assistant can actually edit files) depends entirely on the model you pick — recent tool-calling-tuned models (e.g. Qwen2.5-Coder, Llama 3.1+) work noticeably better than older/small ones.
Expand Down
8 changes: 8 additions & 0 deletions app/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,14 @@ export default function SettingsPage() {
Uses your subscription&apos;s included usage instead of per-token API billing.
An API Key above, if set, always takes priority over this.
</p>
{useSubscription && settings && !settings.claudeSubscriptionUsable && (
<p className="text-xs text-amber-400 mt-1.5 ml-6">
⚠ This server has no usable Claude Code login right now (no <code className="text-amber-300">CLAUDE_CODE_OAUTH_TOKEN</code>{" "}
and no local <code className="text-amber-300">claude login</code> session) — starting a chat with this toggle on and no
API Key set above will fail. Set an Anthropic API Key above instead, or ask the site operator to configure{" "}
<code className="text-amber-300">CLAUDE_CODE_OAUTH_TOKEN</code>.
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Expand Down
34 changes: 30 additions & 4 deletions app/src/lib/agents/assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ import { createSdkMcpServer, query, tool, type Options, type Query, type SdkMcpT
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { buildGitToolImpls, buildWorkspaceToolImpls } from "./workspaceToolImpls";
import { effectiveAnthropicApiKey, effectiveClaudeModel, effectiveUseClaudeSubscription, ANONYMOUS_USER_ID } from "../settings";
import { effectiveAnthropicApiKey, effectiveClaudeModel, effectiveUseClaudeSubscription, claudeSubscriptionCredentialsAvailable, ANONYMOUS_USER_ID } from "../settings";
import type { AssistantEvent } from "../types";

// Requires actual evidence the subscription path can work (a real
// CLAUDE_CODE_OAUTH_TOKEN or `claude login` credentials file), not just
// that the user checked "use my subscription" -- see
// `claudeSubscriptionCredentialsAvailable()` in settings.ts. Otherwise the
// Claude tab would silently appear "configured" for a session that can
// never actually authenticate, and the very first turn would fail deep
// inside the CLI ("Not logged in - Please run /login", then "/login isn't
// available in this environment" since this is a headless SDK session).
export function aiAssistantConfigured(userId: number = ANONYMOUS_USER_ID): boolean {
return !!effectiveAnthropicApiKey(userId) || effectiveUseClaudeSubscription(userId);
return !!effectiveAnthropicApiKey(userId) || (effectiveUseClaudeSubscription(userId) && claudeSubscriptionCredentialsAvailable());
}

const SYSTEM_PROMPT = (hasGit: boolean) =>
Expand Down Expand Up @@ -212,7 +220,7 @@ function sessionKey(repoId: string, userId: number): string {
function getOrCreateSession(repoId: string, workspaceDir: string, hasGit: boolean, userId: number): AssistantSession {
const key = sessionKey(repoId, userId);
const apiKey = effectiveAnthropicApiKey(userId) ?? "";
const useSubscription = !apiKey && effectiveUseClaudeSubscription(userId);
const useSubscription = !apiKey && effectiveUseClaudeSubscription(userId) && claudeSubscriptionCredentialsAvailable();
const model = effectiveClaudeModel(userId);
const existing = sessions.get(key);
if (
Expand Down Expand Up @@ -333,7 +341,25 @@ export async function* sendMessage(
if (msg.type === "assistant") {
const blocks = (msg.message.content ?? []) as Array<{ type: string; text?: string }>;
for (const block of blocks) {
if (block.type === "text" && block.text) yield { kind: "text", text: block.text };
if (block.type !== "text" || !block.text) continue;
// Defense-in-depth against a credential going bad mid-runtime
// (e.g. a CLAUDE_CODE_OAUTH_TOKEN revoked after this process
// started, or a `claude login` file removed): the CLI's own
// onboarding/auth-failure banners ("Not logged in", "/login
// isn't available in this environment") come back as ordinary
// assistant text blocks, not thrown errors -- without this they'd
// render as if Claude actually replied that to the user's
// message. `aiAssistantConfigured()`'s credential gate prevents
// this in the normal case; this catches the rest.
if (/^Not logged in\b/.test(block.text) || /isn't available in this environment\.?$/.test(block.text)) {
sessions.delete(key);
yield {
kind: "error",
message: "This server's Claude subscription login isn't available right now. Set an Anthropic API key in Settings instead.",
};
return;
}
yield { kind: "text", text: block.text };
}
} else if (msg.type === "result") {
if (msg.subtype === "success") {
Expand Down
34 changes: 34 additions & 0 deletions app/src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
// env and still let a user override it (or vice versa: leave env unset and
// configure everything from the UI on a self-hosted instance). Secrets are
// never echoed back in full over the API — only a masked preview.
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { db } from "./db";

/** Sentinel `user_id` for "no account" — self-hosted/no sign-in, or an
Expand Down Expand Up @@ -190,6 +193,30 @@ export function effectiveUseClaudeSubscription(userId: number = ANONYMOUS_USER_I
return getAssistantSettings(userId).useClaudeSubscription === "true" || process.env.CG_CLAUDE_USE_SUBSCRIPTION === "true";
}

// The Claude Code CLI persists a subscription login to `~/.claude/.credentials.json`
// (verified by reading the strings in the actual bundled `claude` binary --
// `@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude`), or accepts a
// long-lived `CLAUDE_CODE_OAUTH_TOKEN` env var. On this app's Docker
// deployment (Render), the home directory is NOT on the persistent
// `CG_DATA_DIR` volume and there is no interactive TTY to ever run
// `claude login` in the first place -- so a subscription login can only
// ever exist here via `CLAUDE_CODE_OAUTH_TOKEN`. Self-hosted operators with
// real shell access to the host running CodeGraph can still get a
// persistent login via the credentials file.
const CLAUDE_CREDENTIALS_PATH = path.join(homedir(), ".claude", ".credentials.json");

/** Whether this exact server process has ANY real evidence a Claude
* subscription login would actually work -- not just that a user checked
* the "use my subscription" box. Toggling the box alone previously let
* `aiAssistantConfigured()` report Claude as available even when the
* server has never been authenticated, so the very first chat turn failed
* deep inside the CLI with "Not logged in - Please run /login", followed
* by "/login isn't available in this environment" (this SDK session is
* headless/non-interactive, so the CLI can't even prompt for it). */
export function claudeSubscriptionCredentialsAvailable(): boolean {
return !!process.env.CLAUDE_CODE_OAUTH_TOKEN || existsSync(CLAUDE_CREDENTIALS_PATH);
}

export interface EffectiveLocalLlmConfig {
baseUrl: string;
model: string;
Expand Down Expand Up @@ -218,6 +245,12 @@ export interface AssistantSettingsView {
claudeModel: string | null;
claudeModelSavedInDb: boolean;
useClaudeSubscription: boolean;
/** True only when this server process has actual evidence a subscription
* login would work (CLAUDE_CODE_OAUTH_TOKEN set, or a local `claude
* login` credentials file) -- independent of whether the user has
* toggled the checkbox on. Lets the Settings page warn when the toggle
* is checked but will not actually work on this deployment. */
claudeSubscriptionUsable: boolean;
localBaseUrl: string | null;
localModel: string | null;
localModelList: string[];
Expand All @@ -238,6 +271,7 @@ export function viewAssistantSettings(userId: number = ANONYMOUS_USER_ID): Assis
claudeModel: s.claudeModel || process.env.CG_CLAUDE_MODEL || null,
claudeModelSavedInDb: !!s.claudeModel,
useClaudeSubscription: effectiveUseClaudeSubscription(userId),
claudeSubscriptionUsable: claudeSubscriptionCredentialsAvailable(),
localBaseUrl: s.localBaseUrl || process.env.CG_LOCAL_LLM_BASE_URL || null,
localModel: s.localModel || process.env.CG_LOCAL_LLM_MODEL || null,
localModelList: effectiveLocalModelList(userId),
Expand Down
31 changes: 29 additions & 2 deletions app/tests/assistant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,21 @@ describe("aiAssistantConfigured", () => {
expect(aiAssistantConfigured()).toBe(true);
});

it("is true with no API key when CG_CLAUDE_USE_SUBSCRIPTION=true (Claude Pro/Max login)", () => {
it("is still false with CG_CLAUDE_USE_SUBSCRIPTION=true alone -- toggling the box is not evidence the server can actually authenticate (this is the exact bug that produced 'Not logged in - Please run /login')", () => {
delete process.env.ANTHROPIC_API_KEY;
process.env.CG_CLAUDE_USE_SUBSCRIPTION = "true";
expect(aiAssistantConfigured()).toBe(true);
expect(aiAssistantConfigured()).toBe(false);
});

it("is true with CG_CLAUDE_USE_SUBSCRIPTION=true once CLAUDE_CODE_OAUTH_TOKEN gives real evidence of a usable login", () => {
delete process.env.ANTHROPIC_API_KEY;
process.env.CG_CLAUDE_USE_SUBSCRIPTION = "true";
process.env.CLAUDE_CODE_OAUTH_TOKEN = "test-oauth-token";
try {
expect(aiAssistantConfigured()).toBe(true);
} finally {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
}
});
});

Expand Down Expand Up @@ -157,3 +168,19 @@ describe("assistant.ts session Options — root-container permission mode", () =
expect(src).toContain('behavior: "allow"');
});
});

// Regression guard for the "Not logged in" defense-in-depth net in
// sendMessage: a CLAUDE_CODE_OAUTH_TOKEN present at process start could
// still be revoked mid-runtime, or a `claude login` credentials file could
// be removed out from under a long-lived server. Without this detection,
// the CLI's own auth-failure onboarding text ("Not logged in - Please run
// /login", "/login isn't available in this environment") comes back as an
// ordinary assistant text block and renders as if Claude itself said it.
describe("assistant.ts sendMessage — CLI auth-failure text detection", () => {
it("still detects and redirects the exact known CLI onboarding strings to a clear error", () => {
const src = readFileSync(path.join(__dirname, "..", "src", "lib", "agents", "assistant.ts"), "utf8");
expect(src).toContain("Not logged in");
expect(src).toContain("isn't available in this environment");
expect(src).toContain("This server's Claude subscription login isn't available right now");
});
});
60 changes: 60 additions & 0 deletions app/tests/subscriptionCredentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Regression tests for the "Not logged in - Please run /login" bug: toggling
// "Use my Claude subscription" alone used to be treated as sufficient
// evidence Claude was configured, even when this server process had never
// actually authenticated (no CLAUDE_CODE_OAUTH_TOKEN, no local `claude
// login` credentials file). The very first chat turn then failed deep
// inside the headless CLI subprocess, with no clear explanation.
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

process.env.CG_SESSION_SECRET = process.env.CG_SESSION_SECRET || "test-secret-for-subscription-credentials";

import { claudeSubscriptionCredentialsAvailable, effectiveUseClaudeSubscription } from "@/lib/settings";

describe("claudeSubscriptionCredentialsAvailable", () => {
const originalToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
afterEach(() => {
if (originalToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalToken;
});

it("is true when CLAUDE_CODE_OAUTH_TOKEN is set, regardless of any credentials file", () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = "a-real-looking-token";
expect(claudeSubscriptionCredentialsAvailable()).toBe(true);
});

it("is false when CLAUDE_CODE_OAUTH_TOKEN is unset and no ~/.claude/.credentials.json exists (this test environment's real state)", () => {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
// Not mocking homedir() here deliberately: this proves the real check
// against this actual CI/sandbox environment, which has no `claude
// login` session -- exactly the state a fresh Render deploy is in.
expect(claudeSubscriptionCredentialsAvailable()).toBe(false);
});
});

describe("effectiveUseClaudeSubscription vs claudeSubscriptionCredentialsAvailable — the fix's core distinction", () => {
const originalToggle = process.env.CG_CLAUDE_USE_SUBSCRIPTION;
const originalToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
afterEach(() => {
if (originalToggle === undefined) delete process.env.CG_CLAUDE_USE_SUBSCRIPTION;
else process.env.CG_CLAUDE_USE_SUBSCRIPTION = originalToggle;
if (originalToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalToken;
});

it("the toggle being on is NOT, by itself, evidence credentials exist -- these are two independent facts", () => {
process.env.CG_CLAUDE_USE_SUBSCRIPTION = "true";
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
expect(effectiveUseClaudeSubscription()).toBe(true); // user's preference: on
expect(claudeSubscriptionCredentialsAvailable()).toBe(false); // but unusable on this server
});

it("credentials existing does NOT mean the toggle is on -- the user still controls whether it's used", () => {
delete process.env.CG_CLAUDE_USE_SUBSCRIPTION;
process.env.CLAUDE_CODE_OAUTH_TOKEN = "a-real-looking-token";
expect(effectiveUseClaudeSubscription()).toBe(false); // user hasn't opted in
expect(claudeSubscriptionCredentialsAvailable()).toBe(true); // even though it would work
});
});
Loading