Skip to content
Closed
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
1 change: 1 addition & 0 deletions backend/druks/chat/bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ class Conversation {
: await connection.newSession(setup);
this.state.sessionId ||= session.sessionId;
this.state.harness = request.harness;
this.state.replyCommand = request.replyCommand;
this.state.model = request.model;
await this.configure(connection, request, session.configOptions ?? []);
this.connection = connection;
Expand Down
15 changes: 8 additions & 7 deletions backend/druks/chat/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@
from druks.files.constants import MAX_UPLOAD_BYTES
from druks.files.datastructures import File
from druks.files.storage import get_file_storage
from druks.harnesses.base import Harness
from druks.harnesses.config import AgentConfig, get_config
from druks.harnesses.registry import get_harness, get_harnesses
from druks.harnesses.registry import get_harnesses
from druks.locks import lock
from druks.mcp.enums import AllowedTools, Toolkit
from druks.mcp.helpers import get_bearer_token_env_var
Expand Down Expand Up @@ -317,7 +316,8 @@ async def send_turn(
conversationId=conversation.id,
archivePath=archive_path,
harness=config.harness_class.name,
command=config.harness_class.adapter_command,
command=config.get_command(config.harness_class.adapter_command),
replyCommand=config.get_command(config.harness_class.reply_command),
effort=config.effort,
fastMode=config.fast_mode,
mcpUrl=server.url,
Expand Down Expand Up @@ -498,8 +498,9 @@ async def finish_turn(
if state == MessageState.REPLIED and body and not await conversation.is_held(session):
await channels.get(conversation.source).send_reply(session, conversation, reply)
elif not conversation.title:
harness = get_harness(status["harness"])
await name_conversation(session, conversation, bridge.host, message, body, harness)
await name_conversation(
session, conversation, bridge.host, message, body, status["replyCommand"]
)


async def report_result(session: AsyncSession, run: Run, *, result) -> str | None:
Expand Down Expand Up @@ -529,7 +530,7 @@ async def name_conversation(
host: Host,
message: Message,
reply: str,
harness: type[Harness],
command: list[str],
) -> None:
"""Ask the harness for a short name. A failed call leaves the conversation unnamed,
and the next reply asks again: a name is never worth failing a delivery."""
Expand All @@ -538,7 +539,7 @@ async def name_conversation(
f"Message: {message.body[:2000]}\n\nReply: {reply[:2000]}"
)
with suppress(asyncssh.Error, OSError):
result = await host.exec([*harness.reply_command, prompt], timeout=30)
result = await host.exec([*command, prompt], timeout=30)
if result.ok and result.stdout.strip():
conversation.title = result.stdout.strip()[:80]
await session.commit()
Expand Down
4 changes: 4 additions & 0 deletions backend/druks/harnesses/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ class Harness(ABC):
adapter_command: ClassVar[tuple[str, ...]] = ()
# The argv that answers one prompt and exits.
reply_command: ClassVar[tuple[str, ...]]
# The variable this CLI reads a subscription's placeholder from.
subscription_variable: ClassVar[str]
# The variable this CLI reads a key's placeholder from, when it is not the provider's.
api_key_variable: ClassVar[str | None] = None

def __init__(
self,
Expand Down
1 change: 1 addition & 0 deletions backend/druks/harnesses/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ClaudeHarness(Harness):
command = "claude"
adapter_command = ("/opt/druks-chat/node_modules/.bin/claude-agent-acp",)
reply_command = (command, "-p")
subscription_variable = "ANTHROPIC_AUTH_TOKEN"
no_ask_mode = "bypassPermissions"

@classmethod
Expand Down
12 changes: 8 additions & 4 deletions backend/druks/harnesses/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ class CodexHarness(Harness):
command = "codex"
adapter_command = ("/opt/druks-chat/node_modules/.bin/codex-acp",)
reply_command = (command, "exec", "--skip-git-repo-check")
subscription_variable = _SUBSCRIPTION_TOKEN.upper()
# codex exec reads CODEX_API_KEY from the environment and ignores OPENAI_API_KEY there,
# so the key needs its own entry, not the catalog's.
api_key_variable = "CODEX_API_KEY"

@classmethod
def get_acp_session(
Expand All @@ -325,7 +329,9 @@ def get_acp_session(
if identity:
login = _auth_file(identity)
else:
login = json.dumps({"auth_mode": "apikey", "OPENAI_API_KEY": "${CODEX_API_KEY}"})
login = json.dumps(
{"auth_mode": "apikey", "OPENAI_API_KEY": f"${{{cls.api_key_variable}}}"}
)
files = {f"{sandbox_home}/.codex/auth.json": login}
if account_type == AccountKind.OPERATOR:
config: dict[str, object] = {"model": model_id, "developer_instructions": prompt}
Expand Down Expand Up @@ -590,13 +596,11 @@ def get_secret_refs(cls, subscription: VaultSecret) -> list[SecretRef]:

@classmethod
def get_secrets(cls, provider: str, key: str) -> dict[str, Secret]:
# codex exec reads CODEX_API_KEY from the environment and ignores
# OPENAI_API_KEY there, so the key needs its own entry, not the catalog's.
return {
OpenAiProvider.id: Secret(
key,
host="api.openai.com",
auth_variable="CODEX_API_KEY",
auth_variable=cls.api_key_variable,
auth_header="Authorization",
auth_prefix="Bearer ",
)
Expand Down
15 changes: 15 additions & 0 deletions backend/druks/harnesses/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from dataclasses import dataclass

from drukbox_sdk import Secret
Expand All @@ -12,6 +13,7 @@
from druks.user_settings.models import InstallationSettings, SettingsOverride

from .base import Harness
from .constants import CREDENTIAL_VARIABLES
from .exceptions import AgentConfigError, HarnessNotConnectedError
from .models import ProviderCatalog
from .providers import get_provider, is_registered, provider_label
Expand Down Expand Up @@ -57,6 +59,19 @@ def secrets_id(self) -> str:
def charged_account_id(self) -> str | None:
return self.subscription.account_id if self.subscription else None

def get_command(self, command: Sequence[str]) -> tuple[str, ...]:
"""``command`` launched with this config's credential variable as the only one."""
if self.billing == "api_key":
provider = get_provider(self.model.partition("/")[0])
credential_variable = self.harness_class.api_key_variable or provider.api_key_variable
else:
credential_variable = self.harness_class.subscription_variable
arguments = ["env"]
for variable in CREDENTIAL_VARIABLES:
if variable != credential_variable:
arguments.extend(("-u", variable))
return (*arguments, *command)


async def check_config(
session: AsyncSession, harness_name: str, model: str, billing: str
Expand Down
11 changes: 11 additions & 0 deletions backend/druks/harnesses/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,14 @@
"Monitor",
"ScheduleWakeup",
)

# Every variable a harness CLI reads a provider credential from. A launch keeps only the
# variable of its own credential, because a sandbox can inject the others.
CREDENTIAL_VARIABLES = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"OPENAI_API_KEY",
"CODEX_API_KEY",
"CODEX_SUBSCRIPTION_TOKEN",
)
7 changes: 6 additions & 1 deletion backend/druks/harnesses/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ class Provider:
label: ClassVar[str]
# What this provider bills: "subscription", "api_key", or both.
billing_options: ClassVar[frozenset[str]]
# The variable a box holds this provider's key placeholder in.
api_key_variable: ClassVar[str]
# OAuth refresh config (set by providers that offer a subscription).
REFRESH_MARGIN: ClassVar[timedelta]
_TOKEN_URL: ClassVar[str]
Expand Down Expand Up @@ -683,6 +685,7 @@ class AnthropicProvider(Provider):
id = "anthropic"
label = "Anthropic"
billing_options = frozenset({"subscription", "api_key"})
api_key_variable = "ANTHROPIC_API_KEY"

# Longer than one call, so a busy subscription can wait for idle before its token is urgent.
REFRESH_MARGIN = timedelta(seconds=MAX_AGENT_TIMEOUT_SECONDS, hours=1)
Expand All @@ -701,7 +704,7 @@ def get_secret(cls, key: str) -> Secret:
return Secret(
key,
host="api.anthropic.com",
auth_variable="ANTHROPIC_API_KEY",
auth_variable=cls.api_key_variable,
auth_header="x-api-key",
auth_prefix="",
)
Expand Down Expand Up @@ -919,6 +922,8 @@ class OpenAiProvider(Provider):
id = "openai"
label = "OpenAI"
billing_options = frozenset({"subscription", "api_key"})
# The catalog entry's variable.
api_key_variable = "OPENAI_API_KEY"

REFRESH_MARGIN = timedelta(hours=24)
_TOKEN_URL = "https://auth.openai.com/oauth/token"
Expand Down
9 changes: 5 additions & 4 deletions backend/druks/sandbox/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import shlex
from collections.abc import Sequence
from dataclasses import replace
from datetime import UTC, datetime
from pathlib import Path
from types import TracebackType
Expand Down Expand Up @@ -255,7 +256,7 @@ async def run_agent(
extra_env=extra_env,
mcp_servers=mcp_servers,
call_id=run_id,
identity=config.identity,
config=config,
)
except HarnessError as exc:
error = exc
Expand Down Expand Up @@ -284,6 +285,7 @@ async def run_prompt(
session: AsyncSession,
harness: "Harness",
*,
config: "AgentConfig",
prompt: str,
schema: dict[str, Any],
artifact_dir: Path,
Expand All @@ -294,7 +296,6 @@ async def run_prompt(
extra_env: dict[str, str] | None = None,
mcp_servers: tuple[McpServer, ...] = (),
call_id: str | None = None,
identity: dict | None = None,
) -> Any:
"""Drive one prompt through ``harness`` on this VM: the harness
builds the invocation and parses the result; this sandbox executes it."""
Expand All @@ -318,11 +319,11 @@ async def run_prompt(
skills=skills,
extra_env=extra_env,
mcp_servers=mcp_servers,
identity=identity,
identity=config.identity,
timeout=timeout,
)
result = await self._exec(
invocation,
replace(invocation, args=config.get_command(invocation.args)),
run_id=run_id,
artifact_dir=artifact_dir,
timeout=timeout,
Expand Down
22 changes: 18 additions & 4 deletions backend/tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from druks.files.models import FileRecord
from druks.harnesses.claude import ClaudeHarness
from druks.harnesses.codex import CodexHarness
from druks.harnesses.config import AgentConfig
from druks.harnesses.opencode import OpenCodeHarness
from druks.mcp.enums import Toolkit
from druks.mcp.inbound import get_druks_account_token, get_druks_mcp_server
Expand Down Expand Up @@ -85,13 +86,18 @@ async def sandbox(druks_db, conversation, monkeypatch):
)
monkeypatch.setattr(service, "get_sandbox", AsyncMock(return_value=(host, identity)))
monkeypatch.setattr(service, "get_running_sandbox", AsyncMock(return_value=host))
config = SimpleNamespace(
config = AgentConfig(
harness_class=ClaudeHarness,
model="anthropic/claude-opus-4-7",
subscription=None,
api_key=None,
secrets={},
secret_refs=[],
identity={},
billing="api_key",
effort="",
fast_mode=False,
timeout=600,
fast_mode=False,
)
monkeypatch.setattr(service, "get_agent", AsyncMock(return_value=(config, "", Toolkit.ALL)))
monkeypatch.setattr(service, "sandbox_client", SimpleNamespace(set_expiry=AsyncMock()))
Expand Down Expand Up @@ -235,7 +241,13 @@ async def attach(*, host_id):
async def test_delivered_turn_is_sent_again_when_the_bridge_never_got_the_prompt(
druks_db, conversation, sandbox, monkeypatch
):
state = {"status": "idle", "sessionId": "one", "messageId": "", "harness": "claude"}
state = {
"status": "idle",
"sessionId": "one",
"messageId": "",
"harness": "claude",
"replyCommand": ["claude", "-p"],
}
prompts = []

async def request(self, method, **values):
Expand Down Expand Up @@ -342,6 +354,7 @@ async def request(self, method, **values):
"status": "running" if statuses == 1 else terminal_state,
"messageId": message.id,
"harness": "claude",
"replyCommand": ["env", "claude", "-p"],
"epoch": "sandbox-one",
"sequence": 4,
"archivePath": "/home/druks/work/chat/session.tar.gz",
Expand Down Expand Up @@ -375,6 +388,7 @@ async def download(**values):
}
]
assert conversation.title == "Gate check"
assert host.exec.await_args.args[0][:3] == ["env", "claude", "-p"]
assert await get_client().xlen(service.events_key(conversation.id)) == 2
assert previous.deleted_at
archive = await druks_db.get(FileRecord, conversation.session_file.id)
Expand Down Expand Up @@ -454,7 +468,7 @@ async def test_new_sandbox_restores_archive_and_drains_pending_messages(
async def request(self, method, **values):
if method == "start":
starts.append(values)
state["sessionId"] = "restored"
state.update(sessionId="restored", replyCommand=values["replyCommand"])
elif method == "prompt":
prompts.append(values["messageId"])
state.update(
Expand Down
63 changes: 63 additions & 0 deletions backend/tests/test_harness_environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import json
import os
import subprocess
import sys

import pytest
from druks.harnesses.claude import ClaudeHarness
from druks.harnesses.codex import CodexHarness
from druks.harnesses.config import AgentConfig
from druks.harnesses.constants import CREDENTIAL_VARIABLES
from druks.harnesses.opencode import OpenCodeHarness
from druks.harnesses.pi import PiHarness


@pytest.mark.parametrize(
("harness", "model", "billing", "selected"),
[
(ClaudeHarness, "anthropic/claude-opus-4-7", "subscription", "ANTHROPIC_AUTH_TOKEN"),
(ClaudeHarness, "anthropic/claude-opus-4-7", "api_key", "ANTHROPIC_API_KEY"),
(CodexHarness, "openai/gpt-5.5", "subscription", "CODEX_SUBSCRIPTION_TOKEN"),
(CodexHarness, "openai/gpt-5.5", "api_key", "CODEX_API_KEY"),
(OpenCodeHarness, "anthropic/claude-sonnet-4-5", "api_key", "ANTHROPIC_API_KEY"),
(OpenCodeHarness, "openai/gpt-5.5", "api_key", "OPENAI_API_KEY"),
(PiHarness, "anthropic/claude-sonnet-4-5", "api_key", "ANTHROPIC_API_KEY"),
(PiHarness, "openai/gpt-5.5", "api_key", "OPENAI_API_KEY"),
],
)
def test_child_keeps_the_selected_placeholder_and_removes_competing_credentials(
harness, model, billing, selected
):
config = AgentConfig(
harness_class=harness,
model=model,
subscription=None,
api_key=None,
secrets={},
secret_refs=[],
# An empty identity must not read as a key: the billing selects the variable.
identity={},
billing=billing,
effort="high",
timeout=60,
fast_mode=False,
)
credentials = dict.fromkeys(CREDENTIAL_VARIABLES, "foreign-key")
credentials[selected] = "issued-placeholder"
command = config.get_command(
(sys.executable, "-c", "import json, os; print(json.dumps(dict(os.environ)))")
)

result = subprocess.run(
command,
env={"PATH": os.defpath, "MCP_GITHUB_TOKEN": "mcp-placeholder", **credentials},
capture_output=True,
text=True,
check=True,
)

environment = json.loads(result.stdout)
assert {name: environment[name] for name in credentials if name in environment} == {
selected: "issued-placeholder"
}
assert environment["MCP_GITHUB_TOKEN"] == "mcp-placeholder"
Loading
Loading