diff --git a/docker/chat/server.py b/docker/chat/server.py index 6472a1d..48944d1 100644 --- a/docker/chat/server.py +++ b/docker/chat/server.py @@ -20,6 +20,12 @@ OAuth flow: 6. Redirects to /chat/ The claude binary is expected to be mounted from the host at /usr/local/bin/claude. + +Workspace access: + - CHAT_WORKSPACE_DIR environment variable: bind-mounted project working tree + - Claude invocation uses --permission-mode acceptEdits for code modification + - CWD is set to workspace directory when configured, enabling Claude to + inspect, explain, or modify code scoped to that tree only """ import asyncio @@ -46,6 +52,10 @@ UI_DIR = "/var/chat/ui" STATIC_DIR = os.path.join(UI_DIR, "static") CLAUDE_BIN = "/usr/local/bin/claude" +# Workspace directory: bind-mounted project working tree for Claude access +# Defaults to empty; when set, Claude can read/write to this directory +WORKSPACE_DIR = os.environ.get("CHAT_WORKSPACE_DIR", "") + # OAuth configuration FORGE_URL = os.environ.get("FORGE_URL", "http://localhost:3000") CHAT_OAUTH_CLIENT_ID = os.environ.get("CHAT_OAUTH_CLIENT_ID", "") @@ -491,12 +501,18 @@ class _WebSocketHandler: return try: + # Build claude command with permission mode (acceptEdits allows file edits) + claude_args = [CLAUDE_BIN, "--print", "--output-format", "stream-json", "--permission-mode", "acceptEdits", message] + # Spawn claude --print with stream-json for streaming output + # Set cwd to workspace directory if configured, allowing Claude to access project code + cwd = WORKSPACE_DIR if WORKSPACE_DIR else None proc = subprocess.Popen( - [CLAUDE_BIN, "--print", "--output-format", "stream-json", message], + claude_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + cwd=cwd, bufsize=1, ) @@ -1040,12 +1056,18 @@ class ChatHandler(BaseHTTPRequestHandler): # Save user message to history _write_message(user, conv_id, "user", message) + # Build claude command with permission mode (acceptEdits allows file edits) + claude_args = [CLAUDE_BIN, "--print", "--output-format", "stream-json", "--permission-mode", "acceptEdits", message] + # Spawn claude --print with stream-json for token tracking (#711) + # Set cwd to workspace directory if configured, allowing Claude to access project code + cwd = WORKSPACE_DIR if WORKSPACE_DIR else None proc = subprocess.Popen( - [CLAUDE_BIN, "--print", "--output-format", "stream-json", message], + claude_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + cwd=cwd, bufsize=1, # Line buffered ) diff --git a/lib/generators.sh b/lib/generators.sh index 561b032..67ff830 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -705,6 +705,9 @@ COMPOSEEOF - chat-config:/var/chat/config # Chat history persistence: per-user NDJSON files on bind-mounted host volume - ${CHAT_HISTORY_DIR:-./state/chat-history}:/var/lib/chat/history + # Workspace directory: bind-mounted project working tree for Claude access (#1027) + # Mounted when CHAT_WORKSPACE_DIR is set (defaults to ./workspace) + - ${CHAT_WORKSPACE_DIR:-./workspace}:/var/workspace environment: CHAT_HOST: "0.0.0.0" CHAT_PORT: "8080" @@ -718,6 +721,8 @@ COMPOSEEOF # Shared secret for Caddy forward_auth verify endpoint (#709) FORWARD_AUTH_SECRET: ${FORWARD_AUTH_SECRET:-} # Rate limiting removed (#1084) + # Workspace directory for Claude code access (#1027) + CHAT_WORKSPACE_DIR: ${CHAT_WORKSPACE_DIR:-./workspace} healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] interval: 30s diff --git a/nomad/jobs/chat.hcl b/nomad/jobs/chat.hcl index 95f86ab..f9a6657 100644 --- a/nomad/jobs/chat.hcl +++ b/nomad/jobs/chat.hcl @@ -21,8 +21,17 @@ # FORWARD_AUTH_SECRET from kv/disinto/shared/chat # - Seeded on fresh boxes by tools/vault-seed-chat.sh # -# Host volume: +# Host volumes: # - chat-history → /var/lib/chat/history (persists conversation history) +# - workspace → /var/workspace (project working tree for Claude access, #1027) +# +# Client-side host_volume registration (operator prerequisite): +# In nomad/client.hcl on each Nomad node: +# host_volume "chat-workspace" { +# path = "/var/disinto/chat-workspace" +# read_only = false +# } +# Nodes without the host_volume registered will not schedule the workspace mount. # # Not the runtime yet: docker-compose.yml is still the factory's live stack # until cutover. This file exists so CI can validate it and S5.2 can wire @@ -61,6 +70,21 @@ job "chat" { read_only = false } + # Workspace volume: bind-mounted project working tree for Claude access (#1027) + # Source is a fixed logical name resolved by client-side host_volume registration. + volume "workspace" { + type = "host" + source = "chat-workspace" + read_only = false + } + + # ── Metadata (per-dispatch env var via NOMAD_META_*) ────────────────────── + # CHAT_WORKSPACE_DIR: project working tree path, injected into task env + # as NOMAD_META_CHAT_WORKSPACE_DIR for the workspace volume mount target. + meta { + CHAT_WORKSPACE_DIR = "/var/workspace" + } + # ── Restart policy ─────────────────────────────────────────────────────── restart { attempts = 3 @@ -115,11 +139,20 @@ job "chat" { read_only = false } + # Mount workspace directory for Claude code access (#1027) + # Binds project working tree so Claude can inspect/modify code + volume_mount { + volume = "workspace" + destination = "/var/workspace" + read_only = false + } + # ── Environment: secrets from Vault (S5.2) ────────────────────────────── # CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET, FORWARD_AUTH_SECRET # rendered from kv/disinto/shared/chat via template stanza. env { - FORGE_URL = "http://forgejo:3000" + FORGE_URL = "http://forgejo:3000" + CHAT_WORKSPACE_DIR = "${NOMAD_META_CHAT_WORKSPACE_DIR}" } # ── Vault-templated secrets (S5.2, issue #989) ─────────────────────────