Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698020693d | ||
|
|
5d16b273be | ||
|
|
dda28a3864 | ||
|
|
4202e1fea1 | ||
|
|
785c9d34d7 | ||
|
|
585d1c3208 | ||
|
|
cb31e463f9 | ||
|
|
3e995a477a | ||
|
|
ef6939f5d3 | ||
|
|
72599e5c2d | ||
|
|
63e79d81cc | ||
|
|
2c14b10888 | ||
|
|
4706a27ab2 | ||
|
|
8350f68071 | ||
|
|
705809116a | ||
|
|
ff90448c79 | ||
|
|
17a51bd1c3 | ||
|
|
7ebae027fa | ||
|
|
c2ade350ed | ||
|
|
e54db2b299 | ||
|
|
4afd0c62b3 | ||
|
|
d57d38566f | ||
|
|
bb33c62721 | ||
|
|
041e38bf4f | ||
|
|
0634730d60 | ||
|
|
dab367231c | ||
|
|
b2c1a0ea89 | ||
|
|
ddf0d61efc | ||
|
|
bce3daf61e | ||
|
|
3ab5c41068 | ||
|
|
c306c32457 | ||
|
|
2e1b96c104 | ||
|
|
e01cc71b7d | ||
|
|
8315468776 | ||
|
|
4aa984717c | ||
|
|
ab457fdbbe | ||
|
|
31bd7c3cfe | ||
|
|
18666c1bea | ||
|
|
61add62e98 | ||
|
|
7ba8a2fe7d | ||
|
|
a9e75313e9 | ||
|
|
6eada984e0 | ||
|
|
20cb52defb | ||
|
|
fff6f02a55 |
+10
@@ -79,6 +79,16 @@ themselves, which is most of where the wall clock went. It is bats' own
|
||||
`--jobs`, and it needs `flock` rather than GNU parallel; without `flock` the
|
||||
script falls back to one at a time and says so.
|
||||
|
||||
**The concurrency is budgeted for the whole run, not per suite.** Every
|
||||
selected suite runs at once, so what lands on the machine is suites × jobs;
|
||||
asking each for `nproc` made that ten times the cores on a ten-core box. The
|
||||
script targets roughly twice the cores in total and divides, and prints both
|
||||
numbers. That is #295: several tests wait on a bounded deadline — a pty
|
||||
reaching raw mode, a shipper polling, an audit finishing — and a saturated
|
||||
machine blows through it, so they failed for the load rather than for the
|
||||
thing they check. If you add a test that waits for something, assume the
|
||||
machine is busy.
|
||||
|
||||
Each run reports what every suite ran as well as what failed, because a suite
|
||||
that cannot start — a syntax error, or duplicate test names, which bats refuses
|
||||
outright — exits nonzero having run nothing, and that reads as "no failures" to
|
||||
|
||||
@@ -145,6 +145,10 @@ what each one buys, what goes wrong without it, and where each one's boundary
|
||||
ends. None of them is required to run dejima; what they change is what the
|
||||
layer below the container costs, and whether anyone finds out.
|
||||
|
||||
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
|
||||
the most common question about this design, and why the effort went into the
|
||||
layers below the container rather than into a harder one.
|
||||
|
||||
## Setup
|
||||
|
||||
This repo only holds the tooling; no project lives here. Point it at a project
|
||||
@@ -305,19 +309,33 @@ There is no credential to hold or expire; what that setup needs instead is for
|
||||
the endpoint to be reachable from inside the container, which is an allowlist
|
||||
question rather than a sign-in one.
|
||||
|
||||
## Provider axes: forge, workflow, harness
|
||||
## Provider axes: forge, workflow, harness, model backend
|
||||
|
||||
The forge integration, the push/PR workflow, and the agent harness aren't
|
||||
hardcoded into dejima: each is a directory under `forges/`, `workflows/` or
|
||||
`harnesses/`, selected independently by `[core] forge`, `[core] workflow` and
|
||||
`[core] harness`.
|
||||
The forge integration, the push/PR workflow, the agent harness and the model
|
||||
API aren't hardcoded into dejima: each is a directory under `forges/`,
|
||||
`workflows/`, `harnesses/` or `backends/`, selected independently by
|
||||
`[core] forge`, `[core] workflow`, `[core] harness` and
|
||||
`[harness.<name>] backend`.
|
||||
|
||||
- **Forges:** `none`, `gitea`, `github`
|
||||
- **Workflows:** `local-only`, `branch-push`, `worktree-pr`
|
||||
- **Harnesses:** `claude-code`, `cline`
|
||||
- **Model backends:** `anthropic`, `anthropic-api-key`, `openrouter`, `self-hosted`
|
||||
|
||||
Which of those last two pair up is the backend's API dialect against the
|
||||
harness's, checked at launch rather than maintained as a list:
|
||||
|
||||
| Backend | `claude-code` | `cline` | Credential |
|
||||
| --- | --- | --- | --- |
|
||||
| `anthropic` | yes | yes | OAuth, held on the host |
|
||||
| `anthropic-api-key` | yes | yes | a static key, held on the host |
|
||||
| `openrouter` | no | yes | a static key, held on the host |
|
||||
| `self-hosted` | no | yes | none, and so no proxy at all |
|
||||
|
||||
**→ [docs/providers.md](docs/providers.md)** - the manifest contract, key by
|
||||
key, and the shipped providers as worked examples.
|
||||
key, what each pairing needs configured, and the shipped providers as worked
|
||||
examples. The model API is called a *backend* throughout, to keep it apart
|
||||
from the axes themselves.
|
||||
|
||||
## Using this responsibly
|
||||
|
||||
@@ -652,7 +670,7 @@ and how to run them.
|
||||
| [research-review.md](docs/research-review.md) | Checking the threat model against the field's, and the standing questions that are the agenda |
|
||||
| [configuration.md](docs/configuration.md) | Per-project declaration, dependencies, config parity |
|
||||
| [tools.md](docs/tools.md) | The optional tools a session can be given, and what hardening each one carries |
|
||||
| [providers.md](docs/providers.md) | Forge, workflow and harness axes, and writing your own |
|
||||
| [providers.md](docs/providers.md) | Forge, workflow, harness and model-backend axes, and writing your own |
|
||||
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
|
||||
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
|
||||
| [development.md](docs/development.md) | The file map, the test suites, running them |
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Anthropic on API billing: a key rather than a subscription login. This is
|
||||
# what most organisations put their people on.
|
||||
#
|
||||
# One backend for both places the key can live, because they are one
|
||||
# arrangement. Claude Code's own console login creates a key and stores it as
|
||||
# primaryApiKey in ~/.claude.json - the defaults below - and a key you hold in
|
||||
# a file of your own is the same static credential read from somewhere else:
|
||||
#
|
||||
# [backend.anthropic-api-key]
|
||||
# credential_file = /path/to/your/key
|
||||
# credential_field =
|
||||
#
|
||||
# An empty credential_field means the whole file is the key.
|
||||
#
|
||||
# For a subscription login, see anthropic. That one is a genuinely different
|
||||
# arrangement rather than a different location - a Bearer token that expires
|
||||
# and is refreshed, against the same endpoint.
|
||||
|
||||
upstream = https://api.anthropic.com
|
||||
proxied_domain = api.anthropic.com
|
||||
api_style = anthropic
|
||||
|
||||
credential = static
|
||||
|
||||
# Anthropic's own header, and the key goes in bare - no scheme prefix. This is
|
||||
# the pair the subscription backend does NOT use, which is the whole
|
||||
# difference between the two: an OAuth token is a Bearer, an API key is
|
||||
# x-api-key.
|
||||
auth_header = x-api-key
|
||||
auth_format = {token}
|
||||
|
||||
# The other slot, stripped so a placeholder cannot travel beside the injected
|
||||
# key. Two answers to the same question and which one the API prefers is not
|
||||
# dejima's to guess - and here the wrong answer is the placeholder.
|
||||
strip_headers = authorization
|
||||
|
||||
# Defaulted to where Claude Code puts it, so the common case needs no
|
||||
# configuration. dejima strips this field from the copy of ~/.claude.json it
|
||||
# stages, so the container gets the surrounding config and none of the key.
|
||||
keys = credential_file:path=~/.claude.json credential_field:?jsonfield=primaryApiKey
|
||||
requires = credential_file
|
||||
@@ -0,0 +1,45 @@
|
||||
# Anthropic, on a subscription login: the OAuth credential Claude Code writes
|
||||
# on the host.
|
||||
#
|
||||
# The refreshing kind. The token expires, so the proxy renews it against the
|
||||
# endpoint below and writes the rotated one back to the host file - which is
|
||||
# what keeps a host-side login working rather than being signed out by a
|
||||
# sandbox run. For a console key on API billing, see anthropic-api-key.
|
||||
|
||||
upstream = https://api.anthropic.com
|
||||
|
||||
# What the in-container TLS terminator answers for, and what /etc/hosts pins to
|
||||
# loopback. On the backend rather than the harness, because it is a property
|
||||
# of the endpoint: the same harness reaches a different one when a different
|
||||
# backend is selected, and the terminator has to follow the endpoint.
|
||||
proxied_domain = api.anthropic.com
|
||||
|
||||
# The Messages API, which is not OpenAI-compatible. A harness pointed here has
|
||||
# to speak it; one that cannot is refused at launch rather than sending
|
||||
# well-formed requests in the wrong shape.
|
||||
api_style = anthropic
|
||||
|
||||
credential = anthropic-oauth
|
||||
oauth_endpoint = https://platform.claude.com/v1/oauth/token
|
||||
oauth_client_id = 9d1c250a-e61b-44d9-88ed-5944d1962f5e
|
||||
|
||||
auth_header = Authorization
|
||||
auth_format = Bearer {token}
|
||||
|
||||
# x-api-key must not travel beside the injected Authorization: two answers to
|
||||
# the same question, and which one the API prefers is not dejima's to guess.
|
||||
# The container only ever holds a placeholder, so what this prevents is a
|
||||
# placeholder being the one that gets read.
|
||||
strip_headers = x-api-key
|
||||
|
||||
# The API rejects an OAuth token without it. Merged into whatever the client
|
||||
# already sent rather than replacing it, and left completely alone when the
|
||||
# value is already there - see the proxy's comment on why re-joining a list
|
||||
# that needs no change is itself a fingerprint.
|
||||
ensure_headers = anthropic-beta=oauth-2025-04-20
|
||||
|
||||
# Claude Code's own credential file, which is where the OAuth blob lives. A
|
||||
# path, never a secret: the file is read host-side per request and is not
|
||||
# mounted into the container.
|
||||
keys = credential_file:path=~/.claude/.credentials.json
|
||||
requires = credential_file
|
||||
@@ -0,0 +1,34 @@
|
||||
# OpenRouter, an OpenAI-compatible gateway in front of many models.
|
||||
#
|
||||
# A hosted endpoint behind a static API key. The key stays on the host and is
|
||||
# read per request, exactly as the Anthropic OAuth token is.
|
||||
|
||||
upstream = https://openrouter.ai/api/v1
|
||||
|
||||
# The name the in-container terminator answers for, and what /etc/hosts pins to
|
||||
# loopback. The host alone: the path above is where requests are FORWARDED, and
|
||||
# a hostname is what a certificate and a DNS pin are about.
|
||||
proxied_domain = openrouter.ai
|
||||
|
||||
api_style = openai
|
||||
|
||||
# A key, not a login. Nothing to refresh and nothing to write back, so a 401
|
||||
# here means the key is wrong rather than stale - which is why the proxy does
|
||||
# not retry it.
|
||||
credential = static
|
||||
|
||||
auth_header = Authorization
|
||||
auth_format = Bearer {token}
|
||||
|
||||
# Both spellings an OpenAI-compatible client might reach for, so a placeholder
|
||||
# cannot travel beside the injected Authorization. The container only ever
|
||||
# holds a placeholder; what this prevents is the placeholder being the one the
|
||||
# endpoint reads.
|
||||
strip_headers = x-api-key api-key
|
||||
|
||||
# No default, and no assumed value: a key file's location is the operator's,
|
||||
# and guessing at one would be dejima pointing at a file it has no reason to
|
||||
# think exists. `requires` makes an unset one a refusal at launch rather than
|
||||
# an authentication failure several layers down.
|
||||
keys = credential_file:?path
|
||||
requires = credential_file
|
||||
@@ -0,0 +1,27 @@
|
||||
# A model the operator serves: on the host, or somewhere on the sandbox's own
|
||||
# network.
|
||||
#
|
||||
# The third arrangement, and the only one with no credential anywhere in it.
|
||||
# Nothing to withhold means no proxy, no terminator and nothing to steal, which
|
||||
# makes this the strongest posture dejima offers - see docs/security.md.
|
||||
#
|
||||
# WHERE it is belongs to the operator rather than to this file, which is why
|
||||
# base_url is a config key and not a manifest constant. A local endpoint is
|
||||
# also the one an operator is most likely to keep configured while using
|
||||
# something else: it lives in [backend.self-hosted] and stays there when the
|
||||
# selected backend changes.
|
||||
|
||||
credential = none
|
||||
|
||||
# No upstream and no proxied_domain: both describe an endpoint dejima stands in
|
||||
# front of, and it stands in front of nothing here.
|
||||
|
||||
# OpenAI-compatible, which is what a self-served model almost always speaks -
|
||||
# llama.cpp, vLLM, Ollama and LM Studio all expose that shape.
|
||||
api_style = openai
|
||||
|
||||
# No default. Refusing an unset one at launch beats a session starting against
|
||||
# an endpoint dejima picked, and there is no address worth guessing: whatever
|
||||
# is at 127.0.0.1:8080 on somebody else's machine is not this operator's model.
|
||||
keys = base_url:?url
|
||||
requires = base_url
|
||||
+733
-35
@@ -37,11 +37,11 @@ Options (each overrides the config file for one launch only):
|
||||
--identity <name> which [identity.<name>] supplies the credentials
|
||||
-i, --include <tool> add an optional tool to this launch (repeatable);
|
||||
see docs/tools.md for what dejima ships
|
||||
--model <id> the selected harness's model, where it has one;
|
||||
--base-url <url> its endpoint, and how much context it may use.
|
||||
--context-window <n> Refused for a harness with no such setting
|
||||
--ro <path> mount read-only; --rw read-write. Repeatable. Long
|
||||
forms --extra-readonly and --extra-read-write. Both
|
||||
--backend <name> the selected harness's model API, model and context
|
||||
--model <id> budget, each refused for a harness with no such
|
||||
--context-window <n> setting
|
||||
--ro <path> mount read-only; --rw read-write. Repeatable, long
|
||||
forms --extra-readonly/--extra-read-write, and both
|
||||
refuse a path overlapping dejima's own stores
|
||||
--allow-github-push open GitHub's push path (port 22)
|
||||
--strict-audit refuse to start over unacknowledged CRITICAL findings
|
||||
@@ -49,15 +49,15 @@ Options (each overrides the config file for one launch only):
|
||||
--no-conversation keep this session's transcript out of the log
|
||||
--no-epitaph keep nothing if this session compacts
|
||||
--no-firewall disable the egress firewall entirely (debugging only)
|
||||
--print-config print the resolved configuration and exit
|
||||
--no-pause do not wait for a keypress before the image build
|
||||
--print-config print every key as it resolves, and exit
|
||||
--print-briefing print the briefing the agent will get, and exit
|
||||
-h, --help this message
|
||||
|
||||
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
|
||||
config that says off is a decision, not a default to override in passing.
|
||||
|
||||
Config lives in ~/.config/dejima/config and [core] harness must be set before
|
||||
a launch starts. --print-config prints every key as it resolves.
|
||||
Config lives in ~/.config/dejima/config; [core] harness must be set there.
|
||||
DEJIMA_HELP
|
||||
exit 0 ;;
|
||||
esac
|
||||
@@ -104,7 +104,18 @@ color_init 2
|
||||
# and report, which costs one extra parse of a config that is about to be
|
||||
# refused anyway.
|
||||
if ! config_validate >/dev/null 2>&1; then
|
||||
# Dropping the cache alone left the reporting to whoever parsed next, and
|
||||
# that is usually a SUBSHELL - `$(config_get ...)` - whose cache dies with
|
||||
# it. So the next parsing context reported it too, and an invalid config
|
||||
# printed its reason once per context: twice for an ordinary launch, and
|
||||
# ten lines each once a message grew a hint. Same shape as #284.
|
||||
#
|
||||
# Re-parsed here instead, unsuppressed, in this shell: the reason is
|
||||
# printed exactly once and the failed status is cached where every
|
||||
# subshell inherits it. Deliberately not an exit - flags are not parsed
|
||||
# yet, and `--help` on a broken config should still answer.
|
||||
_CFG_PARSED_PATH=""
|
||||
config_validate || true
|
||||
fi
|
||||
|
||||
# Flags come first, then the optional workspace path, then "--" and
|
||||
@@ -222,6 +233,9 @@ ALLOW_GITHUB_PUSH_FLAG=0
|
||||
# project already agreed on, not a replacement for them. Repeatable.
|
||||
TOOL_FLAGS=()
|
||||
NO_LOG=0
|
||||
# Launch-scoped, like NO_LOG: initialised here so an inherited environment
|
||||
# variable of the same name cannot decide it.
|
||||
NO_PAUSE=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift; break ;;
|
||||
@@ -235,15 +249,25 @@ while [ $# -gt 0 ]; do
|
||||
--strict-audit) _set_override audit.strict on; shift ;;
|
||||
--no-epitaph) _set_override epitaph.enabled off; shift ;;
|
||||
--no-conversation) _set_override logging.conversation off; shift ;;
|
||||
# Not a config override: this is about the terminal in front of THIS
|
||||
# launch, not about what dejima should do generally. Someone starting
|
||||
# eight sessions from a tmuxinator layout wants every one of them to
|
||||
# reach its prompt, and does not want that recorded as a preference
|
||||
# for the launch they run by hand tomorrow.
|
||||
--no-pause) NO_PAUSE=1; shift ;;
|
||||
--no-log) _set_override logging.loki_url ""
|
||||
_set_override logging.conversation off
|
||||
NO_LOG=1; shift ;;
|
||||
--model) _need_value --model "${2:-}"
|
||||
_defer_harness_key --model model "$2"; shift 2 ;;
|
||||
--base-url) _need_value --base-url "${2:-}"
|
||||
_defer_harness_key --base-url base_url "$2"; shift 2 ;;
|
||||
--context-window) _need_value --context-window "${2:-}"
|
||||
_defer_harness_key --context-window context_window "$2"; shift 2 ;;
|
||||
# Deferred like the three above, and for the extra reason that this one
|
||||
# is per harness by design: which provider a launch uses is the
|
||||
# harness's setting, so "claude-code has no provider setting" is the
|
||||
# error a harness that declares none should give.
|
||||
--backend) _need_value --backend "${2:-}"
|
||||
_defer_harness_key --backend backend "$2"; shift 2 ;;
|
||||
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
|
||||
--harness) _need_value --harness "${2:-}"; _set_override core.harness "$2"; shift 2 ;;
|
||||
--identity) _need_value --identity "${2:-}"; _set_override core.identity "$2"; shift 2 ;;
|
||||
@@ -360,7 +384,7 @@ _warn_legacy_env() {
|
||||
done
|
||||
if [ "$shown" = 1 ]; then
|
||||
echo "Set them in $(config_path) instead, then unset them in your shell profile." >&2
|
||||
echo "" >&2
|
||||
DEJIMA_STARTUP_NOTICE=1
|
||||
fi
|
||||
|
||||
# GITEA_TOKEN/GITEA_SSH_HOST/GITEA_API are retired (named above) -
|
||||
@@ -1003,10 +1027,17 @@ share_loose="$(find "$SANDBOX_SHARE_ROOT" -mindepth 1 -maxdepth 1 \
|
||||
if [ -n "$share_loose" ]; then
|
||||
echo "Note: $SANDBOX_SHARE_ROOT holds entries no session can see any more," >&2
|
||||
echo " because ~/shared is now this project's own subdirectory of it:" >&2
|
||||
while IFS= read -r _entry; do
|
||||
# A sample, not an inventory. This is a standing condition an operator
|
||||
# clears once, and printing ninety filenames on every launch pushes the
|
||||
# notices that are about THIS run off the top of the screen.
|
||||
_share_n="$(printf '%s\n' "$share_loose" | wc -l)"
|
||||
printf '%s\n' "$share_loose" | head -8 | while IFS= read -r _entry; do
|
||||
[ -n "$_entry" ] || continue
|
||||
printf ' %s\n' "$_entry" >&2
|
||||
done <<< "$share_loose"
|
||||
done
|
||||
[ "$_share_n" -le 8 ] || printf ' %s... and %d more%s\n' \
|
||||
"${C_DIM:-}" "$(( _share_n - 8 ))" "${C_RESET:-}" >&2
|
||||
unset _share_n
|
||||
echo " Move them into a project's share with 'dejima-share adopt <path>'," >&2
|
||||
echo " or delete them. 'dejima-share list' shows what each project holds." >&2
|
||||
fi
|
||||
@@ -1049,9 +1080,31 @@ harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" || exit 1
|
||||
# than merged back - nothing a session writes here reaches the host, and
|
||||
# concurrent sessions never contend. See docs/configuration.md, "Config
|
||||
# parity with your host Claude Code setup".
|
||||
#
|
||||
# Seeded through jq rather than cp, because this file HOLDS A CREDENTIAL. A
|
||||
# Claude Code signed in with a console API token stores it as a top-level
|
||||
# primaryApiKey here - not in .credentials.json, which stays `{}` for that
|
||||
# login. Copied verbatim, that key entered every container, whatever the
|
||||
# selected backend and whether or not the credential proxy was running: the
|
||||
# proxy withholds one file while this copied another.
|
||||
#
|
||||
# Stripped rather than the whole file withheld, because everything else in it
|
||||
# is why the copy exists - mcpServers, project state, onboarding flags.
|
||||
#
|
||||
# customApiKeyResponses is left: it holds hashes of keys the operator
|
||||
# approved, not the keys, and dropping it re-prompts inside the sandbox for a
|
||||
# decision already made on the host.
|
||||
#
|
||||
# A file jq cannot parse is a refusal, not a fallback to cp. Falling back
|
||||
# would restore the exact leak this exists to close, at the moment least
|
||||
# likely to be noticed.
|
||||
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
|
||||
if [ -f "$HOME/.claude.json" ]; then
|
||||
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
if ! jq 'del(.primaryApiKey)' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
|
||||
echo "$HOME/.claude.json is not readable as JSON, so dejima cannot strip the" >&2
|
||||
echo "credential it may hold before copying it into the container." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo '{}' > "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
fi
|
||||
@@ -1235,6 +1288,32 @@ trap cleanup EXIT
|
||||
SANDBOX_GITCONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-gitconfig.XXXXXX")"
|
||||
if [ -f "$HOME/.gitconfig" ]; then
|
||||
cp "$HOME/.gitconfig" "$SANDBOX_GITCONFIG_DIR/.gitconfig"
|
||||
# A gitconfig can hold credentials, so the copy does not carry them.
|
||||
#
|
||||
# Three shapes, all ordinary rather than exotic: a `credential.helper`
|
||||
# that prints a token (or is a `!` command that fetches one), an
|
||||
# `http.<url>.extraHeader` holding an Authorization line, and userinfo
|
||||
# embedded in a `url.<...>.insteadOf` key - where the secret is in the key
|
||||
# name itself. None of them is part of what the sandbox is granted: the
|
||||
# session gets the ssh key and forge token dejima manages, and
|
||||
# `gh auth setup-git` writes its own helper inside the container.
|
||||
#
|
||||
# Dropped by name from the copy rather than by editing the operator's
|
||||
# file, which is never touched.
|
||||
while IFS= read -r _gc_key; do
|
||||
case "${_gc_key,,}" in
|
||||
credential.*|*.extraheader) ;;
|
||||
url.*insteadof) case "$_gc_key" in *@*) ;; *) continue ;; esac ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" --unset-all "$_gc_key" 2>/dev/null || true
|
||||
_gc_dropped=1
|
||||
done < <(git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" --list --name-only 2>/dev/null || true)
|
||||
if [ -n "${_gc_dropped:-}" ]; then
|
||||
echo "dejima: credential settings in ~/.gitconfig were left out of the copy" >&2
|
||||
echo "the session gets. Its git access is the key and token dejima manages." >&2
|
||||
fi
|
||||
unset _gc_key _gc_dropped
|
||||
else
|
||||
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
|
||||
fi
|
||||
@@ -1269,6 +1348,140 @@ SANDBOX_GID="$(id -g)"; export SANDBOX_GID
|
||||
export SANDBOX_SHARE_DIR
|
||||
export SANDBOX_SETTINGS_FILE
|
||||
export SANDBOX_CLAUDE_JSON_FILE
|
||||
|
||||
# Everything dejima copies into a container, checked for credential-shaped
|
||||
# values it does not already know about.
|
||||
#
|
||||
# The two leaks found by hand (#291) were both this: a host file copied
|
||||
# wholesale, holding a credential nobody had classified as one. The fixes for
|
||||
# those are denylists, which encode last week's knowledge - so this looks for
|
||||
# the shape instead, and asks rather than assuming.
|
||||
#
|
||||
# The scanner never receives a decision and never sees this function's
|
||||
# answers; it classifies, and reports redacted paths with opaque ids. Real
|
||||
# paths stay inside it, which matters because a gitconfig can carry userinfo
|
||||
# in a section header - a path that IS a credential.
|
||||
DEJIMA_SCAN_DECISIONS="$(config_root)/credential-scan.decisions"
|
||||
|
||||
_scan_record() { # <decision> <id> <label> <redacted path> <shape>
|
||||
mkdir -p "$(dirname "$DEJIMA_SCAN_DECISIONS")" || return 1
|
||||
# The id is what matches; the rest is so the file can be read and revised
|
||||
# by hand. Keyed on the field rather than the value, so rotating a
|
||||
# credential does not re-ask a question already answered.
|
||||
printf '%s\t%s\t# %s %s (%s)\n' \
|
||||
"$1" "$2" "$3" "$4" "$5" >> "$DEJIMA_SCAN_DECISIONS"
|
||||
}
|
||||
|
||||
# Applies the operator's standing decisions, asks about the rest, and returns
|
||||
# nonzero to stop the launch.
|
||||
_scan_staged_file() { # <path> <format> <label>
|
||||
local file="$1" fmt="$2" label="$3"
|
||||
local findings ids_allow ids_scrub verdict fid flabel fpath shape answer
|
||||
[ -f "$file" ] || return 0
|
||||
[ -x "$SCRIPT_DIR/dejima-credential-scan" ] || return 0
|
||||
|
||||
ids_allow="$(mktemp)"; ids_scrub="$(mktemp)"
|
||||
# Standing decisions are ids in the first two fields; feed the allow ones
|
||||
# in as "already answered" so the scanner does not report them at all.
|
||||
if [ -f "$DEJIMA_SCAN_DECISIONS" ]; then
|
||||
awk -F'\t' '$1 == "allow" { print $2 }' "$DEJIMA_SCAN_DECISIONS" > "$ids_allow"
|
||||
awk -F'\t' '$1 == "scrub" { print $2 }' "$DEJIMA_SCAN_DECISIONS" > "$ids_scrub"
|
||||
fi
|
||||
|
||||
findings="$("$SCRIPT_DIR/dejima-credential-scan" --file "$file" --format "$fmt" \
|
||||
--label "$label" --known-safe "$SCRIPT_DIR/dejima-credential-scan-known-safe" \
|
||||
--decided "$ids_allow" 2>/dev/null)" || {
|
||||
# An unparseable file is not a finding, and not a reason to stop: it
|
||||
# is copied as-is today and the scanner simply cannot speak for it.
|
||||
rm -f "$ids_allow" "$ids_scrub"
|
||||
echo "dejima: could not scan $label for credentials; it is copied unchecked." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
# Decided BEFORE the loop below, which redirects stdin from a here-string
|
||||
# to read the findings - inside it `[ -t 0 ]` is false however the launch
|
||||
# was started, so asking there would mean never asking at all.
|
||||
local interactive=0
|
||||
[ -t 0 ] && interactive=1
|
||||
local pending=0
|
||||
while IFS=$'\t' read -r verdict fid flabel fpath shape; do
|
||||
[ -n "$fid" ] || continue
|
||||
# Already told to scrub this one: do it and say nothing.
|
||||
if grep -qxF "$fid" "$ids_scrub" 2>/dev/null; then continue; fi
|
||||
pending=1
|
||||
echo "" >&2
|
||||
echo "dejima found something credential-shaped in a file it is about to" >&2
|
||||
echo "copy into the container." >&2
|
||||
echo "" >&2
|
||||
# Padded to the longest label so the values line up. "looks like" is
|
||||
# that label, and pushing five short ones out of line to accommodate
|
||||
# it reads worse than two extra spaces on each.
|
||||
printf ' %-10s %s\n' file "$flabel" >&2
|
||||
printf ' %-10s %s\n' field "$fpath" >&2
|
||||
printf ' %-10s %s\n' 'looks like' "$shape ($verdict)" >&2
|
||||
printf ' %-10s %s\n' harness "$DEJIMA_HARNESS_NAME" >&2
|
||||
printf ' %-10s %s\n' backend "${DEJIMA_BACKEND_NAME:-(none)}" >&2
|
||||
printf ' %-10s %s\n' id "$fid" >&2
|
||||
echo "" >&2
|
||||
echo "The value itself is not shown, and dejima has not looked at what it" >&2
|
||||
echo "means - only at its shape. Finding it at all is best effort: this" >&2
|
||||
echo "recognises common credential shapes and can miss an unusual one." >&2
|
||||
echo "" >&2
|
||||
echo "If this is a credential dejima should know about, the six lines" >&2
|
||||
echo "above are what to send. Never the value." >&2
|
||||
echo "" >&2
|
||||
if [ "$interactive" = 0 ]; then
|
||||
echo "Nothing is attached to answer, so this launch stops rather than" >&2
|
||||
echo "carrying it in. Record a decision by running dejima from a" >&2
|
||||
echo "terminal once, or add a line to" >&2
|
||||
echo "$DEJIMA_SCAN_DECISIONS:" >&2
|
||||
echo "" >&2
|
||||
# Real tabs: the file is tab-separated, and a line written
|
||||
# with spaces silently fails to match.
|
||||
printf ' scrub\t%s\n' "$fid" >&2
|
||||
printf ' allow\t%s\n' "$fid" >&2
|
||||
echo "" >&2
|
||||
echo "A standing scrub is not offered at the prompt, because removing" >&2
|
||||
echo "something the harness needs breaks every launch after. Written" >&2
|
||||
echo "here it is your decision, and the line above is how to undo it." >&2
|
||||
rm -f "$ids_allow" "$ids_scrub"
|
||||
return 1
|
||||
fi
|
||||
# Scrubbing is deliberately one launch only. Removing a field the
|
||||
# harness turns out to need breaks it on every launch after, and the
|
||||
# cause is several layers from the symptom - so the safe answer is
|
||||
# not also the sticky one. A standing scrub is still possible by
|
||||
# writing the line into the decisions file by hand, which is a
|
||||
# deliberate act by someone who has read what it does.
|
||||
echo " s) keep it out of the container this launch, and ask again next time" >&2
|
||||
echo " a) let it into the container, every launch" >&2
|
||||
echo " o) let it into the container this launch, and ask again next time" >&2
|
||||
echo " r) refuse - stop this launch" >&2
|
||||
echo "" >&2
|
||||
printf 'Which? [s/a/o/r] ' >&2
|
||||
read -r answer </dev/tty || answer=r
|
||||
case "$answer" in
|
||||
s|S) printf '%s\n' "$fid" >> "$ids_scrub" ;;
|
||||
a|A) _scan_record allow "$fid" "$flabel" "$fpath" "$shape" ;;
|
||||
o|O) echo "Allowed for this launch only; nothing recorded." >&2 ;;
|
||||
# Anything else, including an empty line, is the refusal - the
|
||||
# answer that changes nothing.
|
||||
*) echo "Refused. Nothing was copied." >&2
|
||||
rm -f "$ids_allow" "$ids_scrub"
|
||||
return 1 ;;
|
||||
esac
|
||||
done <<< "$findings"
|
||||
|
||||
if [ -s "$ids_scrub" ]; then
|
||||
"$SCRIPT_DIR/dejima-credential-scan" --file "$file" --format "$fmt" \
|
||||
--label "$label" --scrub "$ids_scrub" \
|
||||
|| echo "dejima: could not scrub $label; it is copied as it is." >&2
|
||||
fi
|
||||
[ "$pending" = 0 ] || true
|
||||
rm -f "$ids_allow" "$ids_scrub"
|
||||
return 0
|
||||
}
|
||||
|
||||
export SANDBOX_GITCONFIG_DIR
|
||||
# Rule 4's other half: a workflow that never pushes gets no agent socket
|
||||
# forwarded either - left empty here, docker-compose.yml's
|
||||
@@ -1360,7 +1573,342 @@ fi
|
||||
# fixed_hosts already has, and since #142 the provider directories are in
|
||||
# the audit's TCB list, so widening this unseen is a CRITICAL finding.
|
||||
SANDBOX_HARNESS_DOMAINS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" api_domains)"
|
||||
SANDBOX_HARNESS_PROXIED_DOMAIN="$(manifest_get harness "$DEJIMA_HARNESS_NAME" proxied_api_domain)"
|
||||
|
||||
# Only a skippable answer. Whether the credential is WELL FORMED is the proxy's
|
||||
# own startup check, which fails the launch loudly rather than falling back -
|
||||
# a fallback for a malformed key would be a session running with the
|
||||
# operator's real credential mounted in.
|
||||
_credential_usable() { # <kind> <path> [field]
|
||||
case "$1" in
|
||||
anthropic-oauth) jq -e '.claudeAiOauth.accessToken' "$2" >/dev/null 2>&1 ;;
|
||||
static)
|
||||
if [ -n "${3:-}" ]; then
|
||||
# A named field has to actually be there. Without this a
|
||||
# ~/.claude.json with no primaryApiKey passes the gate, the
|
||||
# proxy starts, and the first request fails with an
|
||||
# authentication error several layers from the cause.
|
||||
jq -e --arg f "$3" \
|
||||
'getpath($f | split(".")) | strings | length > 0' \
|
||||
"$2" >/dev/null 2>&1
|
||||
else
|
||||
[ -s "$2" ]
|
||||
fi
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Which shipped backends could serve a credential that is actually on this
|
||||
# host, for the harness this launch selected.
|
||||
#
|
||||
# Refusing without this leaves an operator guessing between backends by name,
|
||||
# which is exactly the report that produced it: an "anthropic" that wanted a
|
||||
# subscription login, and no sign of which backend served the login they
|
||||
# actually had. Derived rather than listed, so a backend added later is
|
||||
# suggested the day it lands.
|
||||
#
|
||||
# Only ones the harness can talk to, since naming a backend it has no client
|
||||
# for trades one refusal for another. And only ones whose credential_file
|
||||
# resolves to something: a backend whose key has no conventional location
|
||||
# cannot be confirmed present, and guessing would be the same unhelpful
|
||||
# advice in the other direction.
|
||||
_backends_that_fit() {
|
||||
local b kind file field styles
|
||||
styles="$(manifest_get harness "$DEJIMA_HARNESS_NAME" api_styles)"
|
||||
for b in $(provider_list backend); do
|
||||
kind="$(manifest_get backend "$b" credential)"
|
||||
[ -n "$kind" ] && [ "$kind" != none ] || continue
|
||||
if [ -n "$styles" ]; then
|
||||
_set_contains "$styles" "$(manifest_get backend "$b" api_style)" || continue
|
||||
fi
|
||||
config_type "backend.$b.credential_file" >/dev/null 2>&1 || continue
|
||||
file="$(config_get "backend.$b" credential_file)" || continue
|
||||
[ -n "$file" ] || continue
|
||||
field="$(manifest_get backend "$b" credential_field)"
|
||||
_credential_usable "$kind" "$file" "$field" && printf '%s\n' "$b"
|
||||
done
|
||||
}
|
||||
|
||||
# Claude Code can sign in with no stored credential at all, through workload
|
||||
# identity federation: ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID
|
||||
# name the rule, an OIDC identity token comes from ANTHROPIC_IDENTITY_TOKEN or
|
||||
# ANTHROPIC_IDENTITY_TOKEN_FILE, and it exchanges that for an access token by
|
||||
# itself. No backend serves it yet.
|
||||
#
|
||||
# Detected only to explain a refusal. On such a host there is no credential
|
||||
# file for any backend to find, so "no backend fits" is true and useless -
|
||||
# it reads as dejima not seeing a login that is plainly working.
|
||||
_federation_configured() {
|
||||
[ -n "${ANTHROPIC_FEDERATION_RULE_ID:-}" ] \
|
||||
|| [ -n "${ANTHROPIC_IDENTITY_TOKEN:-}" ] \
|
||||
|| [ -n "${ANTHROPIC_IDENTITY_TOKEN_FILE:-}" ]
|
||||
}
|
||||
|
||||
# Prints the "use this one" paragraph, or nothing when nothing fits.
|
||||
_suggest_backends() { # <harness-section-name>
|
||||
local fits
|
||||
fits="$(_backends_that_fit)"
|
||||
if [ -z "$fits" ] && _federation_configured; then
|
||||
echo "" >&2
|
||||
echo "This host is set up for workload identity federation, which signs in" >&2
|
||||
echo "without storing a credential - so there is nothing for a backend to" >&2
|
||||
echo "read, and no backend serves it yet. See issue #290." >&2
|
||||
return 0
|
||||
fi
|
||||
[ -n "$fits" ] || return 0
|
||||
echo "" >&2
|
||||
if [ "$(printf '%s\n' "$fits" | wc -l)" = 1 ]; then
|
||||
echo "A credential for '$fits' is on this host. To use it:" >&2
|
||||
else
|
||||
echo "Credentials for these are on this host:" >&2
|
||||
printf '%s\n' "$fits" | sed 's/^/ /' >&2
|
||||
echo "" >&2
|
||||
echo "To use one:" >&2
|
||||
fi
|
||||
echo "" >&2
|
||||
echo " [harness.$1]" >&2
|
||||
echo " backend = $(printf '%s\n' "$fits" | head -1)" >&2
|
||||
}
|
||||
|
||||
# The selected backend - the model API this session talks to - and the name the
|
||||
# in-container terminator has to answer for.
|
||||
#
|
||||
# Two different things, and it is worth keeping them apart.
|
||||
#
|
||||
# WHICH backend a launch uses is the harness's setting, [harness.<name>]
|
||||
# backend. Per harness because that is how it is actually used: Claude Code
|
||||
# against Anthropic and Cline against somewhere else, in different launches on
|
||||
# the same machine, which one global setting cannot express.
|
||||
#
|
||||
# WHAT that backend looks like is the backend's manifest, including the name
|
||||
# the terminator answers for. It belongs there rather than on the harness: a
|
||||
# harness reaches whichever endpoint it is configured for, so with the name
|
||||
# declared on the harness the terminator would answer for Anthropic while the
|
||||
# session talked somewhere else - and the session would reach that API
|
||||
# directly, with the credential the proxy exists to withhold being the one in
|
||||
# use.
|
||||
# A harness that declares no `backend` key at all names no endpoint for the
|
||||
# proxy to stand in front of, and the launch runs without one. That is the
|
||||
# self-served case: a model on the operator's own network needs no credential
|
||||
# withheld, because there is none. Asked with config_type rather than by
|
||||
# swallowing config_get's error, so a real fault reading a key the harness DOES
|
||||
# declare still stops the launch.
|
||||
DEJIMA_BACKEND_NAME=""
|
||||
SANDBOX_BACKEND_PROXIED_DOMAIN=""
|
||||
DEJIMA_BACKEND_CREDENTIAL_KIND=""
|
||||
DEJIMA_BACKEND_CREDENTIAL_FILE=""
|
||||
DEJIMA_BACKEND_CREDENTIAL_FIELD=""
|
||||
if config_type "harness.$DEJIMA_HARNESS_NAME.backend" >/dev/null 2>&1; then
|
||||
DEJIMA_BACKEND_NAME="$(config_get "harness.$DEJIMA_HARNESS_NAME" backend)"
|
||||
# Unset, and the harness says what it used to talk to: write that down.
|
||||
#
|
||||
# A MIGRATION, NOT A DEFAULT, and the difference is the point. A default is
|
||||
# dejima's answer re-decided on every read, so changing it later moves a
|
||||
# session whose config never changed. This records the operator's answer
|
||||
# once, into their own file; after it runs, nothing dejima decides later
|
||||
# can move them. It is only ever stated by a harness for which the value is
|
||||
# not a guess - what every existing session of that harness already was -
|
||||
# so the launch it runs in behaves identically either way.
|
||||
#
|
||||
# Written INTO the operator's existing section for this harness when there
|
||||
# is one, and appended as a new section only when there is not.
|
||||
#
|
||||
# Appending unconditionally is simpler and produces a config with the same
|
||||
# section header twice, which parses correctly and reads as a mistake
|
||||
# dejima made in the operator's own file. Reported as exactly that.
|
||||
#
|
||||
# Inserting immediately after the header is safe here specifically because
|
||||
# the key is UNSET: there is no later assignment for a line near the top
|
||||
# to lose to, which is the thing that makes editing a config file
|
||||
# dangerous in general.
|
||||
if [ -z "$DEJIMA_BACKEND_NAME" ]; then
|
||||
_assumed="$(manifest_get harness "$DEJIMA_HARNESS_NAME" assumed_backend)"
|
||||
if [ -n "$_assumed" ] && [ -w "$(config_path)" ]; then
|
||||
_cfg_tmp="$(mktemp "$(dirname "$(config_path)")/.config.XXXXXX")" || exit 1
|
||||
awk -v sec="[harness.$DEJIMA_HARNESS_NAME]" \
|
||||
-v line="backend = $_assumed" '
|
||||
{ print }
|
||||
!done {
|
||||
t = $0
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", t)
|
||||
if (t == sec) { print line; done = 1 }
|
||||
}
|
||||
END { if (!done) printf "\n%s\n%s\n", sec, line }
|
||||
' "$(config_path)" > "$_cfg_tmp" || exit 1
|
||||
# The operator's file keeps its own permissions rather than a
|
||||
# fresh mktemp's.
|
||||
chmod --reference="$(config_path)" "$_cfg_tmp" 2>/dev/null || true
|
||||
mv "$_cfg_tmp" "$(config_path)" || exit 1
|
||||
unset _cfg_tmp
|
||||
DEJIMA_BACKEND_NAME="$_assumed"
|
||||
# Its own block, and the line that was written picked out: this
|
||||
# is dejima editing the operator's config, which is the one
|
||||
# startup notice they may want to go and look at.
|
||||
printf '\n %sdejima wrote a line into %s%s\n' \
|
||||
"${C_WARN:-}" "$(config_path)" "${C_RESET:-}" >&2
|
||||
printf ' [harness.%s]\n %sbackend = %s%s\n\n' \
|
||||
"$DEJIMA_HARNESS_NAME" "${C_KEY:-}" "$_assumed" "${C_RESET:-}" >&2
|
||||
printf ' That is what this harness was already talking to, written down so a\n' >&2
|
||||
printf ' later change to dejima cannot move it. Nothing about this launch\n' >&2
|
||||
printf ' differs because of it.\n' >&2
|
||||
DEJIMA_STARTUP_NOTICE=1
|
||||
fi
|
||||
unset _assumed
|
||||
fi
|
||||
|
||||
# Still unset, and nothing could say what it should be: a refusal, not a
|
||||
# default.
|
||||
#
|
||||
# No harness ships one, for the reason [core] harness has none: a default
|
||||
# that could change later is a config file that is not the source of
|
||||
# truth, and a session whose config did not change must not start talking
|
||||
# to a different endpoint because dejima's idea of a sensible one moved.
|
||||
# The risk is concrete rather than theoretical for a harness that supports
|
||||
# many backends and has one of its own.
|
||||
#
|
||||
# Declaring the key is how a harness says it talks to a model API at all,
|
||||
# so this needs no harness name here: one pointed only at a model on the
|
||||
# operator's own network leaves the key out and never reaches this.
|
||||
if [ -z "$DEJIMA_BACKEND_NAME" ]; then
|
||||
echo "[harness.$DEJIMA_HARNESS_NAME] backend is not set, so nothing says which" >&2
|
||||
echo "model API this session talks to." >&2
|
||||
echo "" >&2
|
||||
echo " [harness.$DEJIMA_HARNESS_NAME]" >&2
|
||||
echo " backend = anthropic" >&2
|
||||
echo "" >&2
|
||||
echo "There is deliberately no default: one dejima could change later" >&2
|
||||
echo "would move your session to another endpoint without your config" >&2
|
||||
echo "changing. --backend <name> sets it for a single launch." >&2
|
||||
echo "" >&2
|
||||
printf 'Available:' >&2
|
||||
for _p in $(provider_list backend); do printf ' %s' "$_p" >&2; done
|
||||
printf '\n' >&2
|
||||
_suggest_backends "$DEJIMA_HARNESS_NAME"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "$DEJIMA_BACKEND_NAME" ]; then
|
||||
# A manifest that omits a required key is not caught by manifest_validate,
|
||||
# which only type-checks the keys a file actually has - true of every axis,
|
||||
# not just this one. Left unchecked here, an absent upstream reaches the
|
||||
# proxy as an empty --upstream and fails as a connection error with no
|
||||
# hint of the cause, so the four a backend cannot work without are named
|
||||
# where they are read.
|
||||
_p_kind="$(manifest_get backend "$DEJIMA_BACKEND_NAME" credential)"
|
||||
_p_required="api_style credential"
|
||||
# upstream and proxied_domain describe an endpoint dejima stands in front
|
||||
# of, so they are required of a proxied backend and meaningless to a
|
||||
# direct one - which reaches a model the operator serves, with nothing to
|
||||
# withhold and so no proxy at all.
|
||||
[ "$_p_kind" = none ] || _p_required="$_p_required upstream proxied_domain"
|
||||
for _need in $_p_required; do
|
||||
if [ -z "$(manifest_get backend "$DEJIMA_BACKEND_NAME" "$_need")" ]; then
|
||||
echo "backend '$DEJIMA_BACKEND_NAME' declares no $_need, so dejima cannot" >&2
|
||||
echo "tell where its API is or how to speak to it." >&2
|
||||
echo "See docs/providers.md, \"Model backends\", for what a manifest needs." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# A harness speaks one wire format or a few, and a backend speaks one.
|
||||
# Pairing them wrongly is knowable here and produces nothing but confusion
|
||||
# later: well-formed requests in a shape the endpoint does not parse, or a
|
||||
# harness writing a configuration for a client it does not have.
|
||||
_p_style="$(manifest_get backend "$DEJIMA_BACKEND_NAME" api_style)"
|
||||
_h_styles="$(manifest_get harness "$DEJIMA_HARNESS_NAME" api_styles)"
|
||||
if [ -n "$_h_styles" ] && ! _set_contains "$_h_styles" "$_p_style"; then
|
||||
echo "harness '$DEJIMA_HARNESS_NAME' cannot talk to backend '$DEJIMA_BACKEND_NAME'." >&2
|
||||
echo "The backend speaks the '$_p_style' API; this harness speaks: $_h_styles." >&2
|
||||
echo "" >&2
|
||||
echo "Pick a backend it can talk to, or a harness that speaks '$_p_style'." >&2
|
||||
exit 1
|
||||
fi
|
||||
# What the backend says it cannot work without. Refused here rather than
|
||||
# left to the proxy's own startup check, because an unset credential_file
|
||||
# does not fail the proxy - it skips it, and the session then reaches a
|
||||
# real endpoint holding a placeholder, which fails as an authentication
|
||||
# error several layers from the cause.
|
||||
for _need in $(manifest_get backend "$DEJIMA_BACKEND_NAME" requires); do
|
||||
if [ -z "$(config_get "backend.$DEJIMA_BACKEND_NAME" "$_need")" ]; then
|
||||
echo "backend '$DEJIMA_BACKEND_NAME' needs [backend.$DEJIMA_BACKEND_NAME] $_need, which is not set." >&2
|
||||
echo "" >&2
|
||||
echo " [backend.$DEJIMA_BACKEND_NAME]" >&2
|
||||
case "$_need" in
|
||||
*_file)
|
||||
echo " $_need = /path/to/your/key" >&2
|
||||
echo "" >&2
|
||||
echo "The file stays on the host and is read per request; it is" >&2
|
||||
echo "never mounted into the container." >&2
|
||||
;;
|
||||
base_url)
|
||||
echo " $_need = http://127.0.0.1:8080/v1" >&2
|
||||
echo "" >&2
|
||||
echo "Where you serve the model. There is deliberately no default:" >&2
|
||||
echo "whatever is at some other address is not your model." >&2
|
||||
;;
|
||||
*) echo " $_need = ..." >&2 ;;
|
||||
esac
|
||||
# A backend asking to be pointed at a key is the other half of the
|
||||
# report: an operator who picked this one by its name, when the
|
||||
# login they actually have is served by a different backend whose
|
||||
# credential is already where dejima can see it.
|
||||
_suggest_backends "$DEJIMA_HARNESS_NAME"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
unset _p_style _h_styles _need _p_required
|
||||
SANDBOX_BACKEND_PROXIED_DOMAIN="$(manifest_get backend "$DEJIMA_BACKEND_NAME" proxied_domain)"
|
||||
DEJIMA_BACKEND_CREDENTIAL_KIND="$(manifest_get backend "$DEJIMA_BACKEND_NAME" credential)"
|
||||
if config_type "backend.$DEJIMA_BACKEND_NAME.credential_file" >/dev/null 2>&1; then
|
||||
DEJIMA_BACKEND_CREDENTIAL_FILE="$(config_get "backend.$DEJIMA_BACKEND_NAME" credential_file)"
|
||||
fi
|
||||
# The operator's, not the manifest's: one static arrangement serves both
|
||||
# a key inside a JSON document and a file that is nothing but the key.
|
||||
if config_type "backend.$DEJIMA_BACKEND_NAME.credential_field" >/dev/null 2>&1; then
|
||||
DEJIMA_BACKEND_CREDENTIAL_FIELD="$(config_get "backend.$DEJIMA_BACKEND_NAME" credential_field)"
|
||||
fi
|
||||
|
||||
fi
|
||||
# Everything staged for the container, checked before anything is put back
|
||||
# into it. Placed here rather than beside the copies so a finding can name
|
||||
# the harness and backend it belongs to, which is most of a useful report.
|
||||
# The tildes are deliberate and must not expand: this argument is the name to
|
||||
# SHOW an operator and to key their decisions on, not a path to open. Keyed on
|
||||
# the label rather than the temp path so a decision survives the next launch,
|
||||
# which stages the same file somewhere else entirely.
|
||||
# shellcheck disable=SC2088
|
||||
_scan_staged_file "$SANDBOX_CLAUDE_JSON_FILE" json '~/.claude.json' || exit 1
|
||||
# shellcheck disable=SC2088
|
||||
_scan_staged_file "$SANDBOX_GITCONFIG_DIR/.gitconfig" ini '~/.gitconfig' || exit 1
|
||||
|
||||
# Runs after the scan above, deliberately. The scan reads the safe copy and
|
||||
# asks about anything credential-shaped in it; this then puts a credential
|
||||
# back, because the operator asked for exactly that. Re-seeding first would
|
||||
# have the scanner ask about the thing they just chose.
|
||||
if [ -n "$DEJIMA_BACKEND_NAME" ]; then
|
||||
# The deliberate downgrade, for a backend whose credential lives IN the
|
||||
# file dejima stages.
|
||||
#
|
||||
# ~/.claude.json is copied in with its credential field stripped, which is
|
||||
# what keeps a console login's key on the host. With the proxy turned off
|
||||
# for this backend the operator has asked for the opposite, and stripping
|
||||
# anyway would leave the session with no credential and no way to supply
|
||||
# one - the file is a throwaway copy, so there is nothing else to mount.
|
||||
#
|
||||
# Re-seeded rather than never stripped, so the safe form is what every
|
||||
# other path gets and this is one explicit step away from it.
|
||||
if [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
|
||||
&& [ -n "$DEJIMA_BACKEND_CREDENTIAL_FIELD" ] \
|
||||
&& ! backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
|
||||
&& [ -f "$HOME/.claude.json" ] \
|
||||
&& [ "$(readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE")" \
|
||||
= "$(readlink -m "$HOME/.claude.json")" ]; then
|
||||
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
echo "[backend.$DEJIMA_BACKEND_NAME] proxy is off, so this session gets your real" >&2
|
||||
echo "$DEJIMA_BACKEND_CREDENTIAL_FIELD from $HOME/.claude.json." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# The two ways a briefing reaches a harness that is not handed it by a
|
||||
# session-start hook. A hook cannot carry a large one: Claude Code persists a
|
||||
# big payload to a file and injects a short preview in its place - measured in
|
||||
@@ -1375,7 +1923,7 @@ SANDBOX_HARNESS_BRIEFING_ARGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" bri
|
||||
SANDBOX_HARNESS_BRIEFING_PATH="$(manifest_get harness "$DEJIMA_HARNESS_NAME" briefing_path)"
|
||||
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
|
||||
SANDBOX_HARNESS_SETTINGS SANDBOX_HARNESS_DOMAINS \
|
||||
SANDBOX_HARNESS_PROXIED_DOMAIN SANDBOX_HARNESS_BRIEFING_ARGS \
|
||||
SANDBOX_BACKEND_PROXIED_DOMAIN SANDBOX_HARNESS_BRIEFING_ARGS \
|
||||
SANDBOX_HARNESS_BRIEFING_PATH
|
||||
|
||||
# [core] command defaults to the selected harness's own command rather than
|
||||
@@ -1672,29 +2220,117 @@ export SANDBOX_HARNESS_SEED
|
||||
SANDBOX_ANTHROPIC_TRANSPORT="$(config_get credentials transport)"
|
||||
SANDBOX_ANTHROPIC_BASE_URL=""
|
||||
SANDBOX_ANTHROPIC_AUTH_TOKEN=""
|
||||
SANDBOX_ANTHROPIC_API_KEY=""
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET=""
|
||||
SANDBOX_ANTHROPIC_PROXY_DIR=""
|
||||
SANDBOX_FAKE_CREDENTIALS_FILE=""
|
||||
SANDBOX_ANTHROPIC_PROXY_PID=""
|
||||
if [ "$(config_get_bool credentials proxy)" != "0" ] \
|
||||
&& jq -e '.claudeAiOauth.accessToken' "$HOME/.claude/.credentials.json" >/dev/null 2>&1; then
|
||||
# Whether this provider's credential is in a state the proxy can use.
|
||||
#
|
||||
# Not "does the file exist", and the answer belongs to the provider's kind
|
||||
# rather than to one fixed check.
|
||||
#
|
||||
# For anthropic-oauth it is whether ~/.claude/.credentials.json holds a
|
||||
# claudeAiOauth block. A Claude Code set up with a console key rather than a
|
||||
# login has none, and there is then no OAuth token for the proxy to inject or
|
||||
# refresh - so selecting this backend for that setup falls through to mounting
|
||||
# the real credential file, which is the one arrangement where dejima's own
|
||||
# credential enters the container. The backend for that setup is
|
||||
# anthropic-api-key, which keeps the key on the host like any other.
|
||||
#
|
||||
# For a static key it is whether the file has anything in it.
|
||||
#
|
||||
|
||||
# Asked for the proxy, selected a backend, and the credential is not one that
|
||||
# backend can serve: refuse.
|
||||
#
|
||||
# The fallthrough below mounts the real credential file, which is the right
|
||||
# answer when the operator asked for it with `proxy = off` and the wrong one
|
||||
# here - it silently moves a launch from the strongest posture to the weakest,
|
||||
# and the session still works, so nothing about the result says it happened.
|
||||
# A credential shape dejima does not recognise is exactly when that is most
|
||||
# likely: a Claude Code signed in with a console API token has no
|
||||
# claudeAiOauth block, so the anthropic backend cannot serve it and every
|
||||
# launch quietly mounted the real key.
|
||||
#
|
||||
# Refusing rather than warning, because docs/security.md describes the mounted
|
||||
# arrangement as a kill switch rather than an option, and a kill switch that
|
||||
# can engage itself is not one.
|
||||
#
|
||||
# Scoped to a credential that EXISTS and cannot be served. An absent one is
|
||||
# not this: nothing is mounted, nothing is downgraded, and the launch is
|
||||
# someone who has not logged in on the host yet.
|
||||
if [ -n "$DEJIMA_BACKEND_NAME" ] \
|
||||
&& [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
|
||||
&& backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
|
||||
&& [ -s "$DEJIMA_BACKEND_CREDENTIAL_FILE" ] \
|
||||
&& ! _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
|
||||
echo "backend '$DEJIMA_BACKEND_NAME' cannot serve the credential in" >&2
|
||||
echo "$DEJIMA_BACKEND_CREDENTIAL_FILE." >&2
|
||||
echo "" >&2
|
||||
case "$DEJIMA_BACKEND_CREDENTIAL_KIND" in
|
||||
anthropic-oauth)
|
||||
echo "It expects a subscription login - a claudeAiOauth block - and that" >&2
|
||||
echo "file has none. A Claude Code signed in with a console API token" >&2
|
||||
echo "stores it differently." >&2
|
||||
;;
|
||||
static)
|
||||
echo "It expects a key, and that file is empty or unreadable." >&2
|
||||
;;
|
||||
esac
|
||||
_suggest_backends "$DEJIMA_HARNESS_NAME"
|
||||
echo "" >&2
|
||||
echo "Launching as configured would mount that file into the container," >&2
|
||||
echo "which is the one arrangement where your real credential leaves the" >&2
|
||||
echo "host. If you want that deliberately:" >&2
|
||||
echo "" >&2
|
||||
echo " [backend.$DEJIMA_BACKEND_NAME]" >&2
|
||||
echo " proxy = off" >&2
|
||||
echo "" >&2
|
||||
echo "which turns the proxy off for this backend only. [credentials]" >&2
|
||||
echo "proxy = off turns it off for every backend, including ones it" >&2
|
||||
echo "serves." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A direct backend runs no proxy: there is no credential to withhold, so
|
||||
# there is nothing for one to do. That is the self-served case, and it is the
|
||||
# arrangement with nothing in the container to steal.
|
||||
if [ -n "$DEJIMA_BACKEND_NAME" ] \
|
||||
&& [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
|
||||
&& backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
|
||||
&& _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
|
||||
SANDBOX_ANTHROPIC_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SANDBOX_ANTHROPIC_PROXY_DIR/proxy.sock"
|
||||
# Every Anthropic-shaped value is stated here rather than defaulted in the
|
||||
# proxy, which now knows no provider's name (#171). When the provider layer
|
||||
# lands these come from a manifest instead; until then this is the one
|
||||
# caller and this is where the arrangement lives.
|
||||
"$SCRIPT_DIR/dejima-credential-proxy" \
|
||||
--socket "$SANDBOX_ANTHROPIC_PROXY_SOCKET" \
|
||||
--credentials "$HOME/.claude/.credentials.json" \
|
||||
--upstream "https://api.anthropic.com" \
|
||||
--credential-kind anthropic-oauth \
|
||||
--oauth-endpoint "https://platform.claude.com/v1/oauth/token" \
|
||||
--oauth-client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e" \
|
||||
--auth-header Authorization \
|
||||
--auth-format "Bearer {token}" \
|
||||
--strip-header x-api-key \
|
||||
--ensure-header "anthropic-beta=oauth-2025-04-20" &
|
||||
# Every value comes from backends/<name>/backend.conf. Nothing about a
|
||||
# backend is written here, which is what makes adding one a directory
|
||||
# rather than an edit to this file.
|
||||
_proxy_args=(
|
||||
--socket "$SANDBOX_ANTHROPIC_PROXY_SOCKET"
|
||||
--credentials "$DEJIMA_BACKEND_CREDENTIAL_FILE"
|
||||
--upstream "$(manifest_get backend "$DEJIMA_BACKEND_NAME" upstream)"
|
||||
--credential-kind "$DEJIMA_BACKEND_CREDENTIAL_KIND"
|
||||
)
|
||||
[ -z "$DEJIMA_BACKEND_CREDENTIAL_FIELD" ] \
|
||||
|| _proxy_args+=(--credential-field "$DEJIMA_BACKEND_CREDENTIAL_FIELD")
|
||||
_proxy_args+=(
|
||||
--auth-header "$(manifest_get backend "$DEJIMA_BACKEND_NAME" auth_header)"
|
||||
--auth-format "$(manifest_get backend "$DEJIMA_BACKEND_NAME" auth_format)"
|
||||
)
|
||||
# Word-split on purpose: a manifest list is space-separated and every
|
||||
# element is type-checked, so neither of these can carry a space.
|
||||
for _h in $(manifest_get backend "$DEJIMA_BACKEND_NAME" strip_headers); do
|
||||
_proxy_args+=(--strip-header "$_h")
|
||||
done
|
||||
for _h in $(manifest_get backend "$DEJIMA_BACKEND_NAME" ensure_headers); do
|
||||
_proxy_args+=(--ensure-header "$_h")
|
||||
done
|
||||
_oauth_endpoint="$(manifest_get backend "$DEJIMA_BACKEND_NAME" oauth_endpoint)"
|
||||
_oauth_client_id="$(manifest_get backend "$DEJIMA_BACKEND_NAME" oauth_client_id)"
|
||||
[ -z "$_oauth_endpoint" ] || _proxy_args+=(--oauth-endpoint "$_oauth_endpoint")
|
||||
[ -z "$_oauth_client_id" ] || _proxy_args+=(--oauth-client-id "$_oauth_client_id")
|
||||
unset _h _oauth_endpoint _oauth_client_id
|
||||
"$SCRIPT_DIR/dejima-credential-proxy" "${_proxy_args[@]}" &
|
||||
SANDBOX_ANTHROPIC_PROXY_PID=$!
|
||||
for _ in $(seq 1 50); do
|
||||
[ -S "$SANDBOX_ANTHROPIC_PROXY_SOCKET" ] && break
|
||||
@@ -1713,6 +2349,45 @@ if [ "$(config_get_bool credentials proxy)" != "0" ] \
|
||||
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "loopback" ]; then
|
||||
SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"
|
||||
SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"
|
||||
elif [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" = static ]; then
|
||||
# API billing: the session authenticates with a key rather than a
|
||||
# login, so there is no credential FILE to fake - Claude Code reads
|
||||
# ANTHROPIC_API_KEY, and a placeholder there is the whole of what the
|
||||
# container needs. The host-side proxy swaps it for the real key per
|
||||
# request, the same arrangement the OAuth path has.
|
||||
#
|
||||
# Set here rather than by the harness plugin because it reaches the
|
||||
# container through compose, which is core's side of the boundary.
|
||||
# It is Anthropic-shaped, like the two variables beside it, and a
|
||||
# second backend wanting a different variable is the point at which
|
||||
# this becomes a declaration rather than a branch.
|
||||
SANDBOX_ANTHROPIC_API_KEY="sandbox-credential-proxy-placeholder"
|
||||
# Pre-approve it, so the session does not open on a prompt whose
|
||||
# recommended answer breaks it.
|
||||
#
|
||||
# Claude Code asks about any ANTHROPIC_API_KEY it did not put there
|
||||
# itself - "Do you want to use this API key?", defaulting to "No
|
||||
# (recommended)" - and records the answer as the last 20 characters
|
||||
# of the key in customApiKeyResponses.approved. Answering the
|
||||
# recommended way leaves an unattended session with no credential,
|
||||
# which is a worse failure than the prompt it avoids.
|
||||
#
|
||||
# Derived from the value rather than written as a literal, so it
|
||||
# follows if the placeholder ever changes. If Claude Code changes how
|
||||
# it records approvals this stops suppressing the prompt and nothing
|
||||
# else - it is a courtesy, not a control.
|
||||
if [ -n "$SANDBOX_CLAUDE_JSON_FILE" ] && [ -f "$SANDBOX_CLAUDE_JSON_FILE" ]; then
|
||||
_ak_seen="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
|
||||
if jq --arg t "${SANDBOX_ANTHROPIC_API_KEY: -20}" \
|
||||
'.customApiKeyResponses.approved =
|
||||
(((.customApiKeyResponses.approved // []) + [$t]) | unique)' \
|
||||
"$SANDBOX_CLAUDE_JSON_FILE" > "$_ak_seen"; then
|
||||
mv "$_ak_seen" "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
else
|
||||
rm -f "$_ak_seen"
|
||||
fi
|
||||
unset _ak_seen
|
||||
fi
|
||||
else
|
||||
# With no ANTHROPIC_AUTH_TOKEN set, Claude Code authenticates from
|
||||
# .credentials.json directly, so the container needs a well-formed
|
||||
@@ -1751,6 +2426,7 @@ elif [ -f "$HOME/.claude/.credentials.json" ]; then
|
||||
extra_mounts+=(-v "$HOME/.claude/.credentials.json:$HOME/.claude/.credentials.json")
|
||||
fi
|
||||
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_ANTHROPIC_PROXY_SOCKET
|
||||
export SANDBOX_ANTHROPIC_API_KEY
|
||||
export SANDBOX_ANTHROPIC_TRANSPORT
|
||||
|
||||
# Optional remote access (see docs/remote-access.md). Entirely skipped
|
||||
@@ -1969,7 +2645,7 @@ startup_banner() {
|
||||
}
|
||||
startup_banner
|
||||
|
||||
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
||||
printf '\n%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
||||
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
|
||||
"$C_DIM" "$C_RESET" >&2
|
||||
|
||||
@@ -1985,12 +2661,34 @@ harness_report_guards "$DEJIMA_HARNESS_NAME"
|
||||
# after it with no briefing and no guards, and nothing said so. The project's
|
||||
# own standard is that a silently narrower run is worse than a noisy one.
|
||||
if ! _unwired="$(harness_guards_wired)"; then
|
||||
printf ' %snot registered for %s, so not in force this session:%s\n' \
|
||||
printf '\n %snot registered for %s, so not in force this session:%s\n' \
|
||||
"$C_WARN" "$DEJIMA_HARNESS_NAME" "$C_RESET" >&2
|
||||
printf '%s\n' "$_unwired" | while IFS= read -r _u; do
|
||||
[ -n "$_u" ] && printf ' %s%s%s\n' "$C_KEY" "$_u" "$C_RESET" >&2
|
||||
done
|
||||
printf ' Run %s./install.sh%s to register them.\n' "$C_KEY" "$C_RESET" >&2
|
||||
DEJIMA_STARTUP_NOTICE=1
|
||||
fi
|
||||
|
||||
# Everything said so far is about the operator's configuration and this
|
||||
# session; every line after this is the image build, which on a cold cache is
|
||||
# thousands of lines of Docker output. A notice that lands here scrolls away
|
||||
# before it can be read, which is the same reason the exit audit prints its
|
||||
# findings LAST.
|
||||
#
|
||||
# Only when something was actually said, and only with someone there to read
|
||||
# it: an unattended launch must not block on a keystroke nobody will press.
|
||||
# The orphaned-share list deliberately does not raise this - it is a standing
|
||||
# condition rather than something about this run, and pausing every launch
|
||||
# until it is cleared would teach the operator to press Enter without looking,
|
||||
# which is the failure this exists to avoid.
|
||||
if [ "${DEJIMA_STARTUP_NOTICE:-0}" = 1 ] && [ "${NO_PAUSE:-0}" != 1 ] \
|
||||
&& [ -t 0 ] && [ -t 2 ]; then
|
||||
printf '\n %sThe build output below will scroll that away.%s ' \
|
||||
"${C_DIM:-}" "${C_RESET:-}" >&2
|
||||
printf '%sPress Enter to continue.%s ' "${C_KEY:-}" "${C_RESET:-}" >&2
|
||||
read -r _ </dev/tty || true
|
||||
printf '\n' >&2
|
||||
fi
|
||||
|
||||
# Before the image build, which is the long part: an operator who wants the
|
||||
|
||||
+5
-1
@@ -970,7 +970,11 @@ tcb_snapshot() {
|
||||
# missed when that layer landed: a tool.conf names a tarball URL and
|
||||
# the sha256 checked against it, and the host's image build installs
|
||||
# what it points at as root.
|
||||
for dir in bin hooks docker test forges workflows harnesses tools; do
|
||||
#
|
||||
# backends/ likewise: a backend.conf names the upstream every request
|
||||
# is forwarded to and the host path the credential is read from, so an
|
||||
# edit here redirects where a real credential is sent.
|
||||
for dir in bin hooks docker test forges workflows harnesses tools backends; do
|
||||
if [ -d "$ws/$dir" ]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
stage_entry "tcb:${f#"$ws"/}" "$f"
|
||||
|
||||
@@ -165,6 +165,30 @@ _config_keys() {
|
||||
_config_extra_keys
|
||||
}
|
||||
|
||||
# A key that used to exist and now lives somewhere else.
|
||||
#
|
||||
# "unknown key" is true and useless on its own: an operator who wrote a key
|
||||
# dejima once accepted has no way to tell a typo from a move, and the answer
|
||||
# is in a document they have no reason to think they need. A rename that
|
||||
# ships without this costs every operator who had the old key the same search.
|
||||
#
|
||||
# Keep this to keys that dejima ITSELF moved. A key nobody ever accepted is a
|
||||
# typo, and guessing at what it was meant to be is worse than saying nothing.
|
||||
_config_moved_hint() { # <section> <key>
|
||||
local section="$1" key="$2"
|
||||
case "$section.$key" in
|
||||
harness.*.base_url|harness.*base_url)
|
||||
printf ' Where the model is belongs to the backend now, not the harness.\n' >&2
|
||||
printf ' For one you serve yourself:\n\n' >&2
|
||||
printf ' [harness.%s]\n backend = self-hosted\n\n' \
|
||||
"${section#harness.}" >&2
|
||||
printf ' [backend.self-hosted]\n base_url = ...\n\n' >&2
|
||||
printf ' A hosted API is a backend of its own instead, which carries its own\n' >&2
|
||||
printf ' address - see docs/providers.md, "Model backends".\n' >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
config_root() {
|
||||
printf '%s' "${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}"
|
||||
}
|
||||
@@ -371,6 +395,14 @@ _config_valid_value() {
|
||||
identifier)
|
||||
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
|
||||
;;
|
||||
jsonfield)
|
||||
# Which value inside a credentials file is the key: dot-separated
|
||||
# object names, nothing else. No array indices and no jq syntax,
|
||||
# because the credential proxy walks these segments itself and a
|
||||
# value that could name an expression would be one that could name
|
||||
# a computation.
|
||||
[[ "$value" =~ ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$ ]]
|
||||
;;
|
||||
text)
|
||||
# A sentence rather than a token - a person's name, an email
|
||||
# address, a one-line description. An identifier has no room for a
|
||||
@@ -524,6 +556,7 @@ _config_parse() {
|
||||
_config_trim_into value "${line#*=}"
|
||||
if ! _config_type_into type "$section.$key"; then
|
||||
echo "unknown key in $conf: [$section] $key" >&2
|
||||
_config_moved_hint "$section" "$key"
|
||||
return 1
|
||||
fi
|
||||
if ! _config_valid_value "$type" "$value"; then
|
||||
|
||||
+43
-18
@@ -7,18 +7,18 @@
|
||||
# credential, so the credential itself never enters the container. See
|
||||
# docs/security.md.
|
||||
#
|
||||
# NOTHING HERE NAMES A PROVIDER. Which upstream, which credential, how it is
|
||||
# NOTHING HERE NAMES A BACKEND. Which upstream, which credential, how it is
|
||||
# read, which header carries it and what shape that header takes are all
|
||||
# arguments (#171). The Anthropic arrangement this started as is one set of
|
||||
# those arguments and no longer a special case in the code, because the moment
|
||||
# a second provider exists, a proxy that knows one provider's name has to grow
|
||||
# a second backend exists, a proxy that knows one backend's name has to grow
|
||||
# a branch for every other.
|
||||
#
|
||||
# Two credential kinds, and the difference is refresh. A static key is read and
|
||||
# sent. An OAuth credential expires, so it is refreshed against an endpoint and
|
||||
# the rotated token written back to the host file atomically - which is what
|
||||
# keeps a host-side session logged in rather than being signed out by a sandbox
|
||||
# run. A provider says which kind it has; this decides nothing on its own.
|
||||
# run. A backend says which kind it has; this decides nothing on its own.
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
@@ -44,7 +44,7 @@ HOP_BY_HOP = {
|
||||
# its own. Everything the client did send keeps the client's casing; these are
|
||||
# only consulted for a header being added rather than replaced. The auth and
|
||||
# ensure-header names are added to this at startup, in the spelling the
|
||||
# provider declared, for the same reason.
|
||||
# backend declared, for the same reason.
|
||||
SUPPLIED_NAMES = {
|
||||
"host": "Host",
|
||||
"content-length": "Content-Length",
|
||||
@@ -60,13 +60,35 @@ class StaticCredential:
|
||||
# the header block. Refused rather than trimmed if anything is left that
|
||||
# cannot appear in a header, since a key that needs sanitising is a key
|
||||
# that was pasted wrong, and guessing at it would send a mangled
|
||||
# credential upstream and report the provider's rejection as the fault.
|
||||
def __init__(self, path):
|
||||
# credential upstream and report the backend's rejection as the fault.
|
||||
# A field, when the key is one value inside a document rather than the
|
||||
# whole file. Claude Code signed in with a console API token keeps its key
|
||||
# as primaryApiKey in ~/.claude.json, beside unrelated app state, so a
|
||||
# backend has to be able to say which value is the credential.
|
||||
def __init__(self, path, field=None):
|
||||
self.path = path
|
||||
self.field = field
|
||||
|
||||
def token(self):
|
||||
with open(self.path) as f:
|
||||
value = f.read().strip()
|
||||
raw = f.read()
|
||||
if self.field is None:
|
||||
value = raw.strip()
|
||||
else:
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise RuntimeError("%s is not JSON: %s" % (self.path, e))
|
||||
cur = doc
|
||||
for seg in self.field.split("."):
|
||||
if not isinstance(cur, dict) or seg not in cur:
|
||||
raise RuntimeError(
|
||||
"%s has no %s" % (self.path, self.field))
|
||||
cur = cur[seg]
|
||||
if not isinstance(cur, str):
|
||||
raise RuntimeError(
|
||||
"%s: %s is not a string" % (self.path, self.field))
|
||||
value = cur.strip()
|
||||
if not value:
|
||||
raise RuntimeError("%s is empty" % self.path)
|
||||
bad = [c for c in value if ord(c) < 0x20 or ord(c) == 0x7F]
|
||||
@@ -188,7 +210,7 @@ class OAuthCredential:
|
||||
|
||||
|
||||
class AuthShape:
|
||||
"""How a provider wants its credential presented.
|
||||
"""How a backend wants its credential presented.
|
||||
|
||||
header/format are the injection; strip is what must not travel beside it;
|
||||
ensure is a comma-list header that has to contain a value, used for the
|
||||
@@ -313,7 +335,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
seen = set()
|
||||
for name, value in self.headers.items():
|
||||
low = name.lower()
|
||||
# The strip set is the provider's, not a fixed list. It exists so a
|
||||
# The strip set is the backend's, not a fixed list. It exists so a
|
||||
# header the client used for a credential cannot travel alongside
|
||||
# the one this proxy injects: x-api-key beside an Authorization is
|
||||
# two answers to the same question, and which one an API prefers is
|
||||
@@ -332,7 +354,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# Only names registered above are emitted, so a value put in `supplied`
|
||||
# whose name is not in SUPPLIED_NAMES is computed and then silently
|
||||
# dropped. That is deliberate - it is what keeps this pass from
|
||||
# inventing a header the provider never declared - but it means the two
|
||||
# inventing a header the backend never declared - but it means the two
|
||||
# have to be populated from the same place, which is why main() derives
|
||||
# SUPPLIED_NAMES from the auth header and the ensure list rather than
|
||||
# listing them.
|
||||
@@ -450,20 +472,23 @@ def main():
|
||||
p.add_argument("--socket", required=True)
|
||||
p.add_argument("--credentials", required=True)
|
||||
# No default for any of these, deliberately. A default upstream or a
|
||||
# default credential kind would be one provider's arrangement wearing the
|
||||
# name of every provider's, which is the shape #171 was filed about: the
|
||||
# caller says which provider this is, and this program has no opinion.
|
||||
# default credential kind would be one backend's arrangement wearing the
|
||||
# name of every backend's, which is the shape #171 was filed about: the
|
||||
# caller says which backend this is, and this program has no opinion.
|
||||
p.add_argument("--upstream", required=True)
|
||||
p.add_argument("--credential-kind", required=True,
|
||||
choices=("static", "anthropic-oauth"))
|
||||
# Which value in the credentials file is the key, for the static kind.
|
||||
# Absent means the whole file is the key.
|
||||
p.add_argument("--credential-field", default=None)
|
||||
p.add_argument("--auth-header", default="Authorization")
|
||||
# "{token}" rather than a scheme name, so a provider that wants something
|
||||
# "{token}" rather than a scheme name, so a backend that wants something
|
||||
# other than "Bearer x" - a bare key, a prefix of its own - needs no code
|
||||
# here.
|
||||
p.add_argument("--auth-format", default="Bearer {token}")
|
||||
# Repeatable, and replacing rather than adding to a default: a provider
|
||||
# Repeatable, and replacing rather than adding to a default: a backend
|
||||
# states what must not travel beside its credential, and inheriting
|
||||
# another provider's list would be a guess.
|
||||
# another backend's list would be a guess.
|
||||
p.add_argument("--strip-header", action="append", default=[],
|
||||
metavar="NAME")
|
||||
p.add_argument("--ensure-header", action="append", default=[],
|
||||
@@ -498,7 +523,7 @@ def main():
|
||||
creds = OAuthCredential(args.credentials, args.oauth_endpoint,
|
||||
args.oauth_client_id, args.refresh_margin)
|
||||
else:
|
||||
creds = StaticCredential(args.credentials)
|
||||
creds = StaticCredential(args.credentials, args.credential_field)
|
||||
|
||||
try:
|
||||
creds.startup_check()
|
||||
@@ -510,7 +535,7 @@ def main():
|
||||
|
||||
auth = AuthShape(args.auth_header, args.auth_format,
|
||||
args.strip_header, ensure)
|
||||
# In the spelling the provider declared, for headers this has to add
|
||||
# In the spelling the backend declared, for headers this has to add
|
||||
# rather than replace. A client that sent its own keeps its own casing.
|
||||
SUPPLIED_NAMES[auth.header.lower()] = auth.header
|
||||
for name, _ in ensure:
|
||||
|
||||
Executable
+367
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
# Finds credential-shaped values in the host files dejima copies into a
|
||||
# container, and prints what it found WITHOUT printing any value.
|
||||
#
|
||||
# This exists because the alternative does not scale. Two leaks were found by
|
||||
# hand in one week - a console API key in ~/.claude.json, credential helpers
|
||||
# in ~/.gitconfig - and both fixes were denylists: they encode exactly the
|
||||
# knowledge dejima did not have the week before. A harness's authentication is
|
||||
# not a stable interface, so the next field added leaks silently unless
|
||||
# something is looking (#291).
|
||||
#
|
||||
# NOTHING HERE DECIDES ANYTHING. It classifies and reports; bin/dejima asks
|
||||
# the operator and applies the answer. Keeping the judgement out of here is
|
||||
# what lets the same classifier run in a test without a terminal.
|
||||
#
|
||||
# THE ONE INVARIANT: a credential value never leaves this program. Not in a
|
||||
# finding, not in an error, not in a traceback. What a caller gets is a
|
||||
# redacted path and a shape name - enough to report a bug to whoever ships the
|
||||
# harness, and useless to anyone who intercepts it.
|
||||
#
|
||||
# Which is why the caller never sees a real path either. In a gitconfig the
|
||||
# userinfo sits in the SECTION HEADER, so `url.https://me:PAT@github.com/` is
|
||||
# a path that IS a credential. Findings carry a redacted path for a human and
|
||||
# an opaque id for matching, and --scrub takes ids back - so real paths stay
|
||||
# inside this program, which is the only place that has to hold them.
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Anything that looks like credentials in a URL, wherever it appears.
|
||||
USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)(?P<user>[^/@\s:]*):"
|
||||
r"[^/@\s]+@")
|
||||
|
||||
|
||||
def redact(text):
|
||||
"""A path an operator can recognise, with any embedded secret removed."""
|
||||
return USERINFO.sub(lambda m: "%s%s:***@" % (m.group("scheme"),
|
||||
m.group("user")), text)
|
||||
|
||||
|
||||
def finding_id(label, path):
|
||||
"""Stable across runs, and reveals nothing about what it identifies.
|
||||
|
||||
Keyed on the real path, so a decision survives the credential itself
|
||||
rotating - the operator answered about a field, not about a value."""
|
||||
h = hashlib.sha256(("%s\t%s" % (label, path)).encode("utf-8"))
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
# Vendor prefixes, which are worth naming separately from the heuristics: a
|
||||
# match here is not a guess, and the shape name tells an operator exactly what
|
||||
# they are looking at without showing it to them.
|
||||
KNOWN_SHAPES = (
|
||||
("anthropic-api-key", re.compile(r"sk-ant-")),
|
||||
("openrouter-key", re.compile(r"sk-or-")),
|
||||
("openai-key", re.compile(r"^sk-[A-Za-z0-9]{20,}$")),
|
||||
("github-token", re.compile(r"(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{8,}|github_pat_")),
|
||||
("slack-token", re.compile(r"xox[baprs]-")),
|
||||
("aws-access-key-id", re.compile(r"^AKIA[0-9A-Z]{16}$")),
|
||||
("google-api-key", re.compile(r"^AIza[0-9A-Za-z_-]{35}$")),
|
||||
("jwt", re.compile(r"^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.")),
|
||||
("private-key-pem", re.compile(r"^-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
||||
("auth-header-value", re.compile(r"(?i)(^|:\s*)(basic|bearer)\s+\S{8,}")),
|
||||
("url-userinfo", re.compile(r"^[a-z][a-z0-9+.-]*://[^/@\s]+:[^/@\s]+@")),
|
||||
)
|
||||
|
||||
# Names that say "this is a secret", matched as WHOLE WORDS rather than as
|
||||
# substrings. Substring matching put `auth` inside `oauthAccount` and flagged
|
||||
# every field of the signed-in account's description, and `tengu_mcp_local_
|
||||
# oauth_blocked_hosts` along with them.
|
||||
SUSPECT_WORDS = frozenset((
|
||||
"key", "apikey", "token", "secret", "secrets", "credential", "credentials",
|
||||
"password", "passwd", "auth", "authorization", "bearer", "cookie",
|
||||
"signature", "privatekey",
|
||||
))
|
||||
|
||||
# Secrets are drawn from a restricted alphabet and contain no whitespace:
|
||||
# base64, hex, base58, or a vendor's own. Prose is not, which is the whole
|
||||
# discriminator - English text scores 4.0-4.5 bits per character by this
|
||||
# measure, so entropy ALONE flags every sentence in a config file.
|
||||
SECRETISH = re.compile(r"^[A-Za-z0-9+/=_.~-]+$")
|
||||
|
||||
|
||||
def words(name):
|
||||
"""Splits an identifier into lowercase words: camelCase, snake_case and
|
||||
kebab-case all reduce to the same thing."""
|
||||
name = re.sub(r"\[\d+\]$", "", name)
|
||||
parts = re.split(r"[^A-Za-z0-9]+", name)
|
||||
out = []
|
||||
for part in parts:
|
||||
out.extend(w.lower() for w in
|
||||
re.findall(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+", part))
|
||||
return out
|
||||
|
||||
|
||||
def suspect_name(path):
|
||||
"""The leaf, plus its immediate parent.
|
||||
|
||||
The parent matters for a gitconfig, where `credential.helper` is a
|
||||
credential because of the section it sits in. Anything further up is too
|
||||
broad: a whole subtree named after a feature flag about oauth is not a
|
||||
subtree of credentials."""
|
||||
segments = path.split(".")
|
||||
for seg in segments[-2:]:
|
||||
if SUSPECT_WORDS & set(words(seg)):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Shapes that look like secrets to an entropy test and are not. Checked before
|
||||
# entropy, so a UUID machine id does not cost an operator a prompt.
|
||||
NOT_SECRET = (
|
||||
re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"), # uuid
|
||||
re.compile(r"^[a-z]+://"), # url
|
||||
re.compile(r"^/"), # absolute path
|
||||
re.compile(r"^~?/"), # home path
|
||||
re.compile(r"^\d{4}-\d{2}-\d{2}"), # timestamp
|
||||
re.compile(r"^[0-9.]+$"), # version/number
|
||||
)
|
||||
|
||||
MIN_ENTROPY_LEN = 32
|
||||
MIN_ENTROPY_BITS = 4.0
|
||||
|
||||
|
||||
def shannon(value):
|
||||
if not value:
|
||||
return 0.0
|
||||
counts = {}
|
||||
for ch in value:
|
||||
counts[ch] = counts.get(ch, 0) + 1
|
||||
n = float(len(value))
|
||||
return -sum((c / n) * math.log(c / n, 2) for c in counts.values())
|
||||
|
||||
|
||||
def classify(path, value):
|
||||
"""Returns (verdict, shape) with verdict in credential|suspect|None.
|
||||
|
||||
Never returns any part of `value`.
|
||||
"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return (None, None)
|
||||
v = value.strip()
|
||||
for shape, pattern in KNOWN_SHAPES:
|
||||
if pattern.search(v):
|
||||
return ("credential", shape)
|
||||
if any(p.match(v) for p in NOT_SECRET):
|
||||
return (None, None)
|
||||
# Both remaining rules require a value that could BE a secret: one token,
|
||||
# restricted alphabet, no whitespace. Without that, a field called
|
||||
# "authDescription" holding a sentence is a finding.
|
||||
if not SECRETISH.match(v):
|
||||
return (None, None)
|
||||
if suspect_name(path) and len(v) >= 8:
|
||||
return ("suspect", "named-field")
|
||||
if len(v) >= MIN_ENTROPY_LEN and shannon(v) >= MIN_ENTROPY_BITS:
|
||||
return ("suspect", "high-entropy")
|
||||
return (None, None)
|
||||
|
||||
|
||||
def walk_json(doc, prefix=""):
|
||||
if isinstance(doc, dict):
|
||||
for k, v in doc.items():
|
||||
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
|
||||
elif isinstance(doc, list):
|
||||
# Indexed, so a decision recorded against one element does not silently
|
||||
# cover a different one later.
|
||||
for i, v in enumerate(doc):
|
||||
yield from walk_json(v, "%s[%d]" % (prefix, i))
|
||||
else:
|
||||
yield (prefix, doc)
|
||||
|
||||
|
||||
def ini_logical_lines(text):
|
||||
"""Yields (raw_lines, logical_text) with backslash continuations joined.
|
||||
|
||||
A gitconfig value may run across lines, and both reading and removing have
|
||||
to treat the whole run as one thing. Reading it line by line finds a
|
||||
credential under a key that does not exist - `password=...` on a
|
||||
continuation of `helper = !echo \\` parses as its own key - and removing
|
||||
one line of it leaves the rest behind."""
|
||||
lines = text.splitlines(True)
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
raws = [lines[i]]
|
||||
logical = lines[i].rstrip("\n")
|
||||
while logical.rstrip().endswith("\\") and i + 1 < len(lines):
|
||||
i += 1
|
||||
raws.append(lines[i])
|
||||
logical = logical.rstrip()[:-1] + lines[i].rstrip("\n")
|
||||
yield (raws, logical.strip())
|
||||
i += 1
|
||||
|
||||
|
||||
def ini_entries(text):
|
||||
"""Yields (raw_lines, kind, key, value) where kind is section|pair|other."""
|
||||
section = ""
|
||||
for raws, line in ini_logical_lines(text):
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
yield (raws, "other", None, None)
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
inner = line[1:-1].strip()
|
||||
m = re.match(r'^(\S+)\s+"(.*)"$', inner)
|
||||
if m:
|
||||
section = "%s.%s" % (m.group(1), m.group(2))
|
||||
# The subsection text is part of the key, and a URL with
|
||||
# userinfo in it is a credential sitting in a section header.
|
||||
yield (raws, "section", section, m.group(2))
|
||||
else:
|
||||
section = inner
|
||||
yield (raws, "section", section, None)
|
||||
continue
|
||||
if "=" not in line:
|
||||
yield (raws, "other", None, None)
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
key = "%s.%s" % (section, k.strip()) if section else k.strip()
|
||||
yield (raws, "pair", key, v.strip())
|
||||
|
||||
|
||||
def walk_ini(text):
|
||||
for _raws, kind, key, value in ini_entries(text):
|
||||
if kind in ("section", "pair") and value is not None:
|
||||
yield (key, value)
|
||||
|
||||
|
||||
def delete_json_path(doc, path):
|
||||
# Mirrors walk_json's construction exactly, including list indices.
|
||||
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
|
||||
cur = doc
|
||||
for part in parts[:-1]:
|
||||
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
|
||||
last = parts[-1]
|
||||
if last.startswith("["):
|
||||
del cur[int(last[1:-1])]
|
||||
else:
|
||||
del cur[last]
|
||||
|
||||
|
||||
def drop_ini_paths(text, paths):
|
||||
"""Removes whole sections for a section-header finding, whole logical
|
||||
lines otherwise. Line-based rather than via a parser, so a file this
|
||||
cannot fully model is changed only where it matched - but a match takes
|
||||
every line of the value with it."""
|
||||
drop = set(paths)
|
||||
out = []
|
||||
in_dropped_section = False
|
||||
for raws, kind, key, _value in ini_entries(text):
|
||||
if kind == "section":
|
||||
# A finding on the header itself takes the section with it: the
|
||||
# secret is the header, and the keys under it belong to it.
|
||||
in_dropped_section = key in drop
|
||||
if in_dropped_section:
|
||||
continue
|
||||
out.extend(raws)
|
||||
continue
|
||||
if in_dropped_section:
|
||||
continue
|
||||
if kind == "pair" and key in drop:
|
||||
continue
|
||||
out.extend(raws)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def load_list(path):
|
||||
if not path:
|
||||
return []
|
||||
out = []
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
out.append(line)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--file", required=True)
|
||||
p.add_argument("--format", required=True, choices=("json", "ini"))
|
||||
# What this file is called where an operator would recognise it, rather
|
||||
# than the temp path of the copy being scanned.
|
||||
p.add_argument("--label", required=True)
|
||||
# Paths judged safe, and paths already decided. Both are plain lists of
|
||||
# "<label>\t<path>" so bin/dejima can build them without a parser.
|
||||
p.add_argument("--known-safe", default=None)
|
||||
p.add_argument("--decided", default=None)
|
||||
# Ids to remove from the file, in place. The caller never had the real
|
||||
# paths, so this is the only way it can ask for one to go.
|
||||
p.add_argument("--scrub", default=None)
|
||||
args = p.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.file) as f:
|
||||
text = f.read()
|
||||
except OSError as e:
|
||||
# The filename, never the contents.
|
||||
print("cannot read %s: %s" % (args.label, e.strerror), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.format == "json":
|
||||
try:
|
||||
pairs = list(walk_json(json.loads(text)))
|
||||
except ValueError:
|
||||
print("%s is not readable as JSON" % args.label, file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
pairs = list(walk_ini(text))
|
||||
|
||||
if args.scrub:
|
||||
wanted = set(load_list(args.scrub))
|
||||
drop = [path for path, value in pairs
|
||||
if finding_id(args.label, path) in wanted]
|
||||
if not drop:
|
||||
return 0
|
||||
if args.format == "json":
|
||||
doc = json.loads(text)
|
||||
for path in drop:
|
||||
delete_json_path(doc, path)
|
||||
out = json.dumps(doc, indent=2) + "\n"
|
||||
else:
|
||||
out = drop_ini_paths(text, drop)
|
||||
with open(args.file, "w") as f:
|
||||
f.write(out)
|
||||
return 0
|
||||
|
||||
# Two different keys, deliberately.
|
||||
#
|
||||
# A judged-safe entry is a "<label>\t<path>" written by hand, and it
|
||||
# covers the subtree beneath it - `customApiKeyResponses.approved` has to
|
||||
# answer for `...approved[0]` and every sibling, or an array of the same
|
||||
# judged-safe thing asks once per element.
|
||||
#
|
||||
# A decision is an opaque id and matches ONE path exactly. Broadening
|
||||
# those the same way would let one answer about one field quietly cover
|
||||
# fields the operator never saw.
|
||||
safe = [e for e in load_list(args.known_safe) if "\t" in e]
|
||||
decided = set(load_list(args.decided))
|
||||
|
||||
def judged_safe(entry):
|
||||
for s_entry in safe:
|
||||
if entry == s_entry or entry.startswith(s_entry + ".") \
|
||||
or entry.startswith(s_entry + "["):
|
||||
return True
|
||||
return False
|
||||
|
||||
for path, value in pairs:
|
||||
fid = finding_id(args.label, path)
|
||||
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
|
||||
continue
|
||||
verdict, shape = classify(path, value)
|
||||
if verdict:
|
||||
# verdict, id, label, redacted path, shape. No value, and no
|
||||
# length either - a length is a hint about a secret.
|
||||
print("%s\t%s\t%s\t%s\t%s"
|
||||
% (verdict, fid, args.label, redact(path), shape))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
# Findings judged safe, so the scanner does not ask about them.
|
||||
#
|
||||
# Every line is "<label>\t<path>", and every line needs a reason. This file is
|
||||
# the allowlist half of #291: the scanner refuses to be quiet by default, and
|
||||
# adding a line here is the deliberate act that makes it quiet about one
|
||||
# thing. A line with no reason is a line nobody reviewed.
|
||||
#
|
||||
# Judge on what the value IS, not on whether leaking it would matter. "It is
|
||||
# only an email address" is a decision about consequence; this file is for
|
||||
# decisions about identity.
|
||||
#
|
||||
# An entry covers the subtree beneath it, so `a.b` answers for `a.b[0]` and
|
||||
# `a.b.c`. That is what makes an array of the same judged-safe thing one line
|
||||
# rather than one per element - and it is why a broad entry is a broad grant.
|
||||
# Name the narrowest path that covers what you meant.
|
||||
|
||||
# Hashes of API keys the operator approved on the host, not the keys. Claude
|
||||
# Code stores the last 20 characters, so it is a fragment rather than a
|
||||
# credential - and dropping it re-prompts inside the sandbox for a decision
|
||||
# already made outside it.
|
||||
~/.claude.json customApiKeyResponses.approved
|
||||
~/.claude.json customApiKeyResponses.rejected
|
||||
|
||||
# Installation identifiers. Not credentials: they authenticate nothing, and
|
||||
# they are why a session's own telemetry and cache lookups match the host's.
|
||||
~/.claude.json userID
|
||||
~/.claude.json machineID
|
||||
|
||||
# The signed-in account's own description - address, organisation, roles.
|
||||
# Identifying, and deliberately kept: a session's UI reflects the real account,
|
||||
# which is what makes it recognisable as yours. It grants nothing on its own.
|
||||
~/.claude.json oauthAccount.emailAddress
|
||||
~/.claude.json oauthAccount.accountUuid
|
||||
~/.claude.json oauthAccount.organizationUuid
|
||||
~/.claude.json oauthAccount.organizationName
|
||||
~/.claude.json oauthAccount.organizationRole
|
||||
~/.claude.json oauthAccount.workspaceRole
|
||||
+131
-14
@@ -55,6 +55,17 @@ _provider_axis_dir_into() {
|
||||
forge) printf -v "$1" 'forges' ;;
|
||||
workflow) printf -v "$1" 'workflows' ;;
|
||||
harness) printf -v "$1" 'harnesses' ;;
|
||||
# The model API a session talks to, and how its credential is
|
||||
# presented. Selected per harness rather than in [core], because what
|
||||
# it configures is the credential proxy rather than the shape of the
|
||||
# work (#171).
|
||||
#
|
||||
# "backend" and not "provider", which is what the industry calls a
|
||||
# model API: this repo already calls the axes themselves providers, and
|
||||
# one word for both reads as one thing. A harness's own config keeps
|
||||
# its own vocabulary - cline writes providers.json with a
|
||||
# lastUsedProvider key, and bin/harness/cline.sh translates.
|
||||
backend) printf -v "$1" 'backends' ;;
|
||||
# Not an axis in the select-one sense the other three are: a launch
|
||||
# enables zero or more tools, and none is the default. It is here
|
||||
# because everything else about it is the same - a manifest that is
|
||||
@@ -75,6 +86,7 @@ _provider_manifest_name() {
|
||||
forge) printf 'forge.conf' ;;
|
||||
workflow) printf 'workflow.conf' ;;
|
||||
harness) printf 'harness.conf' ;;
|
||||
backend) printf 'backend.conf' ;;
|
||||
tool) printf 'tool.conf' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
@@ -230,6 +242,54 @@ autonomy enum:commit|push-branch|open-pr commit
|
||||
briefing ?relpath
|
||||
EOF
|
||||
;;
|
||||
# credential = none is an endpoint that needs no credential - a model
|
||||
# the operator serves. There is nothing to withhold, so no proxy runs
|
||||
# and neither upstream nor proxied_domain applies: the session reaches
|
||||
# it directly, and where it is comes from the operator's own
|
||||
# [backend.<name>] base_url rather than from the manifest. Both are
|
||||
# optional for that reason and bin/dejima requires them where they do
|
||||
# apply.
|
||||
#
|
||||
# api_style is which dialect the endpoint speaks, and it is a property
|
||||
# of the API rather than of any harness: Anthropic's Messages API and
|
||||
# an OpenAI-compatible one are different wire formats, so a harness
|
||||
# that can only speak one has to know which it is being pointed at
|
||||
# before it writes its own configuration.
|
||||
#
|
||||
# A model API and how its credential is presented. Every field here
|
||||
# is an argument bin/dejima-credential-proxy already takes (#171):
|
||||
# this axis is what turns "the caller states the arrangement" into
|
||||
# "a file states it", so adding a backend is adding a directory.
|
||||
#
|
||||
# Nothing here is a secret. A backend declares WHERE its credential
|
||||
# is read from, never what it is - the file it names is read
|
||||
# host-side per request and never mounted.
|
||||
#
|
||||
# WHERE a credential sits is the operator's, so credential_field is
|
||||
# a `keys` entry rather than a manifest key. A key is not always the
|
||||
# whole file - Claude Code's console login keeps its key as
|
||||
# primaryApiKey in ~/.claude.json, beside unrelated app state - and
|
||||
# the same static arrangement serves a bare key file with the field
|
||||
# cleared. Two placements of one credential, not two backends.
|
||||
backend) cat <<'EOF'
|
||||
upstream ?url
|
||||
proxied_domain ?hostname
|
||||
api_style enum:anthropic|openai
|
||||
credential enum:static|anthropic-oauth|none
|
||||
auth_header identifier Authorization
|
||||
auth_format authformat Bearer {token}
|
||||
strip_headers ?list:identifier
|
||||
ensure_headers ?list:headerpair
|
||||
oauth_endpoint ?url
|
||||
oauth_client_id ?identifier
|
||||
keys ?list:keyspec
|
||||
requires ?list:identifier
|
||||
EOF
|
||||
;;
|
||||
# api_styles is which of those dialects this harness can speak. A
|
||||
# launch pairing it with a backend it has no client for is refused at
|
||||
# launch rather than failing later with well-formed requests in the
|
||||
# wrong shape.
|
||||
harness) cat <<'EOF'
|
||||
install ?depspec
|
||||
image_dirs ?list:homepath
|
||||
@@ -238,8 +298,8 @@ image_env ?list:envpair
|
||||
command identifier claude
|
||||
command_path ?path
|
||||
api_domains ?list:hostname
|
||||
proxied_api_domain ?hostname
|
||||
guards ?list:identifier
|
||||
api_styles ?list:identifier
|
||||
briefing_args ?args
|
||||
briefing_path ?homepath
|
||||
no_confirm_args ?args
|
||||
@@ -248,6 +308,7 @@ keys ?list:keyspec
|
||||
resume_command ?template
|
||||
transcript_update enum:append|rewrite append
|
||||
briefing ?relpath
|
||||
assumed_backend ?provider:backend
|
||||
EOF
|
||||
;;
|
||||
*) return 1 ;;
|
||||
@@ -360,6 +421,26 @@ _provider_valid_value() {
|
||||
[[ "$sub" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 1
|
||||
[[ "$seg" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]
|
||||
;;
|
||||
authformat)
|
||||
# How the credential is written into its header. "{token}" rather
|
||||
# than a scheme name, so a provider wanting a bare key or a prefix
|
||||
# of its own needs no code - and required, because a format
|
||||
# without it produces a proxy that starts, answers, and sends
|
||||
# every request unauthenticated. The proxy checks this too; a
|
||||
# manifest that cannot be loaded is the better place to find out.
|
||||
case "$value" in *"{token}"*) ;; *) return 1 ;; esac
|
||||
_provider_valid_value text "$value"
|
||||
;;
|
||||
headerpair)
|
||||
# NAME=VALUE for ensure_headers, where NAME is a header rather
|
||||
# than an environment variable - lower case and dashes are the
|
||||
# norm (anthropic-beta), which is exactly what envpair refuses.
|
||||
case "$value" in *=*) ;; *) return 1 ;; esac
|
||||
local hname="${value%%=*}" hvalue="${value#*=}"
|
||||
[ -n "$hname" ] && [ -n "$hvalue" ] || return 1
|
||||
_provider_valid_value identifier "$hname" || return 1
|
||||
_provider_valid_value identifier "$hvalue"
|
||||
;;
|
||||
relpath)
|
||||
# Joined to a provider directory, so no absolute paths and no
|
||||
# traversal - a manifest may only name a file it ships.
|
||||
@@ -565,7 +646,7 @@ _provider_warm_dirs() {
|
||||
local axis sub root name path i=0
|
||||
local -a axes=() names=() paths=() lines=()
|
||||
_provider_root_into root
|
||||
for axis in forge workflow harness; do
|
||||
for axis in forge workflow harness backend; do
|
||||
_provider_axis_dir_into sub "$axis" || continue
|
||||
paths+=("$root/$sub")
|
||||
_provider_list_into "$axis" || continue
|
||||
@@ -704,6 +785,12 @@ manifest_get() {
|
||||
#
|
||||
# Keyed on the resolved directory rather than the name, like every other cache
|
||||
# here, so two roots cannot answer for each other.
|
||||
#
|
||||
# SUCCESSES ONLY. A remembered refusal was the first shape of this and it is
|
||||
# the wrong one: it turns one transient failure into a permanent one, and the
|
||||
# symptom is indistinguishable from the bug this exists to fix - a lookup that
|
||||
# worked at startup failing for the rest of the process. Nothing here is
|
||||
# expensive enough to be worth remembering a "no" for.
|
||||
declare -gA _PROVIDER_KEYS_CACHE=() _PROVIDER_KEYS_SEEN=()
|
||||
|
||||
_provider_keys_field() {
|
||||
@@ -715,14 +802,11 @@ _provider_keys_field() {
|
||||
dir="$(provider_dir "$axis" "$name")" || return 1
|
||||
id="$axis:$dir"
|
||||
if [ -n "${_PROVIDER_KEYS_SEEN[$id]+set}" ]; then
|
||||
# A remembered refusal stays a refusal: re-deriving it would put the
|
||||
# same `|| return 1` chain back in the signal handler's path.
|
||||
[ "${_PROVIDER_KEYS_SEEN[$id]}" = ok ] || return 1
|
||||
printf '%s' "${_PROVIDER_KEYS_CACHE[$id]}"
|
||||
return 0
|
||||
fi
|
||||
file="$dir/$(_provider_manifest_name "$axis")"
|
||||
[ -f "$file" ] || { _PROVIDER_KEYS_SEEN[$id]=no; return 1; }
|
||||
[ -f "$file" ] || return 1
|
||||
type="$(_manifest_type "$axis" keys)" || return 1
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
n=$((n + 1))
|
||||
@@ -738,12 +822,41 @@ _provider_keys_field() {
|
||||
seen=1
|
||||
done < "$file"
|
||||
[ "$seen" = 1 ] || value="$(_manifest_default "$axis" keys)"
|
||||
_provider_valid_value "$type" "$value" || { _PROVIDER_KEYS_SEEN[$id]=no; return 1; }
|
||||
# Core's own key on every backend section, appended rather than declared
|
||||
# by each manifest. Whether to withhold a credential is dejima's question,
|
||||
# not the backend author's, and a backend that forgot to declare it would
|
||||
# silently lose the setting.
|
||||
#
|
||||
# No default, deliberately: unset means "whatever [credentials] proxy
|
||||
# says", which is a third state that on/off cannot carry. See
|
||||
# backend_proxy_enabled.
|
||||
[ "$axis" != backend ] || value="${value:+$value }proxy:?bool"
|
||||
_provider_valid_value "$type" "$value" || return 1
|
||||
_PROVIDER_KEYS_CACHE[$id]="$value"
|
||||
_PROVIDER_KEYS_SEEN[$id]=ok
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# Is the credential proxy on for this backend?
|
||||
#
|
||||
# [backend.<name>] proxy, falling back to [credentials] proxy. Per backend
|
||||
# because the global switch has the wrong blast radius: an operator with one
|
||||
# backend the proxy cannot serve had to disable withholding for every backend,
|
||||
# including the ones it serves perfectly.
|
||||
#
|
||||
# Deliberately not config_get_bool, which reports an UNSET optional key as
|
||||
# off. Unset here means "inherit", and reading it as "disabled" would turn the
|
||||
# credential proxy off for every backend the moment this key existed.
|
||||
backend_proxy_enabled() { # <backend-name>
|
||||
local v=""
|
||||
[ -n "${1:-}" ] || return 1
|
||||
if config_type "backend.$1.proxy" >/dev/null 2>&1; then
|
||||
v="$(config_get "backend.$1" proxy)"
|
||||
fi
|
||||
[ -n "$v" ] || v="$(config_get credentials proxy)"
|
||||
[ "$v" = on ]
|
||||
}
|
||||
|
||||
# A section is known only when a provider directory for its name actually
|
||||
# exists - provider_dir carries the same name/containment checks
|
||||
# _config_extra_type_valid already relies on for the provider: config types.
|
||||
@@ -752,6 +865,7 @@ _config_extra_section_known() {
|
||||
case "$section" in
|
||||
forge.*) axis=forge; name="${section#forge.}" ;;
|
||||
harness.*) axis=harness; name="${section#harness.}" ;;
|
||||
backend.*) axis=backend; name="${section#backend.}" ;;
|
||||
# An identity is the operator's own name for a set of credentials, so
|
||||
# there is no directory to check it against - only that it is a name
|
||||
# at all. Unlike a provider, an unused one is not a misconfiguration:
|
||||
@@ -807,6 +921,7 @@ _config_extra_spec() {
|
||||
case "$want" in
|
||||
forge.*) axis=forge ;;
|
||||
harness.*) axis=harness ;;
|
||||
backend.*) axis=backend ;;
|
||||
identity.*)
|
||||
rest="${want#identity.}"
|
||||
key="${rest#*.}"
|
||||
@@ -992,7 +1107,7 @@ identity_validate() {
|
||||
# "once" that a session could not reach, and a deprecation nobody sees
|
||||
# until removal is not a deprecation.
|
||||
if config_is_set core ssh_key; then
|
||||
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
|
||||
printf '\n %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf ' under its old name.\n' >&2
|
||||
if [ -n "$name" ]; then
|
||||
@@ -1008,8 +1123,10 @@ identity_validate() {
|
||||
# force it answers with THAT identity's key - and printing it as the
|
||||
# thing to move would name a key the operator is not being asked to
|
||||
# touch.
|
||||
printf ' [identity.default]\n ssh_key = %s\n\n' \
|
||||
printf ' [identity.default]\n ssh_key = %s\n' \
|
||||
"$(_config_get_from_file core ssh_key)" >&2
|
||||
# Worth a pause before the build scrolls it away - see bin/dejima.
|
||||
DEJIMA_STARTUP_NOTICE=1
|
||||
fi
|
||||
|
||||
[ -n "$name" ] || return 0
|
||||
@@ -1555,8 +1672,6 @@ If a task genuinely needs something else the sandbox can't provide -
|
||||
broader network access than a domain or two, host-level or system changes,
|
||||
GUI/hardware access - stop and explain clearly to the user what you need
|
||||
and why, instead of trying to work around the firewall or sandbox yourself.
|
||||
They can continue this exact session outside the container by running on
|
||||
the host:
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
@@ -1910,9 +2025,11 @@ briefing_compose() {
|
||||
# The harness fragment closes the briefing: it supplies both the resume
|
||||
# command closing_a has just promised and the sentence about where the
|
||||
# session history it resumes from actually lives. Both are the harness's
|
||||
# own facts - Claude Code resumes from a bind-mounted ~/.claude, Cline
|
||||
# from a per-project state directory named in the command itself - and
|
||||
# core stating either one meant stating Claude Code's.
|
||||
# own facts, INCLUDING whether there is anything to say. Claude Code
|
||||
# resumes from a bind-mounted ~/.claude and says so; Cline cannot be
|
||||
# resumed outside dejima at all, and a core sentence promising it could
|
||||
# was Claude Code's fact stated as everyone's - which left a Cline session
|
||||
# able to send its operator somewhere that does not work.
|
||||
#
|
||||
# $requirements and $project_doc (the missing-dependency report and
|
||||
# .dejima.md) stay hook-computed and hook-appended after this - they read
|
||||
|
||||
@@ -379,6 +379,7 @@ _plugin_version_mounts() {
|
||||
harness_prepare_config() {
|
||||
local overlay="$1" settings="$HOME/.claude/settings.json" settings_error
|
||||
|
||||
|
||||
# install.sh normally creates settings.json, and many hosts have no
|
||||
# CLAUDE.md at all. Created empty rather than left absent because
|
||||
# harness_staged_config below emits both unconditionally and core skips
|
||||
|
||||
+208
-69
@@ -40,18 +40,17 @@ harness_briefing_state_dir() {
|
||||
|
||||
# Which endpoints a session may be pointed at, and why the answer is not "any".
|
||||
#
|
||||
# Absent base_url means Anthropic: Cline is configured with the real hostname
|
||||
# and a placeholder key, and dejima's in-container terminator and host-side
|
||||
# proxy supply the real credential, exactly as they do for Claude Code. The
|
||||
# credential never enters the container.
|
||||
# The selected backend says where the model is. A proxied one carries its own
|
||||
# upstream and dejima's terminator stands in front of it, so the credential
|
||||
# never enters the container. A backend with `credential = none` is a model
|
||||
# the operator serves, carrying its own [backend.<name>] base_url, and it is
|
||||
# reached directly because there is nothing to withhold.
|
||||
#
|
||||
# A base_url means a self-hosted endpoint, and it has to be one reachable
|
||||
# without a credential - the Docker host, or something on the sandbox's own
|
||||
# network. That restriction is R5 and not a network one: a hosted provider
|
||||
# would need an API key inside the container, and dejima has no proxy that
|
||||
# could hold one back on its behalf. An endpoint that needs no key has nothing
|
||||
# to withhold, which is why an open-weight model served locally is supported
|
||||
# today and api.openai.com is not.
|
||||
# That second kind has to be an endpoint that genuinely needs no credential.
|
||||
# The restriction is R5 and not a network one: a hosted API pointed at this way
|
||||
# would need a key inside the container, and dejima has no proxy holding one
|
||||
# for it. An endpoint that needs no key has nothing to withhold, which is why a
|
||||
# model served locally is supported and api.openai.com reached this way is not.
|
||||
#
|
||||
# Two ways an endpoint qualifies, and neither is about the URL's syntax.
|
||||
#
|
||||
@@ -66,17 +65,15 @@ harness_briefing_state_dir() {
|
||||
# what the container can reach at all, so an endpoint failing this check is
|
||||
# one the session could not have connected to anyway. Requiring a literal
|
||||
# address instead was too strict by half: a model server on someone's own
|
||||
# network normally has a name, and refusing it made the local-endpoint path -
|
||||
# the reason this harness is provider-agnostic at all - unusable for the
|
||||
# ordinary case.
|
||||
# network normally has a name, and refusing it made this path - the reason
|
||||
# this harness is backend-agnostic at all - unusable for the ordinary case.
|
||||
#
|
||||
# What neither branch is doing is protecting the model credential. dejima
|
||||
# writes a placeholder into providers.json on this path whatever the endpoint
|
||||
# What this is not doing is protecting the model credential. dejima writes a
|
||||
# placeholder into providers.json on every proxied path whatever the endpoint
|
||||
# is, so no real credential enters the container either way. What this decides
|
||||
# is whether the configuration can WORK: an endpoint that needs a key would
|
||||
# get the placeholder and fail, and saying so at launch beats an auth error
|
||||
# three layers down. Pointing it somewhere is the operator's call, bounded by
|
||||
# an allowlist entry they made deliberately.
|
||||
# is whether the configuration can WORK: an endpoint that needs a key would get
|
||||
# a no-credential marker and fail, and saying so at launch beats an auth error
|
||||
# three layers down.
|
||||
_cline_host_local() {
|
||||
local url="$1" host a b
|
||||
host="${url#*://}"
|
||||
@@ -130,7 +127,7 @@ _cline_host_allowlisted() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# The provider configuration the session runs with, and the three conditions
|
||||
# The backend configuration the session runs with, and the three conditions
|
||||
# that make a launch wrong: no model, a credential mode with no path through,
|
||||
# and an endpoint whose key dejima would have to put in the container.
|
||||
#
|
||||
@@ -146,7 +143,7 @@ _cline_host_allowlisted() {
|
||||
# bounds where a repointed endpoint could reach. What rewriting buys is that a
|
||||
# repointing cannot persist into the next session.
|
||||
harness_prepare_config() {
|
||||
local state model base_url settings
|
||||
local state model settings
|
||||
state="$(_cline_state_dir "$WORKSPACE_DIR")" || {
|
||||
echo "harness.cline: state_dir is not set, so there is nowhere to keep" >&2
|
||||
echo "this project's Cline state. Set it in [harness.cline]." >&2
|
||||
@@ -162,61 +159,98 @@ harness_prepare_config() {
|
||||
echo " [harness.cline]" >&2
|
||||
echo " model = claude-haiku-4-5-20251001" >&2
|
||||
echo "" >&2
|
||||
echo "\`cline auth\` outside the sandbox lists what your provider offers." >&2
|
||||
echo "\`cline auth\` outside the sandbox lists what your backend offers." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
base_url="$(harness_config_get base_url)"
|
||||
local ctx
|
||||
ctx="$(harness_config_get context_window)"
|
||||
|
||||
# The Anthropic path is the credential proxy's path, and it only works in
|
||||
# the shape that was actually tested: the proxy on, terminating TLS for
|
||||
# api.anthropic.com in-container, with Cline configured for the real
|
||||
# hostname and a placeholder key.
|
||||
# WHERE THE MODEL IS is the backend's, and only the backend's. One
|
||||
# setting, so there is nothing for a second one to disagree with.
|
||||
#
|
||||
# With the proxy off, Claude Code mounts the operator's real credential -
|
||||
# a downgrade this repository documents and accepts. There is no
|
||||
# equivalent here: the credential Cline would need is in the operator's
|
||||
# own providers.json, which is exactly the file dejima declines to mount,
|
||||
# and writing a placeholder anyway would produce an auth failure whose
|
||||
# cause is three layers away. The loopback transport is the same story
|
||||
# from the other end - it hands the harness a base URL and a token through
|
||||
# the environment, which Cline does not read for provider selection
|
||||
# A self-served endpoint is `backend = self-hosted`, carrying its own
|
||||
# [backend.self-hosted] base_url. That placement is what lets an operator
|
||||
# keep a local model configured while using a hosted API, and switch
|
||||
# between them by changing one word.
|
||||
local backend
|
||||
backend="$(harness_config_get backend)"
|
||||
if [ -z "$backend" ]; then
|
||||
echo "harness.cline: no [harness.cline] backend is set, so nothing says" >&2
|
||||
echo "where the model is." >&2
|
||||
echo "" >&2
|
||||
echo " [harness.cline]" >&2
|
||||
echo " backend = openrouter a hosted API, key held on the host" >&2
|
||||
echo " backend = self-hosted a model you serve, with no key at all" >&2
|
||||
echo "" >&2
|
||||
echo "There is deliberately no default: one dejima could change later would" >&2
|
||||
echo "move your session to another endpoint without your config changing." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local kind base_url=""
|
||||
kind="$(manifest_get backend "$backend" credential)" || return 1
|
||||
if [ "$kind" = none ]; then
|
||||
base_url="$(config_get "backend.$backend" base_url)" || return 1
|
||||
fi
|
||||
|
||||
# The proxy is what keeps a credential out of the container, and it only
|
||||
# works in the shape that was actually tested: the proxy on, terminating
|
||||
# TLS in-container, with Cline configured for the endpoint's real hostname
|
||||
# and a placeholder key.
|
||||
#
|
||||
# With the proxy off, claude-code mounts the operator's real credential - a
|
||||
# downgrade this repository documents and accepts. There is no equivalent
|
||||
# here: the credential Cline would need is in the operator's own
|
||||
# providers.json, which is exactly the file dejima declines to mount, and
|
||||
# writing a placeholder anyway would produce an auth failure whose cause is
|
||||
# three layers away. The loopback transport is the same story from the
|
||||
# other end - it hands the harness a base URL and a token through the
|
||||
# environment, which Cline does not read for provider selection
|
||||
# (CLINE_PROVIDER and CLINE_MODEL were tried and ignored).
|
||||
#
|
||||
# Both refuse rather than start something that will fail confusingly, and
|
||||
# both have the same way out: point base_url at a model you serve.
|
||||
if [ -z "$base_url" ]; then
|
||||
# A backend with no credential is unaffected: there is nothing to withhold
|
||||
# and no proxy in the path.
|
||||
if [ "$kind" != none ]; then
|
||||
local why=""
|
||||
[ "$(config_get_bool credentials proxy)" = "1" ] \
|
||||
|| why="[credentials] proxy is off"
|
||||
backend_proxy_enabled "$backend" \
|
||||
|| why="the proxy is off for this backend"
|
||||
[ -n "$why" ] || [ "$(config_get credentials transport)" = "tls" ] \
|
||||
|| why="[credentials] transport is loopback"
|
||||
if [ -n "$why" ]; then
|
||||
echo "harness.cline: $why, and cline has no way to reach Anthropic" >&2
|
||||
echo "without the TLS terminator the proxy puts in front of it." >&2
|
||||
echo "harness.cline: $why, and cline has no way to reach a proxied" >&2
|
||||
echo "backend without the TLS terminator the proxy puts in front of it." >&2
|
||||
echo "Its credential lives in the providers.json dejima declines to" >&2
|
||||
echo "mount, so there is nothing to fall back to - unlike claude-code," >&2
|
||||
echo "which mounts the real credential in that mode." >&2
|
||||
echo "" >&2
|
||||
echo "Either turn the proxy back on with the default tls transport," >&2
|
||||
echo "or set [harness.cline] base_url to a model you serve yourself." >&2
|
||||
echo "or select a backend you serve yourself." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$base_url" ] && ! _cline_host_local "$base_url"; then
|
||||
echo "harness.cline: refusing base_url $base_url." >&2
|
||||
echo "It is not a host-local endpoint, so it would need an API key" >&2
|
||||
echo "inside the container, and dejima has no proxy that can hold one" >&2
|
||||
echo "back for that provider - the model credential would stop being" >&2
|
||||
echo "something the sandbox never sees." >&2
|
||||
# An endpoint reached with no credential has to be one that needs none, and
|
||||
# that is an R5 check rather than a network rule: a hosted API pointed at
|
||||
# this way would want a key inside the container, and the model credential
|
||||
# would stop being something the sandbox never sees.
|
||||
#
|
||||
# Still here rather than in core because cline is the only harness with a
|
||||
# direct path today. It belongs in core the moment a second one has one.
|
||||
if [ "$kind" = none ] && ! _cline_host_local "$base_url"; then
|
||||
echo "harness.cline: refusing [backend.$backend] base_url $base_url." >&2
|
||||
echo "That backend needs no credential, so the endpoint has to be one" >&2
|
||||
echo "that needs none: a model you serve. This is not host-local, so it" >&2
|
||||
echo "would want a key inside the container." >&2
|
||||
echo "" >&2
|
||||
echo "Supported today: leave base_url unset for Anthropic through" >&2
|
||||
echo "dejima's own credential proxy, or point it at a model you serve" >&2
|
||||
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&2
|
||||
echo "address, or a host you have allowlisted:" >&2
|
||||
echo "For a hosted API, select a backend for it instead - the key is then" >&2
|
||||
echo "held on the host and injected per request:" >&2
|
||||
echo "" >&2
|
||||
echo " [harness.cline]" >&2
|
||||
echo " backend = openrouter" >&2
|
||||
echo "" >&2
|
||||
echo "For one you serve, that means a loopback or RFC 1918 address, or a" >&2
|
||||
echo "host you have allowlisted:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --project <path> <host>" >&2
|
||||
echo "" >&2
|
||||
@@ -246,25 +280,45 @@ harness_prepare_config() {
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp "$settings/.providers.XXXXXX")" || return 1
|
||||
if [ -n "$base_url" ]; then
|
||||
jq -n --arg m "$model" --arg u "$base_url" \
|
||||
# Which of Cline's own provider types to write, from the selected
|
||||
# backend's api_style rather than from a second setting an operator has
|
||||
# to keep in step. Anthropic's Messages API and an OpenAI-compatible one
|
||||
# are different wire formats, and Cline has a client for each.
|
||||
local style endpoint key
|
||||
style="$(manifest_get backend "$backend" api_style)" || return 1
|
||||
if [ "$kind" = none ]; then
|
||||
endpoint="$base_url"
|
||||
key="dejima-no-credential-needed"
|
||||
else
|
||||
# The endpoint's REAL hostname, not a loopback one. The terminator
|
||||
# answers for it inside the container and the host-side proxy holds the
|
||||
# credential, which is what keeps the session indistinguishable from
|
||||
# one talking to the API directly.
|
||||
endpoint="$(manifest_get backend "$backend" upstream)" || return 1
|
||||
# The placeholder is what Cline sends; the host-side proxy replaces it
|
||||
# with the real credential on the way out. Same arrangement as Claude
|
||||
# Code's faked .credentials.json, confirmed end to end against the API.
|
||||
key="sandbox-credential-proxy-placeholder"
|
||||
fi
|
||||
|
||||
if [ "$style" = anthropic ]; then
|
||||
# Cline's native Anthropic client, which addresses the API by name and
|
||||
# so needs no baseUrl: entrypoint.sh has already pointed that name at
|
||||
# the terminator.
|
||||
jq -n --arg m "$model" --arg k "$key" \
|
||||
--arg t "$now" --arg c "$ctx" \
|
||||
'{version: 1, lastUsedProvider: "anthropic", modes: {},
|
||||
providers: {anthropic: {
|
||||
settings: ({provider: "anthropic", model: $m, apiKey: $k}
|
||||
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
|
||||
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
|
||||
else
|
||||
jq -n --arg m "$model" --arg u "$endpoint" --arg k "$key" \
|
||||
--arg t "$now" --arg c "$ctx" \
|
||||
'{version: 1, lastUsedProvider: "openai-compatible", modes: {},
|
||||
providers: {"openai-compatible": {
|
||||
settings: ({provider: "openai-compatible", model: $m,
|
||||
baseUrl: $u, apiKey: "dejima-no-credential-needed"}
|
||||
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
|
||||
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
|
||||
else
|
||||
# The placeholder is what Cline sends; the host-side proxy replaces it
|
||||
# with the real credential on the way out. Same arrangement as Claude
|
||||
# Code's faked .credentials.json, confirmed end to end against the API.
|
||||
jq -n --arg m "$model" \
|
||||
--arg t "$now" --arg c "$ctx" \
|
||||
'{version: 1, lastUsedProvider: "anthropic", modes: {},
|
||||
providers: {anthropic: {
|
||||
settings: ({provider: "anthropic", model: $m,
|
||||
apiKey: "sandbox-credential-proxy-placeholder"}
|
||||
baseUrl: $u, apiKey: $k}
|
||||
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
|
||||
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
|
||||
fi || { rm -f "$tmp"; return 1; }
|
||||
@@ -278,6 +332,91 @@ harness_prepare_config() {
|
||||
# nothing inside the container has to be told where its state is - the source
|
||||
# is keyed by dejima's workspace slug and only the target follows the harness,
|
||||
# which is what emit_mount_at is for.
|
||||
# The operator's own configuration, brought in from the host.
|
||||
#
|
||||
# Read-only, and that is the whole point rather than caution: everything here
|
||||
# is either an instruction the agent follows or a program cline runs. A
|
||||
# session that could rewrite them would be choosing what happens on the HOST
|
||||
# the next time cline starts outside a container - persistence for a prompt
|
||||
# injection, in files nobody re-reads before the next run.
|
||||
#
|
||||
# `scan` for the same reason claude-code's directories are scanned: on a Linux
|
||||
# host these are routinely symlinks into a dotfiles checkout, and the target
|
||||
# has to be mounted for the link to resolve inside the container. Core refuses
|
||||
# a target that overlaps a guarded store.
|
||||
#
|
||||
# ~/.agents/rules is deliberately absent. It reads like the obvious companion
|
||||
# to ~/.agents/skills and cline does not load it - checked by planting a file
|
||||
# in it and one in ~/Documents/Cline/Rules and asking `cline config` which it
|
||||
# had: only the second appeared. Mounting a directory the harness never reads
|
||||
# would be a promise dejima cannot keep.
|
||||
harness_config_dirs() {
|
||||
emit_config_dir "$HOME/.agents/skills" ro scan
|
||||
# Not confirmed loaded by that same probe, unlike skills. Mounted because
|
||||
# it is the documented default for --hooks-dir and because the cost of
|
||||
# being wrong is an empty directory, where the cost of leaving it out is a
|
||||
# hook that silently does not run.
|
||||
emit_config_dir "$HOME/.agents/hooks" ro scan
|
||||
emit_config_dir "$HOME/.cline/hooks" ro scan
|
||||
}
|
||||
|
||||
# AGENTS.md is instructions the agent follows, so a session that could write
|
||||
# the host's copy would be choosing what the next session is told. Staged
|
||||
# rather than mounted: the copy lands root-owned and read-only, and a file
|
||||
# that is a symlink into a dotfiles checkout arrives as its real content
|
||||
# rather than as a link the host can still swap.
|
||||
harness_staged_config() {
|
||||
emit_staged_config "$HOME/.agents/AGENTS.md" ".agents/AGENTS.md"
|
||||
|
||||
# Global rules, from both host locations into the one directory cline
|
||||
# actually reads.
|
||||
#
|
||||
# cline loads global rules from Documents/Cline/Rules and nowhere else -
|
||||
# ~/.agents/rules reads like its companion and is not read, checked by
|
||||
# planting a file in each and asking `cline config` which it had. So
|
||||
# ~/.agents/rules is supported by staging its files INTO the directory
|
||||
# that is read, rather than by mounting it where it sits and having
|
||||
# nothing load it.
|
||||
#
|
||||
# File by file rather than as a directory mount, because dejima writes its
|
||||
# own briefing into that same directory: a read-only mount over it would
|
||||
# leave nowhere to put the briefing.
|
||||
local d f base briefing seen=" "
|
||||
# The briefing's own name, from the manifest rather than repeated here.
|
||||
# dejima wins that collision, and entrypoint.sh already delivers it after
|
||||
# the staged files - so staging a host file it is about to overwrite would
|
||||
# only add a mount nobody reads.
|
||||
briefing="$(basename "$(manifest_get harness cline briefing_path)")"
|
||||
# Documents first, so a name present in both wins there: it is cline's own
|
||||
# location for this, and where an operator editing rules through cline is
|
||||
# writing. Two sources staged to one destination would otherwise be two
|
||||
# bind mounts at the same container path.
|
||||
for d in "$HOME/Documents/Cline/Rules" "$HOME/.agents/rules"; do
|
||||
[ -d "$d" ] || continue
|
||||
# .md and .txt are what cline reads out of a rules directory.
|
||||
for f in "$d"/*.md "$d"/*.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
base="$(basename "$f")"
|
||||
[ "$base" = "$briefing" ] && continue
|
||||
case "$seen" in *" $base "*) continue ;; esac
|
||||
seen="$seen$base "
|
||||
emit_staged_config "$f" "Documents/Cline/Rules/$base"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Writable copies, discarded with the container.
|
||||
#
|
||||
# cline rewrites both of these as it runs - its settings TUI toggles them - so
|
||||
# a read-only copy would fail the write rather than the persistence, and the
|
||||
# persistence is the part that matters. cline_mcp_settings.json names MCP
|
||||
# servers, which are commands cline executes: a session that could write the
|
||||
# host's copy would be choosing what runs there next time.
|
||||
harness_seed() {
|
||||
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
|
||||
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
|
||||
}
|
||||
|
||||
harness_state_mounts() {
|
||||
local dir
|
||||
dir="$(_cline_state_dir "$1")" || return 0
|
||||
|
||||
@@ -137,7 +137,7 @@ services:
|
||||
# The names this harness needs reachable, and the one its credential
|
||||
# proxy stands in front of - see docker/fixed-domains.sh.
|
||||
SANDBOX_HARNESS_DOMAINS: ${SANDBOX_HARNESS_DOMAINS:-}
|
||||
SANDBOX_HARNESS_PROXIED_DOMAIN: ${SANDBOX_HARNESS_PROXIED_DOMAIN:-}
|
||||
SANDBOX_BACKEND_PROXIED_DOMAIN: ${SANDBOX_BACKEND_PROXIED_DOMAIN:-}
|
||||
SANDBOX_HARNESS_BRIEFING_ARGS: ${SANDBOX_HARNESS_BRIEFING_ARGS:-}
|
||||
SANDBOX_HARNESS_BRIEFING_PATH: ${SANDBOX_HARNESS_BRIEFING_PATH:-}
|
||||
SANDBOX_HARNESS_SEED: ${SANDBOX_HARNESS_SEED:-}
|
||||
@@ -168,6 +168,10 @@ services:
|
||||
# empty - and unset by entrypoint.sh - otherwise.
|
||||
ANTHROPIC_BASE_URL: ${SANDBOX_ANTHROPIC_BASE_URL:-}
|
||||
ANTHROPIC_AUTH_TOKEN: ${SANDBOX_ANTHROPIC_AUTH_TOKEN:-}
|
||||
# The placeholder an API-billing session authenticates with. The real key
|
||||
# never enters the container; the host-side proxy swaps this for it per
|
||||
# request, exactly as it does for a subscription login's token.
|
||||
ANTHROPIC_API_KEY: ${SANDBOX_ANTHROPIC_API_KEY:-}
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET: ${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}
|
||||
SANDBOX_ANTHROPIC_TRANSPORT: ${SANDBOX_ANTHROPIC_TRANSPORT:-}
|
||||
volumes:
|
||||
|
||||
+10
-9
@@ -352,7 +352,7 @@ fi
|
||||
# socket, and credential-proxy-forwarder.js pipes bytes into it. Only started
|
||||
# when this session actually has a proxy socket.
|
||||
#
|
||||
# In the default tls transport the harness's proxied_api_domain is pointed at
|
||||
# In the default tls transport the backend's proxied_domain is pointed at
|
||||
# loopback here and the forwarder terminates TLS for it, so the harness runs
|
||||
# with no base-URL override and no placeholder token - it is indistinguishable
|
||||
# from a stock install, which is the whole point (see docs/security.md). The hosts
|
||||
@@ -381,15 +381,15 @@ esac
|
||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
forwarder_port=18300
|
||||
# Which name the terminator answers for is the selected harness's, from
|
||||
# `proxied_api_domain` in its manifest - this image is shared by every
|
||||
# harness and must name none of them. Empty here means a harness declared
|
||||
# a credential proxy it gave no endpoint for: the terminator would come up
|
||||
# answering for nothing, the session would reach the real API directly,
|
||||
# and the credential the proxy exists to withhold would be the one in use.
|
||||
proxied_domain="${SANDBOX_HARNESS_PROXIED_DOMAIN:-}"
|
||||
# `proxied_domain` in its manifest - this image is shared by every
|
||||
# backend and must name none of them. Empty here means a proxy socket was
|
||||
# mounted with no endpoint declared for it: the terminator would come up
|
||||
# answering for nothing, the session would reach the real API directly, and
|
||||
# the credential the proxy exists to withhold would be the one in use.
|
||||
proxied_domain="${SANDBOX_BACKEND_PROXIED_DOMAIN:-}"
|
||||
if [ -z "$proxied_domain" ]; then
|
||||
echo "FATAL: a credential proxy socket is mounted, but the selected harness" >&2
|
||||
echo "declares no proxied_api_domain for the terminator to stand in front of." >&2
|
||||
echo "FATAL: a credential proxy socket is mounted, but the selected" >&2
|
||||
echo "backend declares no proxied_domain for the terminator." >&2
|
||||
echo "Not starting the session - traffic would reach the API unintercepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -473,6 +473,7 @@ fi
|
||||
# behavior byte-for-byte.
|
||||
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
|
||||
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
|
||||
[ -n "${ANTHROPIC_API_KEY:-}" ] || unset ANTHROPIC_API_KEY
|
||||
|
||||
# Unset is the ordinary case (remote access is opt-in) and says nothing.
|
||||
# Set-but-unusable is worth a startup line because of how it presents: the
|
||||
|
||||
@@ -51,11 +51,11 @@ fi
|
||||
# SaaS name: dns-proxy.sh exempts the extra list from stop-dns-rebind, and
|
||||
# init-firewall.sh downgrades a failed lookup there to a warning.
|
||||
#
|
||||
# Which name this is comes from the harness (proxied_api_domain); the rule
|
||||
# Which name this is comes from the provider (proxied_domain); the rule
|
||||
# about when to allowlist it is dejima's, and stays here.
|
||||
if [ -z "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ] \
|
||||
&& [ -n "${SANDBOX_HARNESS_PROXIED_DOMAIN:-}" ]; then
|
||||
FIXED_DOMAINS+=("$SANDBOX_HARNESS_PROXIED_DOMAIN")
|
||||
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
|
||||
FIXED_DOMAINS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
|
||||
fi
|
||||
|
||||
# GitHub itself is allowlisted by IP range (see github-domains ipset,
|
||||
|
||||
+100
-3
@@ -34,11 +34,18 @@ config file behind them.
|
||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
|
||||
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
|
||||
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||
| `[harness.claude-code] backend` | *(none - must be set)* | which model API this harness talks to, and so which credential the proxy injects. A directory under `backends/`, each shipping its own `[backend.<name>]` keys. Per harness rather than global, because running Claude Code against Anthropic and Cline against something else is the ordinary case. No default, for the reason `[core] harness` has none: one that could change later would move a session to another endpoint without its config changing. Unset, dejima writes `anthropic` into your config once and says so - a migration rather than a default, so nothing it decides later can move you. Also settable per launch with `--backend`. See [providers.md](providers.md#model-backends) |
|
||||
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
|
||||
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
|
||||
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
|
||||
| `[harness.cline] backend` | *(none - must be set)* | as above, and the risk is concrete here: Cline supports many backends and has one of its own. Nothing is written for you either, because what this harness talked to before the key existed depends on `base_url`. Also settable per launch with `--backend` |
|
||||
| `[backend.anthropic] credential_file` | `~/.claude/.credentials.json` | the host file this backend's credential is read from, per request. A path, never a secret, and never mounted into the container |
|
||||
| `[backend.self-hosted] base_url` | *(none - must be set)* | where you serve the model. A loopback or RFC 1918 address, or a host you have allowlisted - it has to be an endpoint needing no credential, since there is none to withhold. On the backend rather than the harness, so a local endpoint stays configured while a hosted API is selected |
|
||||
| `[backend.anthropic-api-key] credential_file` | `~/.claude.json` | where your Anthropic API key is. Defaulted to where Claude Code's console login puts it, so the common case needs nothing set; point it at a file of your own if you hold the key yourself. Read host-side per request and never mounted, and the key is stripped from the copy of `~/.claude.json` dejima stages |
|
||||
| `[backend.anthropic-api-key] credential_field` | `primaryApiKey` | which value inside that file is the key, as dot-separated object names. Clear it (`credential_field =`) when the file is nothing but the key |
|
||||
| `[backend.<name>] proxy` | *(inherits `[credentials] proxy`)* | whether the credential proxy runs for this backend. Per backend so that one backend the proxy cannot serve is not a reason to stop withholding credentials from the ones it can. Off means this backend's real credential is given to the session, and dejima says so at launch |
|
||||
| `[backend.openrouter] credential_file` | *(none - must be set)* | your OpenRouter API key, in a file. Read host-side per request and never mounted; the container only ever holds a placeholder. No default, because a key's location is yours and guessing at one would point dejima at a file it has no reason to think exists |
|
||||
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
|
||||
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted. Also settable per launch with `--base-url` |
|
||||
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
|
||||
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
|
||||
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
|
||||
@@ -348,7 +355,7 @@ Because that directory is dejima's rather than yours, resuming on the host
|
||||
needs `CLINE_DATA_DIR` set to it. The exact command is at the end of every
|
||||
session's briefing.
|
||||
|
||||
The provider configuration is written fresh by dejima at each launch and is
|
||||
The model configuration is written fresh by dejima at each launch and is
|
||||
not yours either. Two shapes are supported:
|
||||
|
||||
- **`base_url` unset** - Anthropic, reached through dejima's own credential
|
||||
@@ -366,7 +373,7 @@ connected to anyway. A model server on your own network normally has a name,
|
||||
so requiring a literal address would have made this path unusable for the
|
||||
ordinary case.
|
||||
|
||||
Anything else is refused at launch - a hosted provider's API key would have to
|
||||
Anything else is refused at launch - a hosted backend's API key would have to
|
||||
go inside the container, and dejima has no proxy that could hold one back on
|
||||
its behalf. Note what the check is *not* doing: dejima writes a placeholder
|
||||
into the container on this path whatever the endpoint is, so no real
|
||||
@@ -408,6 +415,96 @@ editing the base `Dockerfile`: a dependency added that way is declared,
|
||||
approved and layered like any other, and it survives a rebuild. Editing the
|
||||
Dockerfile directly puts it outside that record.
|
||||
|
||||
|
||||
#### Sessions, and where they live
|
||||
|
||||
This project's Cline state - sessions, transcripts, tasks - is kept on the host
|
||||
in `~/.local/share/dejima/cline/<project>` and mounted at `~/.cline/data`. It
|
||||
survives the container being replaced, so **launching dejima again on the same
|
||||
project picks up where the last session left off.**
|
||||
|
||||
A directory per project rather than one shared: Cline indexes every session it
|
||||
has ever run in a single database, so sharing one would put every project's
|
||||
history in every project's context - the same boundary `~/.claude/projects`
|
||||
gets, for the same reason.
|
||||
|
||||
**Cline sessions are not importable or exportable.** A session started by
|
||||
running `cline` on your own machine is in `~/.cline/data` and is not visible
|
||||
inside dejima; a session started inside dejima is in the per-project directory
|
||||
and is not offered back to a host-side Cline. There is no import, no export,
|
||||
and no merge - see
|
||||
[#301](https://gitbox.branderson.io/dejima/dejima/issues/301) for why that was
|
||||
looked at and not built.
|
||||
|
||||
The session briefing tells a Cline agent this, so it will not offer to hand
|
||||
you a session to resume outside the sandbox. Claude Code can be resumed on the
|
||||
host and its briefing says so; the claim lives in each harness's own briefing
|
||||
fragment rather than in the shared preamble, because only one of them can make
|
||||
it.
|
||||
|
||||
#### What comes in from your host
|
||||
|
||||
The same shape as Claude Code's, and for the same reason: your own
|
||||
configuration should reach a session, and nothing the session writes should
|
||||
reach your machine.
|
||||
|
||||
| Host path | How it arrives | Why |
|
||||
| --- | --- | --- |
|
||||
| `~/.agents/skills/` | read-only mount | skills are instructions the agent follows |
|
||||
| `~/.agents/hooks/`, `~/.cline/hooks/` | read-only mount | hooks are programs cline runs |
|
||||
| `~/.agents/AGENTS.md` | read-only copy | instructions; a copy also survives it being a symlink into a dotfiles checkout |
|
||||
| `~/.agents/rules/*.md`, `*.txt` | read-only copy, into `Documents/Cline/Rules/` | see below |
|
||||
| `~/Documents/Cline/Rules/*.md`, `*.txt` | read-only copy | the location cline actually reads |
|
||||
| `~/.cline/settings.json`, `~/.cline/cline_mcp_settings.json` | writable copy, discarded | cline rewrites both as it runs |
|
||||
| `~/.cline/data/` | this project's own state directory | see above |
|
||||
|
||||
Read-only is the point rather than caution. A session that could rewrite a
|
||||
hook, a skill or an MCP server definition would be choosing what happens on
|
||||
**your** machine the next time you start cline outside a container - which is
|
||||
persistence for a prompt injection, in files nobody re-reads before the next
|
||||
run. The two settings files are copies rather than read-only mounts because
|
||||
cline rewrites them itself; the copy is discarded with the container.
|
||||
|
||||
The read-only directories are scanned for symlinks, so a `skills/` that is a
|
||||
link into a dotfiles checkout brings its target too. dejima refuses a target
|
||||
that overlaps one of its own guarded stores.
|
||||
|
||||
**Global rules come from two places and land in one.** cline loads them from
|
||||
`~/Documents/Cline/Rules` and nowhere else - `~/.agents/rules` reads like the
|
||||
obvious companion to `~/.agents/skills` and is not loaded, which you can see
|
||||
by planting a file in each and opening `cline config`. So dejima copies the
|
||||
files from *both* host locations into the one directory cline reads, rather
|
||||
than mounting `~/.agents/rules` where nothing would look at it.
|
||||
|
||||
They are copied file by file rather than mounted as a directory, because
|
||||
dejima writes its own briefing into that same directory and a read-only mount
|
||||
over it would leave nowhere to put it.
|
||||
|
||||
Two collisions have fixed answers. A name present in **both** host locations
|
||||
is taken from `Documents/Cline/Rules`, since that is cline's own location for
|
||||
rules and where it writes when you edit them through cline. A host file named
|
||||
`00-dejima.md` is skipped: that is the briefing's name, and dejima's wins.
|
||||
## What dejima says before it starts
|
||||
|
||||
Some launches print a notice first: an environment variable that is no longer
|
||||
read, a deprecated key, a guard that is not registered, a line dejima wrote
|
||||
into your config. Everything after that is the image build, which on a cold
|
||||
cache is thousands of lines - so when a notice was printed, dejima waits for
|
||||
Enter before starting it.
|
||||
|
||||
The wait happens only when there was something to read, and only when a
|
||||
terminal is attached. An unattended launch never blocks on it.
|
||||
|
||||
`--no-pause` skips the wait and still prints the notice. It is for starting
|
||||
several sessions at once - a tmuxinator layout, a script - where every one of
|
||||
them has a terminal and none of them wants a keypress. It is a flag rather
|
||||
than a config key on purpose: it describes the terminal in front of one
|
||||
launch, not a standing preference.
|
||||
|
||||
A long list of entries left in the shared directory is sampled rather than
|
||||
printed whole, for the same reason: it is a standing condition you clear once,
|
||||
and it should not push the things about this run off the screen.
|
||||
|
||||
## Identities
|
||||
|
||||
**Several accounts on one machine, and none of them yours.** An
|
||||
|
||||
+2
-2
@@ -499,8 +499,8 @@ harness-specific path resolving to nothing is the honest answer.
|
||||
| Seeded files | that a harness reading a credential from a fixed path needs something at it, since an absent file and an empty one are not reliably the same to it. The manifest's `image_files`, created empty and 0600 in that harness's layer, where a mounted credential shadows it - rather than by `entrypoint.sh`, which runs in every image including the base one alone | `harnesses/<name>/harness.conf` |
|
||||
| Image environment | anything the harness wants set for every session of its own - for Claude Code, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`. The manifest's `image_env`, in the same layer | `harnesses/<name>/harness.conf` |
|
||||
| Command | there is no default anywhere shared. `SANDBOX_CMD` comes from the manifest's `command` via `bin/dejima`, and `entrypoint.sh` refuses an empty one rather than naming a harness a given image may not contain | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
|
||||
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The manifest's `proxied_api_domain`; a credential proxy with no domain declared is refused rather than started answering for nothing | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
|
||||
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` and `proxied_api_domain` in the manifest | `harnesses/<name>/harness.conf` |
|
||||
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The BACKEND's `proxied_domain`, not the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. A proxied backend with no domain declared is refused rather than started answering for nothing | `backends/<name>/backend.conf`, `docker/entrypoint.sh` |
|
||||
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` on the harness, `proxied_domain` on the backend | `harnesses/<name>/harness.conf`, `backends/<name>/backend.conf` |
|
||||
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-credential-proxy`, `docker/credential-proxy-forwarder.js`, `docker/entrypoint.sh` |
|
||||
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
|
||||
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. In the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
|
||||
|
||||
+85
-9
@@ -3,16 +3,20 @@
|
||||
[← dejima README](../README.md)
|
||||
|
||||
dejima's forge (Gitea/Forgejo/...), workflow (worktree-and-PR, or something
|
||||
else) and harness (Claude Code, or another agent CLI) behaviour are not
|
||||
hardcoded into core. Each is a directory under `forges/`, `workflows/` or
|
||||
`harnesses/`, selected by `[core] forge`, `[core] workflow` and
|
||||
`[core] harness` in `~/.config/dejima/config`. Shipped today:
|
||||
else), harness (Claude Code, or another agent CLI) and model backend
|
||||
(Anthropic, OpenRouter, something you serve) behaviour are not hardcoded into
|
||||
core. Each is a directory under `forges/`, `workflows/`, `harnesses/` or
|
||||
`backends/`. Shipped today:
|
||||
|
||||
| Axis | Directory | Selector | Ships |
|
||||
| --- | --- | --- | --- |
|
||||
| forge | `forges/` | `[core] forge` | `forges/gitea`, `forges/github`, `forges/none` |
|
||||
| workflow | `workflows/` | `[core] workflow` | `workflows/worktree-pr`, `workflows/branch-push`, `workflows/local-only` |
|
||||
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code` |
|
||||
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code`, `harnesses/cline` |
|
||||
| backend | `backends/` | `[harness.<name>] backend` | `backends/anthropic`, `backends/anthropic-api-key`, `backends/openrouter`, `backends/self-hosted` |
|
||||
|
||||
The backend is selected per harness rather than in `[core]`, because it is the
|
||||
harness that talks to a model API - see [Model backends](#model-backends).
|
||||
|
||||
See [Shipped providers](#shipped-providers) below for what each one is for.
|
||||
|
||||
@@ -44,7 +48,7 @@ elsewhere needs to change.
|
||||
## The manifest file
|
||||
|
||||
Each provider directory has exactly one manifest file, named for its axis:
|
||||
`forge.conf`, `workflow.conf`, `harness.conf`. Format is the same
|
||||
`forge.conf`, `workflow.conf`, `harness.conf`, `backend.conf`. Format is the same
|
||||
`key = value` shape as the main config file:
|
||||
|
||||
- One `key = value` pair per line; surrounding whitespace is trimmed.
|
||||
@@ -228,7 +232,7 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
|
||||
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
|
||||
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the *fixed* allowlist rather than the extra one, which `dns-proxy.sh` exempts from stop-dns-rebind and `init-firewall.sh` treats as non-fatal when it fails to resolve. |
|
||||
| `proxied_api_domain` | `?hostname` | (empty) | The model API endpoint the credential proxy stands in front of. Allowlisted **only when the proxy is off**: with it on, the session reaches the API through the forwarder, so leaving the name unresolvable defeats a bypass that looks it up by DNS. Which name it is comes from here; when to allowlist it is dejima's rule. |
|
||||
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
|
||||
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
|
||||
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
|
||||
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that registers a hook instead, which is how Claude Code gets it. |
|
||||
@@ -262,6 +266,74 @@ resuming the session on the host is `claude --resume <session id>`, with
|
||||
`{{session_id}}` filled in at composition time; and its briefing fragment is
|
||||
`briefing.md`.
|
||||
|
||||
## Model backends
|
||||
|
||||
`backends/<name>/backend.conf`. Which model API a session talks to, and how the
|
||||
credential proxy presents the credential to it.
|
||||
|
||||
**"Backend" and not "provider",** which is what the industry usually calls a
|
||||
model API. This document already calls forge, workflow and harness "provider
|
||||
axes", and one word for both reads as one thing. A harness keeps its own
|
||||
vocabulary: `cline` writes a `providers.json` with a `lastUsedProvider` key,
|
||||
and `bin/harness/cline.sh` translates.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `upstream` | `?url` | (empty) | Where the proxy forwards to. Required unless `credential` is `none`, which runs no proxy. |
|
||||
| `api_style` | `enum:anthropic\|openai` | (required) | Which dialect the endpoint speaks. A property of the API rather than of any harness: Anthropic's Messages API and an OpenAI-compatible one are different wire formats. A harness declares which it can speak in `api_styles`, and a launch pairing the two wrongly is refused rather than sending well-formed requests in a shape the endpoint cannot parse. |
|
||||
| `proxied_domain` | `?hostname` | (empty) | The name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. Required unless `credential` is `none`. On this axis rather than the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. Declared on the harness, the terminator would answer for one API while the session talked to another, and the credential the proxy exists to withhold would be the one in use. |
|
||||
| `credential` | `enum:static\|anthropic-oauth\|none` | (required) | Whether there is a credential, and whether it expires. `static` is read and sent; `anthropic-oauth` is refreshed against `oauth_endpoint` and the rotated token written back to the host file, which is what keeps a host-side login working rather than being signed out by a sandbox run; `none` is a model you serve, with nothing to withhold and so no proxy at all. |
|
||||
| `auth_header` | `identifier` | `Authorization` | Which header carries it. |
|
||||
| `auth_format` | `authformat` | `Bearer {token}` | How it is written. Must contain `{token}`, so a backend wanting a bare key or a prefix of its own needs no code; a format without it produces a proxy that starts, answers, and sends every request unauthenticated. |
|
||||
| `strip_headers` | `?list:identifier` | (empty) | Headers that must not travel beside the injected one. `x-api-key` next to an `Authorization` is two answers to the same question, and which one an API prefers is not dejima's to guess. The auth header is never stripped from under itself. |
|
||||
| `ensure_headers` | `?list:headerpair` | (empty) | `NAME=VALUE` that has to appear in a comma-list header, merged into whatever the client sent and left alone when already present. Anthropic's OAuth beta is the one shipped example. |
|
||||
| `oauth_endpoint` | `?url` | (empty) | Required by the `anthropic-oauth` kind, meaningless to `static`. |
|
||||
| `oauth_client_id` | `?identifier` | (empty) | Same. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This backend's own `[backend.<name>]` config schema, exactly as a forge's works. A backend with a credential is expected to declare `credential_file`. |
|
||||
| `requires` | `?list:identifier` | (empty) | Which of those must be set for a launch to proceed. |
|
||||
|
||||
### What pairs with what
|
||||
|
||||
Compatibility is not a list anyone maintains: it is the backend's `api_style`
|
||||
against the harness's `api_styles`, checked at launch. This table is what those
|
||||
declarations currently work out to.
|
||||
|
||||
| Backend | `claude-code` | `cline` | Credential | You configure |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `anthropic` | yes | yes | OAuth, refreshed and written back | nothing, once you have logged in on the host |
|
||||
| `anthropic-api-key` | yes | yes | a static key, held on the host | nothing, if Claude Code signed you in; otherwise `[backend.anthropic-api-key] credential_file` |
|
||||
| `openrouter` | no | yes | a static key, held on the host | `[backend.openrouter] credential_file` |
|
||||
| `self-hosted` | no | yes | none, and so no proxy at all | `[backend.self-hosted] base_url` |
|
||||
|
||||
`claude-code` speaks Anthropic's Messages API and nothing else, so the two
|
||||
OpenAI-compatible backends are refused with it - at launch, naming both sides,
|
||||
rather than by sending well-formed requests in a shape the endpoint cannot
|
||||
parse. `cline` has a client for each dialect, which is what makes it the
|
||||
backend-agnostic one.
|
||||
|
||||
Adding a backend does not change this table by hand: a new directory whose
|
||||
`api_style` is one a harness already declares works with that harness the day
|
||||
it lands.
|
||||
|
||||
**Which backend a launch uses is the harness's setting**, `[harness.<name>]
|
||||
backend`, overridable for one launch with `--backend`. Per harness because
|
||||
that is how it is used: one harness against Anthropic and another against
|
||||
something else, on the same machine.
|
||||
|
||||
No harness ships a default for it, for the reason `[core] harness` has none. A
|
||||
harness may instead declare `assumed_backend`: what it was talking to before
|
||||
the key existed, which dejima writes into the operator's config the first time
|
||||
it finds the key unset, and says so. That is a migration rather than a default
|
||||
- recorded once in the operator's own file, so a later change to dejima cannot
|
||||
move a session whose config never changed. A harness only states it where the
|
||||
value is not a guess; one whose past endpoint depended on other settings
|
||||
declares nothing, and an unset backend is then a refusal.
|
||||
|
||||
**Nothing here is a secret.** A backend declares *where* its credential is
|
||||
read from - `credential_file` is a path - never what it is. The file it names
|
||||
is read host-side per request and is never mounted into the container, which
|
||||
is the property the whole arrangement exists for.
|
||||
|
||||
## Tool
|
||||
|
||||
Not an axis: a launch selects one forge, one workflow and one harness, and
|
||||
@@ -442,7 +514,11 @@ every launch:
|
||||
| workflow | `branch-push` | worktree → push, stops at the branch |
|
||||
| workflow | `local-only` | **default** - commits locally, pushes nothing |
|
||||
| harness | `claude-code` | hosts every guard; **not a default** - `[core] harness` must be set |
|
||||
| harness | `cline` | provider-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
|
||||
| harness | `cline` | backend-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
|
||||
| backend | `anthropic` | Anthropic on a subscription login; the OAuth token is refreshed and written back to the host |
|
||||
| backend | `anthropic-api-key` | Anthropic on API billing, whether the key came from Claude Code's console login (`primaryApiKey` in `~/.claude.json`, the default) or a file you hold yourself |
|
||||
| backend | `openrouter` | a hosted OpenAI-compatible API; a static key, held on the host |
|
||||
| backend | `self-hosted` | a model you serve; no credential and so no proxy, and the endpoint must be host-local |
|
||||
|
||||
### Harness integration maturity
|
||||
|
||||
@@ -461,7 +537,7 @@ over, which is a kind of coverage no test suite substitutes for.
|
||||
**Cline is new, and has barely been used.** Its integration was built
|
||||
deliberately and is tested, but a test only covers what someone thought to
|
||||
check. The first run against a live model immediately turned up a bug no test
|
||||
had: Cline silently discards a provider entry with no `updatedAt` and falls back
|
||||
had: Cline silently discards a `providers.json` entry with no `updatedAt` and falls back
|
||||
to its own hosted endpoint, which presents as a connection failure to a host
|
||||
nobody configured. The first real session turned up a second, in a component
|
||||
Cline does not own at all - the conversation shipper passed its payload as a
|
||||
|
||||
@@ -57,7 +57,11 @@ doing any work.
|
||||
hostile?** The field says no for untrusted code, and tiers gVisor and
|
||||
per-workload VM kernels above it. dejima's answer is that escape moves a
|
||||
layer - true where the layer exists. See
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes)
|
||||
and [Why a container, and not a microVM](threat-model.md#why-a-container-and-not-a-microvm),
|
||||
which is where the reasoning is written down so a sweep can contradict it.
|
||||
The live half of this question is question 5: whether a current escape
|
||||
clears this configuration's floor.
|
||||
2. **Does a name-based egress allowlist hold?** It holds against an agent that
|
||||
must resolve a name. It does not hold against exfiltration through an
|
||||
allowed domain or against DNS query-name channels, both of which are listed
|
||||
|
||||
+156
-1
@@ -277,6 +277,9 @@ a line added to a manifest widens the egress boundary on the next run.
|
||||
container: it names a tarball URL, the sha256 checked against it and the
|
||||
layout it is unpacked with, all consumed by the image build - so a session
|
||||
that edits one chooses what the next launch downloads and installs as root.
|
||||
`backends/*/backend.conf` names the upstream every request is forwarded to and
|
||||
the host path the credential is read from, so an edit there redirects where a
|
||||
real credential is sent.
|
||||
|
||||
An ordinary edit is a diff you are about to read and is not reported; one git
|
||||
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
|
||||
@@ -574,6 +577,158 @@ approximation of its name.
|
||||
The session's own share arrives at `~/shared` without going through either.
|
||||
## The model API credential never enters the container
|
||||
|
||||
### Four arrangements, and what a session can take from each
|
||||
|
||||
Which one a launch is in comes from the selected backend's `credential` kind
|
||||
and from whether the proxy is on. The differences are worth stating together,
|
||||
because only the last one puts a durable credential where a session can read
|
||||
it.
|
||||
|
||||
| Arrangement | In the container | What a compromised session can take | What it can still do |
|
||||
| --- | --- | --- | --- |
|
||||
| **Proxied, refreshing** (`credential = anthropic-oauth`) | A fake credential file whose secrets are placeholders. The real one is read host-side per request. | The placeholder. | Use the API as you, through the proxy, while the session runs. |
|
||||
| **Proxied, static key** (`credential = static`) | A placeholder key in the harness's own model configuration, and the endpoint's real hostname. | The placeholder. | The same. |
|
||||
| **A backend with no credential** (`credential = none`, a model you serve) | Nothing but the endpoint's address. It needs no credential, so there is none to withhold. | Nothing. | Reach that endpoint, which is why it has to be one you allowlisted. |
|
||||
| **No proxy** (`[credentials] proxy = off`, or a Claude Code login the selected backend cannot serve) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
|
||||
|
||||
**The first three keep the credential out; the fourth does not.** That is the
|
||||
whole of the difference, and it is why `proxy = off` is described here as a
|
||||
kill switch rather than as an option.
|
||||
|
||||
**Both Anthropic billing models are in the first three.** A subscription login
|
||||
is `backends/anthropic`, whose OAuth token the proxy refreshes and writes back;
|
||||
a console key is `backends/anthropic-api-key`, a static credential against the
|
||||
same endpoint presented as `x-api-key` - one backend whether the key came from
|
||||
Claude Code's own login or a file you hold. Either way the credential is held on
|
||||
the host and injected per request. On the API-key path the container gets
|
||||
`ANTHROPIC_API_KEY=sandbox-credential-proxy-placeholder` and no credential file
|
||||
at all.
|
||||
|
||||
What lands in the fourth row is `proxy = off`, and a login whose shape no
|
||||
selected backend serves.
|
||||
|
||||
**The proxy is switched per backend, not globally.** `[backend.<name>] proxy`
|
||||
falls back to `[credentials] proxy` when unset. One backend whose credential
|
||||
the proxy cannot serve is then an opt-out for that backend, rather than a
|
||||
reason to stop withholding credentials from the ones it serves. Turning it off
|
||||
for a backend prints what the session is being given, because it is a
|
||||
downgrade the operator chose rather than a setting that happens to be off.
|
||||
|
||||
**Everything dejima copies in is scanned for credential-shaped values it does
|
||||
not recognise, and an unknown one is a question rather than a surprise.** The
|
||||
scanner reports a redacted path, a shape name, the harness and the backend -
|
||||
never the value, and never a real path, since a gitconfig can carry userinfo
|
||||
in a section header. **Finding it is best effort**: this recognises common
|
||||
credential shapes and can miss an unusual one. Removing one it has found is
|
||||
not - a value it names is removed whole, including one split across
|
||||
continuation lines.
|
||||
|
||||
The choice is yours: keep it out for this launch, let it in for this launch,
|
||||
let it in every launch, or stop. Only the third persists, in
|
||||
`credential-scan.decisions` beside your config, keyed by field rather than by
|
||||
value so rotating a credential does not re-ask a question you already
|
||||
answered. A standing *scrub* is available there too but is not offered at the
|
||||
prompt: removing something the harness turns out to need would break every
|
||||
launch after, with the cause several layers from the symptom. With nothing
|
||||
attached to answer, the launch stops rather than carrying it in.
|
||||
|
||||
This is the general form of the two fixes below, which are denylists and so
|
||||
only know what was already found. See
|
||||
[#291](https://gitbox.branderson.io/dejima/dejima/issues/291).
|
||||
|
||||
#### What the scan does not catch
|
||||
|
||||
It recognises the *shape* of a credential, not its meaning, so it is a
|
||||
backstop against the class of leak that has actually happened here twice -
|
||||
not a guarantee that nothing crosses. Specifically:
|
||||
|
||||
- **Values containing whitespace.** Both heuristic rules require one token
|
||||
from a restricted alphabet, because the alternative flags every sentence in
|
||||
a config file: English prose scores 4.0-4.5 bits per character by the same
|
||||
entropy measure a random key does. People do put spaces in passphrases they
|
||||
chose themselves, and one of those would go straight through. It is a
|
||||
deliberate trade and it fits these files, which hold credentials issued to a
|
||||
machine rather than typed by a person.
|
||||
- **Short or low-entropy secrets in fields not named like one.** A token that
|
||||
is eight lowercase letters in a field called `nickname` is indistinguishable
|
||||
from a nickname.
|
||||
- **Anything in a format it cannot parse.** JSON and INI only. An unparseable
|
||||
file is copied unchecked, and says so at launch rather than being silently
|
||||
trusted or silently blocked.
|
||||
- **Anything dejima does not copy.** This covers the files dejima *stages* -
|
||||
`~/.claude.json` and `~/.gitconfig`. It does not read your read-write
|
||||
mounts, the workspace, or anything else you have pointed a session at, and a
|
||||
credential you leave in one of those is not something dejima is looking for.
|
||||
|
||||
The value of it is not that it is exhaustive. It is that a credential shape
|
||||
dejima has never seen becomes a question at launch instead of a copy nobody
|
||||
noticed for a year.
|
||||
|
||||
**Two host files are copied into a container, and both are filtered.**
|
||||
`~/.gitconfig` can hold a `credential.helper` that prints a token, an
|
||||
`http.<url>.extraHeader` carrying an Authorization line, or userinfo inside a
|
||||
`url.<...>.insteadOf` key; none of that is part of what the sandbox is
|
||||
granted, so the copy drops it and says so. The general problem - that these
|
||||
filters are denylists, and a harness's authentication is not a stable
|
||||
interface - is [#291](https://gitbox.branderson.io/dejima/dejima/issues/291).
|
||||
|
||||
**`~/.claude.json` is staged, not mounted, and the staging strips a
|
||||
credential.** That file mixes app state with configuration the copy exists to
|
||||
carry, and a Claude Code signed in with a console API token keeps its key
|
||||
there as a top-level `primaryApiKey` - not in `.credentials.json`, which stays
|
||||
`{}` for that login. The copy drops that key, and `backends/anthropic-api-key`
|
||||
reads it host-side per request instead. Without it the proxy would
|
||||
withhold one file while dejima copied another, on every launch and whatever
|
||||
backend was selected.
|
||||
|
||||
**The residual the first two share is use-while-running.** A session that has
|
||||
been turned can spend your quota, ask the model anything, and read the answers
|
||||
- through the proxy, without ever holding the credential. That is inherent to
|
||||
the sandbox working at all: the session has to be able to reach the model, and
|
||||
anything it can reach it can misuse. What the proxy buys is that the misuse
|
||||
stops when the session does, and nothing durable leaves.
|
||||
|
||||
**Refreshing and static differ in what a leak elsewhere costs, not in what the
|
||||
container sees.** Neither enters it. But an OAuth access token is short-lived
|
||||
and the proxy rotates it, while an API key is typically long-lived and
|
||||
account-wide - so the host-side file is worth more in the static case, and a
|
||||
compromise of the *host* is correspondingly worse. Scope the key at the
|
||||
backend where you can, the same argument
|
||||
[docs/providers.md](providers.md) makes for a repository-scoped forge token.
|
||||
|
||||
**The proxied domain is not in the egress allowlist while the proxy is on.**
|
||||
`docker/fixed-domains.sh` adds it only when there is no proxy, so a session
|
||||
that tries to reach the API by name finds it unresolvable and goes through the
|
||||
terminator instead. Worth being precise about the limit: this defeats a lookup,
|
||||
not a route. The API's address stays reachable as an IP literal for as long as
|
||||
any other allowlisted name resolves to it.
|
||||
|
||||
**A hosted OpenAI-compatible backend is the second arrangement, and it is
|
||||
what `backends/openrouter` is.** The key sits in a file on the host, named by
|
||||
`[backend.openrouter] credential_file` and read per request; the container
|
||||
gets `sandbox-credential-proxy-placeholder` and the endpoint's real hostname,
|
||||
which the in-container terminator answers for. Nothing about the arrangement
|
||||
is visible to the session, which is the same property the Anthropic path has
|
||||
and the reason both are worth having rather than one.
|
||||
|
||||
Worth naming what a *hosted* backend adds over Anthropic's OAuth, since it is
|
||||
not the container that changes: an API key is typically long-lived and
|
||||
account-wide where an access token is short-lived and rotated, so the host-side
|
||||
file is worth more. Scope the key at the backend where it offers that, the
|
||||
same argument this repository makes for a repository-scoped forge token.
|
||||
|
||||
**A backend you serve yourself is the strongest posture available**, and it is
|
||||
worth saying so plainly rather than treating it as the degraded one. A session
|
||||
pointed at a model on your own network has no credential anywhere in the
|
||||
picture, so there is nothing to place, nothing to withhold and nothing to
|
||||
steal. What it costs is that the model is yours to run.
|
||||
|
||||
It is a backend like any other - `backends/self-hosted`, with
|
||||
`credential = none` and its address in `[backend.self-hosted] base_url`. That
|
||||
placement is deliberate: an operator who serves a model locally and also uses a
|
||||
hosted API keeps both configured and switches by changing one word, rather than
|
||||
having to unset one setting to stop it overriding another.
|
||||
|
||||
**This assumes the host has one.** dejima authenticates nothing: the proxy
|
||||
holds a credential the harness wrote on the host, so a machine where the
|
||||
harness has never signed in has nothing for it to hold. The proxy then does
|
||||
@@ -611,7 +766,7 @@ session, where the credential deliberately never goes.
|
||||
comma-list value has to be present are all arguments, and `bin/dejima`
|
||||
supplies the Anthropic ones (#171). The credential kind is an argument too -
|
||||
a refreshing OAuth file, or a static key that is read and sent - so a
|
||||
provider that cannot be refreshed is not a branch here. Left
|
||||
backend that cannot be refreshed is not a branch here. Left
|
||||
alone, Python's `http.client` would rewrite all of that into its own style
|
||||
and move the fingerprint from the client to the wire. It cannot disguise
|
||||
that the connection is CPython's `ssl` rather than Node: the TLS handshake
|
||||
|
||||
@@ -263,6 +263,80 @@ repository's behalf.
|
||||
**What this repository can do about that is say so**, which is the whole of
|
||||
this section. Nothing in dejima can bound its own host.
|
||||
|
||||
## Why a container, and not a microVM
|
||||
|
||||
The most frequent question about this project.
|
||||
|
||||
The short answer is that a microVM is the stronger boundary and nothing here
|
||||
disputes it. Container escape is the least likely of the six threats above and
|
||||
the only one that needs an exploit at all, and the layer it lands on is already
|
||||
bounded by a control enforced from outside the guest. The effort went there
|
||||
rather than into a harder container.
|
||||
|
||||
**There is already a VM here**, which is what the usual framing misses. It is
|
||||
put as container versus microVM, as though the alternative were a container
|
||||
with nothing underneath. On the deployment this document describes, dejima runs
|
||||
inside a devbox VM whose rule set is enforced from the hypervisor. So the choice
|
||||
is not one boundary against two. It is an outer VM enforced from outside the
|
||||
guest, against that plus an inner one managed by software running on the box it
|
||||
is protecting - inside the blast radius of the row it is meant to bound. A
|
||||
boundary enforced from outside is worth more than one enforced by the thing
|
||||
inside it, and that is most of the answer.
|
||||
|
||||
**The field's ordering is not in dispute.** gVisor's syscall interposition and
|
||||
a per-workload VM kernel are both stronger than a namespaced container.
|
||||
Firecracker is what AWS runs Lambda on, for the reason that applies here too:
|
||||
untrusted code, multi-tenant, on shared hardware. The disagreement is about
|
||||
where marginal effort goes, not about which primitive is stronger.
|
||||
|
||||
### What a stronger runtime would and would not change
|
||||
|
||||
It would not touch the five threats ranked above escape. Two of them would get
|
||||
*harder*, because both are enforced by how the container is configured rather
|
||||
than by what isolates it:
|
||||
|
||||
- **One project's session cannot reach another's**, because each path crossing
|
||||
the boundary is named individually rather than mounting a tree
|
||||
([#104](https://gitbox.branderson.io/dejima/dejima/issues/104)). Under a VM
|
||||
each becomes a filesystem daemon.
|
||||
- **The container sees an agent socket, never your key.** That is a live host
|
||||
socket, and a VM has no equivalent to hand it one.
|
||||
|
||||
One would get easier: egress rules on a tap device sit where the guest holds no
|
||||
capability to touch them. That is available without changing the runtime, which
|
||||
is what [#305](https://gitbox.branderson.io/dejima/dejima/issues/305) does.
|
||||
|
||||
**And "swap the runtime" understates the work**, because several controls are
|
||||
implemented *in* Docker rather than merely hosted by it. The dependency
|
||||
approval is a derived image build; the tooling reaches live sessions through
|
||||
it; the capability floor is an argument about Docker's model. Porting them is a
|
||||
rewrite of the parts that *are* the security argument, in a project that says
|
||||
at the top that none of it has been reviewed by anyone else.
|
||||
|
||||
### What would change the answer
|
||||
|
||||
The decision is a judgement about effort, not a door held shut, and these are
|
||||
the things that would reopen it:
|
||||
|
||||
- **Kata Containers is the cheap path if this reasoning is wrong.** It is an
|
||||
OCI runtime, so the compose file, the mounts and the tooling survive, and it
|
||||
can use Firecracker underneath. A per-launch switch rather than a fork.
|
||||
- **Pointing dejima at projects chosen by someone else.** That contradicts an
|
||||
assumption stated at the top of this document, and reorders the ranked list.
|
||||
- **A container escape in the wild that clears this configuration's floor**:
|
||||
non-root session user, `no-new-privileges`, `cap_drop: ALL` and a short list
|
||||
added back. The class recurs, and whether a live one clears that floor is
|
||||
asked on a schedule in
|
||||
[research-review.md](research-review.md#the-standing-questions) rather than
|
||||
assumed either way.
|
||||
|
||||
What is done instead is depth below the container, which is
|
||||
[the stack](#the-stack-and-what-each-piece-buys): two of the three layers under
|
||||
dejima are enforced where the guest cannot reach them, and the third bounds a
|
||||
compromise in time. A sandbox that reaches for a microVM and stops there has
|
||||
nothing underneath it, so an escape from the VM ends its argument. Here it is a
|
||||
row with controls of its own.
|
||||
|
||||
## What this repository does not claim
|
||||
|
||||
- **That the egress allowlist bounds what reaches the model.** It bounds what
|
||||
@@ -276,6 +350,14 @@ this section. Nothing in dejima can bound its own host.
|
||||
belongs in that harness's assessment.
|
||||
[security.md](security.md#the-firewall-bounds-the-container-not-the-harness)
|
||||
has the evidence.
|
||||
- **That a session cannot use the model API as you while it runs.** It can.
|
||||
The credential proxy keeps the credential on the host, so nothing durable
|
||||
leaves and the misuse stops when the session does - but a session that has
|
||||
been turned can spend your quota and read whatever it asks for, through the
|
||||
proxy, without ever holding the credential. That is inherent to the sandbox
|
||||
working at all. [security.md](security.md#four-arrangements-and-what-a-session-can-take-from-each)
|
||||
has the four arrangements and what each one puts in the container, including
|
||||
the one that puts the credential itself there.
|
||||
- **That escape is impossible.** It is a row in the table with controls of its
|
||||
own, not an outcome that has been designed away.
|
||||
- **That the hooks stop a hostile agent.** They stop an unlucky one.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
They can continue this exact session outside the container by running on
|
||||
the host:
|
||||
|
||||
{{resume_command}}
|
||||
|
||||
(session history is shared via the bind-mounted ~/.claude, so it will pick
|
||||
|
||||
@@ -5,11 +5,38 @@ image_env = CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
command = claude
|
||||
command_path = /usr/local/share/npm-global/bin/claude
|
||||
api_domains = platform.claude.com
|
||||
proxied_api_domain = api.anthropic.com
|
||||
guards = bash-deny push-guard merge-guard notify
|
||||
|
||||
# Anthropic's Messages API and nothing else. Claude Code speaks one dialect, so
|
||||
# pairing it with an OpenAI-compatible backend is refused at launch rather
|
||||
# than producing well-formed requests in the wrong shape.
|
||||
api_styles = anthropic
|
||||
no_confirm_args = --dangerously-skip-permissions
|
||||
briefing_args = --append-system-prompt
|
||||
settings_args = --settings /etc/dejima-settings.json
|
||||
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
|
||||
# Which model API this harness talks to, and so which credential the host-side
|
||||
# proxy injects. Per harness rather than global (#171): an operator running
|
||||
# Claude Code against Anthropic and Cline against something else is the
|
||||
# ordinary case, and one setting cannot say both.
|
||||
#
|
||||
# NO DEFAULT, for the reason [core] harness has none. A default that could ever
|
||||
# change is a config file that is not the source of truth: a session whose
|
||||
# config did not change must not start talking to a different endpoint because
|
||||
# dejima's idea of a sensible one moved.
|
||||
keys = backend:?provider:backend projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
|
||||
|
||||
# What this harness was talking to before the key existed, WRITTEN INTO the
|
||||
# operator's config the first time a launch finds it unset - not resolved as a
|
||||
# default on every read.
|
||||
#
|
||||
# The difference is the whole point. A default is dejima's answer, re-decided
|
||||
# every launch, and changing it later moves a session whose config never
|
||||
# changed. A migration is the operator's answer, recorded once: after it runs,
|
||||
# nothing dejima decides later can move them.
|
||||
#
|
||||
# Safe to state here because it is not a guess. Claude Code against Anthropic
|
||||
# is what every existing session already was, so writing it down changes
|
||||
# nothing about the launch it runs in.
|
||||
assumed_backend = anthropic
|
||||
resume_command = claude --resume {{session_id}}
|
||||
briefing = briefing.md
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export CLINE_DATA_DIR={{harness_state_dir}}
|
||||
cline history # find this session's id
|
||||
cline --id <session-id>
|
||||
This session cannot be continued outside dejima, so do not offer that or ask
|
||||
the operator to try it. It CAN be continued by launching dejima again on this
|
||||
project: this project's Cline state - sessions, transcripts, tasks - is kept
|
||||
on the host in a directory of its own and survives the container being
|
||||
replaced, so the next launch picks up where this one left off.
|
||||
|
||||
(the session id is deliberately absent from this briefing: Cline mints one when
|
||||
a run starts, which is after this text was written, so `cline history` is where
|
||||
to read it. CLINE_DATA_DIR has to be set because this project's Cline state is
|
||||
kept in that directory on the host rather than in ~/.cline/data - Cline indexes
|
||||
every session it has ever run in a single database, so dejima gives each
|
||||
project its own and one project's history is never another's context.)
|
||||
(Cline indexes every session it has ever run in a single database, so dejima
|
||||
gives each project its own rather than sharing one. That directory is
|
||||
dejima's, not Cline's own, and pointing a host-side Cline at it is not a
|
||||
supported path - which is why resuming outside is not on offer.)
|
||||
|
||||
@@ -14,7 +14,9 @@ install = npm:cline
|
||||
# Documents/Cline/Rules is where the briefing is staged. That path is not a
|
||||
# guess and not what Cline's documentation says: ~/Documents/Cline/Rules
|
||||
# reaches the model on Linux and ~/Cline/Rules does not.
|
||||
image_dirs = .cline .cline/data Documents Documents/Cline Documents/Cline/Rules
|
||||
# .agents and .cline/hooks are the operator's own config, mounted from the
|
||||
# host by the plugin; they need a mount point in the image to land on.
|
||||
image_dirs = .cline .cline/data .cline/hooks .agents .agents/skills .agents/hooks Documents Documents/Cline Documents/Cline/Rules
|
||||
|
||||
# DO_NOT_TRACK and DISABLE_TELEMETRY are both honoured; CLINE_NO_AUTO_UPDATE
|
||||
# stops a session upgrading its own harness mid-run, which would replace a
|
||||
@@ -28,15 +30,9 @@ command_path = /usr/local/share/npm-global/bin/cline
|
||||
# endpoint with the firewall up, so there is no catalogue or feature-flag host
|
||||
# to allowlist. Cline's own endpoints (api.cline.bot and friends) are
|
||||
# deliberately absent - a session that falls back to them has lost its
|
||||
# provider configuration, and failing to connect is the right outcome.
|
||||
# backend configuration, and failing to connect is the right outcome.
|
||||
api_domains =
|
||||
|
||||
# Only meaningful on the Anthropic path, where Cline is pointed at the real
|
||||
# hostname with a placeholder key and dejima's terminator and host-side proxy
|
||||
# supply the credential. Confirmed end to end. On the local-endpoint path
|
||||
# nothing connects here and pinning it costs nothing.
|
||||
proxied_api_domain = api.anthropic.com
|
||||
|
||||
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
|
||||
# nowhere to live, and it has no session lifecycle signal dejima can turn into
|
||||
# a notification. push-guard and merge-guard are not listed because no harness
|
||||
@@ -44,6 +40,12 @@ proxied_api_domain = api.anthropic.com
|
||||
# names what this costs; see harness_report_guards.
|
||||
guards =
|
||||
|
||||
# Both, which is what makes this harness the backend-agnostic one: it has a
|
||||
# native Anthropic client and an openai-compatible one, and dejima picks
|
||||
# between them from the selected backend's api_style rather than from a
|
||||
# setting an operator has to keep in step by hand.
|
||||
api_styles = anthropic openai
|
||||
|
||||
# R3. Cline registers no session-start hook, so the briefing is a file, and
|
||||
# this is the path that reaches the model - established by running it, not by
|
||||
# reading the documentation, which says ~/Cline/Rules. On Linux that directory
|
||||
@@ -63,9 +65,10 @@ no_confirm_args = --auto-approve true
|
||||
# goes stale with the next Cline release, and choosing the model an unattended
|
||||
# agent runs on is the operator's call. Absent, the launch refuses and says so.
|
||||
#
|
||||
# base_url absent means the Anthropic path through dejima's credential proxy.
|
||||
# Set, it means a self-hosted endpoint, and the plugin refuses one that is not
|
||||
# host-local - see bin/harness/cline.sh for why that is an R5 check.
|
||||
# base_url is NOT here. Where the model is belongs to the backend: a
|
||||
# self-served one carries its own [backend.<name>] base_url, which is what
|
||||
# lets an operator keep a local endpoint configured while using a hosted API
|
||||
# and switch between them by changing one word.
|
||||
# context_window is the one Cline cannot work out for itself. It budgets
|
||||
# against its own catalogue, and for a model it does not recognise - which is
|
||||
# every self-served one - it has no budget at all, so it grows the conversation
|
||||
@@ -77,7 +80,18 @@ no_confirm_args = --auto-approve true
|
||||
# compacts constantly. Set it a little under whatever the server allows -
|
||||
# Cline's accounting is approximate and a compaction does not always land
|
||||
# under target when a single tool result is large.
|
||||
keys = state_dir:path=~/.local/share/dejima/cline model:?identifier base_url:?url context_window:?int:512:100000000
|
||||
# See claude-code's manifest for why this is per harness and why it has no
|
||||
# default. The risk is concrete here rather than theoretical: Cline supports
|
||||
# many backends and has one of its own, so any value dejima shipped
|
||||
# would be dejima choosing an endpoint on the operator's behalf, and changing
|
||||
# it later would move a running setup without touching its config.
|
||||
#
|
||||
# And no assumed_backend either, deliberately. What this harness was talking
|
||||
# to before the key existed depends on base_url - unset meant Anthropic, set
|
||||
# meant a model the operator serves - so there is no single value to record,
|
||||
# and recording the wrong one would write a proxied endpoint into the config
|
||||
# of a session that never used one. Making the two coherent is its own change.
|
||||
keys = backend:?provider:backend state_dir:path=~/.local/share/dejima/cline model:?identifier context_window:?int:512:100000000
|
||||
|
||||
# Deliberately empty: resuming needs the state directory's path as well as the
|
||||
# session id, briefing placeholders do not nest, and `template` forbids the
|
||||
|
||||
+13
-2
@@ -629,10 +629,21 @@ EOF
|
||||
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
|
||||
snap; git -C "$WS" config user.name changed
|
||||
chmod 500 "$AUDITDIR/$slug"
|
||||
run timeout 5 "$AUDIT" compare "$WS" "$STATE"
|
||||
# The subject is FOREVER, not fast. Rotation trims oldest-first and breaks
|
||||
# when the unlink fails; without that break the loop's condition never
|
||||
# changes and it spins. Any finite bound catches that, so the bound is
|
||||
# generous on purpose: a five-second one was timing the machine, and on a
|
||||
# loaded one a correct run exceeded it and reported a spin that had not
|
||||
# happened (#295).
|
||||
run timeout 120 "$AUDIT" compare "$WS" "$STATE"
|
||||
result="$status"
|
||||
chmod 700 "$AUDITDIR/$slug"
|
||||
[ "$result" -ne 124 ]
|
||||
[ "$result" -ne 124 ] || { echo "rotation did not terminate"; return 1; }
|
||||
# And it gave up rather than evicting: an unwritable directory means every
|
||||
# planted report is still there. Without this the test says only that the
|
||||
# command exited, which a rotation that silently did nothing also satisfies.
|
||||
[ "$(find "$AUDITDIR/$slug" -maxdepth 1 -name '2026-01-01-*.txt' | wc -l)" -eq 12 ] \
|
||||
|| { echo "reports were evicted from a directory that cannot be written"; return 1; }
|
||||
}
|
||||
|
||||
# One `ack` run used to walk one report and exit, saying nothing about the
|
||||
|
||||
+951
-13
File diff suppressed because it is too large
Load Diff
+57
-14
@@ -29,17 +29,25 @@ teardown() {
|
||||
# A real HTTP server standing in for Loki, appending each pushed body to a
|
||||
# file. Real because the shipper reaches it with curl: a stub that replaced
|
||||
# curl would stop testing the thing that formats and sends the payload.
|
||||
# The server binds port 0 itself and reports what it got, rather than a helper
|
||||
# picking a free port and handing the number over.
|
||||
#
|
||||
# That handover was a race, and `bats --jobs` is what makes it fire: the helper
|
||||
# bound port 0, read the number, CLOSED the socket, and only then did the
|
||||
# server bind it. Another test starting in that window takes the same port, and
|
||||
# the loser dies with "Address already in use" - but the readiness probe below
|
||||
# still succeeds, because something IS listening on that port. The test then
|
||||
# ships to the other test's Loki, finds its own log empty, and reports the
|
||||
# failure as a lost transcript. Seen in CI, not locally: it needs enough cores
|
||||
# for two tests to be in that window at once.
|
||||
start_loki() {
|
||||
LOKI_PORT="$(python3 - <<'EOF'
|
||||
import socket
|
||||
s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()
|
||||
EOF
|
||||
)"
|
||||
python3 - "$LOKI_PORT" "$LOKI_LOG" <<'EOF' &
|
||||
import sys
|
||||
local portfile="$BATS_TEST_TMPDIR/loki-port"
|
||||
rm -f "$portfile"
|
||||
python3 - "$portfile" "$LOKI_LOG" <<'EOF' &
|
||||
import sys, os
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
port, path = int(sys.argv[1]), sys.argv[2]
|
||||
portfile, path = sys.argv[1], sys.argv[2]
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
@@ -51,12 +59,26 @@ class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
HTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||
srv = HTTPServer(("127.0.0.1", 0), H)
|
||||
# Written only once the socket is bound and listening, and atomically, so the
|
||||
# shell can never read a half-written number or one for a socket that is gone.
|
||||
tmp = portfile + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(str(srv.server_address[1]))
|
||||
os.replace(tmp, portfile)
|
||||
srv.serve_forever()
|
||||
EOF
|
||||
LOKI_PID=$!
|
||||
local i
|
||||
for i in $(seq 1 50); do
|
||||
(exec 3<>/dev/tcp/127.0.0.1/"$LOKI_PORT") 2>/dev/null && return 0
|
||||
for i in $(seq 1 100); do
|
||||
if [ -s "$portfile" ]; then
|
||||
LOKI_PORT="$(cat "$portfile")"
|
||||
return 0
|
||||
fi
|
||||
# A server that died on startup will never write the file, and waiting
|
||||
# the full timeout for a process that is already gone only delays the
|
||||
# real message.
|
||||
kill -0 "$LOKI_PID" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
echo "the fake Loki never came up" >&2
|
||||
@@ -388,8 +410,16 @@ EOF
|
||||
# a concurrent run is unaffected.
|
||||
local proot="$BATS_TEST_TMPDIR/proot"
|
||||
mkdir -p "$proot"
|
||||
# Loudly: a partial copy here produces exactly the symptom this test is
|
||||
# about - a lookup that fails - and swallowing the error would report the
|
||||
# fixture's fault as the code's.
|
||||
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" \
|
||||
"$REPO_ROOT/providers" "$proot/" 2>/dev/null || true
|
||||
"$REPO_ROOT/backends" "$proot/" \
|
||||
|| { echo "could not build the probe provider root"; return 1; }
|
||||
local axis
|
||||
for axis in forges workflows harnesses backends; do
|
||||
[ -d "$proot/$axis" ] || { echo "the probe provider root has no $axis/"; return 1; }
|
||||
done
|
||||
sed -i 's/^keys \( *\)=/keys \1= probe_only_key:?identifier/' \
|
||||
"$proot/harnesses/claude-code/harness.conf"
|
||||
grep -q 'probe_only_key:?identifier' "$proot/harnesses/claude-code/harness.conf" \
|
||||
@@ -400,12 +430,25 @@ EOF
|
||||
write_config
|
||||
mkdir -p "$(slug_dir)"
|
||||
start_ship
|
||||
sleep 1
|
||||
|
||||
# Wait for the shipper to be demonstrably running rather than sleeping at
|
||||
# it. A fixed sleep is a second race on a loaded machine: if the process
|
||||
# is still starting when the signal arrives, the handler runs before the
|
||||
# key schema has been resolved, which fails for the ORIGINAL #284 reason
|
||||
# and would read as this fix not working.
|
||||
say "a first line" >> "$(slug_dir)/s1.jsonl"
|
||||
await_shipped "a first line" || { echo "the shipper never started"; return 1; }
|
||||
|
||||
say "the final answer" >> "$(slug_dir)/s1.jsonl"
|
||||
kill -TERM "$SHIP_PID"
|
||||
wait "$SHIP_PID" 2>/dev/null || true
|
||||
SHIP_PID=""
|
||||
[[ "$(shipped)" == *"the final answer"* ]] \
|
||||
# Wait for the line rather than reading the log the instant the shipper
|
||||
# exits. `wait` returns when the SHIPPER is gone, which says nothing about
|
||||
# the collector on the other end having written what it was sent - so
|
||||
# reading immediately is a race the loaded machine loses, and it fails as
|
||||
# "the tail was lost", which is precisely this test's bug signature (#295).
|
||||
await_shipped "the final answer" \
|
||||
|| { echo "the unread tail was lost: $(shipped)"; return 1; }
|
||||
# And the lookup did not merely survive - it never failed. That message
|
||||
# reaching an operator's terminal is the visible half of this bug.
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs a command on a real pty and answers its prompts.
|
||||
|
||||
The credential scan only asks when stdin is a terminal, which is the whole
|
||||
point - an unattended launch must not block on a question nobody will see. So
|
||||
a test that drives the interactive path needs a pty rather than a pipe, or it
|
||||
exercises the non-interactive branch while claiming to test the other one.
|
||||
|
||||
Usage: answer-prompt.py [--marker TEXT] <answer-per-prompt>... -- <command> [args...]
|
||||
|
||||
Each answer is sent when the child writes a line ending in the prompt marker.
|
||||
Everything the child wrote is printed on exit, and the child's exit status is
|
||||
this program's.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
|
||||
# The default is the credential scan's prompt. --marker takes another, so a
|
||||
# test for a different prompt does not need a second copy of this.
|
||||
MARKER = "[s/a/o/r]"
|
||||
|
||||
# dejima pauses before the image build when it printed a notice worth reading.
|
||||
# That is not the prompt any test here is about - it is ambient, and every
|
||||
# launch fixture that sets [core] ssh_key raises it - so it is answered
|
||||
# automatically rather than costing every caller an extra argument.
|
||||
AMBIENT = "Press Enter to continue"
|
||||
# An IDLE timeout: how long to wait with no output before giving up. Generous
|
||||
# because the thing it guards against is a command that never finishes, and
|
||||
# any finite bound catches that - while a tight one just times the machine.
|
||||
# Sixty seconds was enough on an idle box and not enough under the full gate,
|
||||
# where every suite runs at once (#295).
|
||||
TIMEOUT = 240.0
|
||||
|
||||
|
||||
def main():
|
||||
if "--" not in sys.argv:
|
||||
print("usage: answer-prompt.py <answer>... -- <command>...", file=sys.stderr)
|
||||
return 2
|
||||
split = sys.argv.index("--")
|
||||
args = list(sys.argv[1:split])
|
||||
global MARKER
|
||||
if "--marker" in args:
|
||||
i = args.index("--marker")
|
||||
MARKER = args[i + 1]
|
||||
del args[i:i + 2]
|
||||
answers = args
|
||||
command = sys.argv[split + 1:]
|
||||
if not command:
|
||||
print("no command given", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
# The child: a session leader with this pty as its controlling
|
||||
# terminal, which is what makes `[ -t 0 ]` true inside dejima.
|
||||
try:
|
||||
os.execvp(command[0], command)
|
||||
except OSError as e:
|
||||
print("exec failed: %s" % e, file=sys.stderr)
|
||||
os._exit(127)
|
||||
|
||||
out = []
|
||||
pending = list(answers)
|
||||
deadline = TIMEOUT
|
||||
while True:
|
||||
r, _, _ = select.select([fd], [], [], deadline)
|
||||
if not r:
|
||||
out.append("\n[answer-prompt: timed out waiting for output]\n")
|
||||
break
|
||||
try:
|
||||
chunk = os.read(fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
text = chunk.decode("utf-8", "replace")
|
||||
out.append(text)
|
||||
# Answer only when the prompt is the most recent thing written, so a
|
||||
# marker appearing inside prose does not consume an answer.
|
||||
if pending and MARKER in text:
|
||||
os.write(fd, (pending.pop(0) + "\n").encode())
|
||||
elif AMBIENT in text and AMBIENT != MARKER:
|
||||
os.write(fd, b"\n")
|
||||
|
||||
# The child may still be sitting at a prompt nobody answered - an
|
||||
# unanswered question is the likeliest way for this to end, and waiting on
|
||||
# it turns a failing test into a hung suite. Killed rather than waited on,
|
||||
# and the caller is told which it was.
|
||||
stuck = False
|
||||
try:
|
||||
done, status = os.waitpid(pid, os.WNOHANG)
|
||||
if done == 0:
|
||||
stuck = True
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
except OSError:
|
||||
status = 0
|
||||
sys.stdout.write("".join(out))
|
||||
if pending:
|
||||
sys.stdout.write("\n[answer-prompt: %d answer(s) never used]\n" % len(pending))
|
||||
if stuck:
|
||||
sys.stdout.write("\n[answer-prompt: the command never exited - killed. "
|
||||
"It was probably waiting at a prompt nothing answered.]\n")
|
||||
return os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") \
|
||||
else (status >> 8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+3
-2
@@ -98,7 +98,8 @@ launch() {
|
||||
# launch is what triggers it. Its cache needs nothing further: it lands
|
||||
# under $HOME, which every launch fixture already points somewhere of its
|
||||
# own.
|
||||
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||
# backend is stated, not defaulted: it has none and never will (#171).
|
||||
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n[harness.claude-code]\nbackend = anthropic\n' \
|
||||
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
@@ -981,7 +982,7 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
# no domains at all, or by the env prefix below never reaching the child.
|
||||
printf 'good.example.com\n' > "$CONFIG_DIR/allowed-domains"
|
||||
launch_setup
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n[harness.claude-code]\nbackend = anthropic\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS=evil.example \
|
||||
|
||||
@@ -1345,3 +1345,312 @@ assert_remedy_forges_exist() {
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# --- the model provider axis (#171) ------------------------------------------
|
||||
|
||||
@test "the anthropic backend manifest validates and answers for every field" {
|
||||
lib manifest_validate backend anthropic
|
||||
[ "$(lib manifest_get backend anthropic upstream)" = "https://api.anthropic.com" ]
|
||||
[ "$(lib manifest_get backend anthropic proxied_domain)" = "api.anthropic.com" ]
|
||||
[ "$(lib manifest_get backend anthropic credential)" = "anthropic-oauth" ]
|
||||
[ "$(lib manifest_get backend anthropic auth_format)" = "Bearer {token}" ]
|
||||
[ "$(lib manifest_get backend anthropic strip_headers)" = "x-api-key" ]
|
||||
[ "$(lib manifest_get backend anthropic ensure_headers)" = "anthropic-beta=oauth-2025-04-20" ]
|
||||
}
|
||||
|
||||
@test "a backend's own keys become [backend.<name>] config keys" {
|
||||
# The same machinery a forge's keys go through, so a backend declaring
|
||||
# where its credential lives needs no row hardcoded in core's schema.
|
||||
[ "$(lib config_type backend.anthropic.credential_file)" = "path" ]
|
||||
run lib config_type backend.anthropic.not_a_key
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "an auth_format with no token placeholder is refused by the schema" {
|
||||
# The proxy refuses it too. Refusing it here as well is the difference
|
||||
# between finding out at launch and finding out from a provider's 401:
|
||||
# a format without {token} produces a proxy that starts, answers, and
|
||||
# sends every request unauthenticated.
|
||||
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/authfmt.XXXXXX")"
|
||||
mkdir -p "$d/backends/probe"
|
||||
_probe() {
|
||||
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\nauth_format = %s\n' "$1" \
|
||||
> "$d/backends/probe/backend.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
|
||||
}
|
||||
_probe 'Bearer'
|
||||
[ "$status" -ne 0 ] || { echo "a format with no {token} was accepted"; return 1; }
|
||||
_probe 'Bearer {token}'
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
_probe '{token}'
|
||||
[ "$status" -eq 0 ] || { echo "a bare-key format was refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an ensure_headers entry has to be NAME=VALUE with a header-shaped name" {
|
||||
# envpair would refuse anthropic-beta outright: it wants an upper-case
|
||||
# environment variable, and a header name is lower case with dashes.
|
||||
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/ensure.XXXXXX")"
|
||||
mkdir -p "$d/backends/probe"
|
||||
_ensure() {
|
||||
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\nensure_headers = %s\n' "$1" \
|
||||
> "$d/backends/probe/backend.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
|
||||
}
|
||||
local bad
|
||||
for bad in 'anthropic-beta' '=value' 'name=' 'has space=v'; do
|
||||
_ensure "$bad"
|
||||
[ "$status" -ne 0 ] || { echo "accepted ensure_headers = $bad"; return 1; }
|
||||
done
|
||||
_ensure 'anthropic-beta=oauth-2025-04-20'
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a backend name that is not a directory is refused by the config type" {
|
||||
# The same provider:<axis> check core.forge and core.harness go through,
|
||||
# so a typo names nothing rather than resolving somewhere.
|
||||
run lib _config_valid_value provider:backend anthropic
|
||||
[ "$status" -eq 0 ] || { echo "the shipped provider was refused: $output"; return 1; }
|
||||
run lib _config_valid_value provider:backend no-such-provider
|
||||
[ "$status" -ne 0 ] || { echo "a provider with no directory was accepted"; return 1; }
|
||||
run lib _config_valid_value provider:backend ../etc
|
||||
[ "$status" -ne 0 ] || { echo "a traversal was accepted"; return 1; }
|
||||
}
|
||||
|
||||
@test "the backend is selected per harness, not globally" {
|
||||
# An operator running Claude Code against Anthropic and Cline against
|
||||
# something else is the ordinary case, and it is why this is not one
|
||||
# [credentials] key. Each harness declares its own, with its own default.
|
||||
[ "$(lib config_type harness.claude-code.backend)" = "?provider:backend" ]
|
||||
[ "$(lib config_type harness.cline.backend)" = "?provider:backend" ]
|
||||
# And NO default, on either. One that could change later would move a
|
||||
# session to another endpoint without its config changing, which is the
|
||||
# reason [core] harness has none either.
|
||||
[ -z "$(lib config_default harness.claude-code.backend)" ] \
|
||||
|| { echo "claude-code ships a default backend"; return 1; }
|
||||
[ -z "$(lib config_default harness.cline.backend)" ] \
|
||||
|| { echo "cline ships a default backend"; return 1; }
|
||||
# And there is no global one to disagree with them.
|
||||
run lib config_type credentials.backend
|
||||
[ "$status" -ne 0 ] || { echo "a global backend key is back"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness that declares no backend names no endpoint to proxy" {
|
||||
# The self-served case: a model on the operator's own network needs no
|
||||
# credential withheld because there is none, so a harness may decline to
|
||||
# name a provider and the launch runs without a proxy.
|
||||
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/noprov.XXXXXX")"
|
||||
mkdir -p "$d/harnesses/bare"
|
||||
printf 'command = true\n' > "$d/harnesses/bare/harness.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; config_type harness.bare.backend' _ "$LIB"
|
||||
[ "$status" -ne 0 ] || { echo "a harness that declares nothing got a provider key"; return 1; }
|
||||
}
|
||||
|
||||
@test "openrouter is declared as a static-key, openai-style backend" {
|
||||
# The second shipped provider, and the case #171 was filed about: a hosted
|
||||
# endpoint behind a static API key, which dejima previously had no answer
|
||||
# for other than refusing it.
|
||||
lib manifest_validate backend openrouter
|
||||
[ "$(lib manifest_get backend openrouter upstream)" = "https://openrouter.ai/api/v1" ]
|
||||
[ "$(lib manifest_get backend openrouter proxied_domain)" = "openrouter.ai" ]
|
||||
[ "$(lib manifest_get backend openrouter api_style)" = "openai" ]
|
||||
[ "$(lib manifest_get backend openrouter credential)" = "static" ]
|
||||
# Both spellings an OpenAI-compatible client might use for a key, so a
|
||||
# placeholder cannot travel beside the injected Authorization.
|
||||
[ "$(lib manifest_get backend openrouter strip_headers)" = "x-api-key api-key" ]
|
||||
# No default for the key's location and no assumed value: an unset one is
|
||||
# a refusal at launch, not a guess at a path.
|
||||
[ "$(lib manifest_get backend openrouter requires)" = "credential_file" ]
|
||||
[ -z "$(lib config_default backend.openrouter.credential_file)" ]
|
||||
# proxied_domain is the HOST alone. upstream carries the path; a
|
||||
# certificate and a DNS pin are about a name.
|
||||
[[ "$(lib manifest_get backend openrouter proxied_domain)" != */* ]]
|
||||
}
|
||||
|
||||
@test "a self-served endpoint is a backend like any other" {
|
||||
# Where the model is belongs to the provider, including when the operator
|
||||
# serves it. That is what lets a local endpoint stay configured while a
|
||||
# hosted API is selected: it is [backend.self-hosted] base_url, not a
|
||||
# setting on the harness that has to be unset to get out of the way.
|
||||
lib manifest_validate backend self-hosted
|
||||
[ "$(lib manifest_get backend self-hosted credential)" = "none" ]
|
||||
[ "$(lib manifest_get backend self-hosted api_style)" = "openai" ]
|
||||
[ "$(lib manifest_get backend self-hosted requires)" = "base_url" ]
|
||||
[ "$(lib config_type backend.self-hosted.base_url)" = "?url" ]
|
||||
[ -z "$(lib config_default backend.self-hosted.base_url)" ] \
|
||||
|| { echo "self-hosted ships a default endpoint"; return 1; }
|
||||
# upstream and proxied_domain describe an endpoint dejima stands in front
|
||||
# of, and it stands in front of nothing here.
|
||||
[ -z "$(lib manifest_get backend self-hosted upstream)" ]
|
||||
[ -z "$(lib manifest_get backend self-hosted proxied_domain)" ]
|
||||
# And the harness no longer carries one of its own to disagree with it.
|
||||
run lib config_type harness.cline.base_url
|
||||
[ "$status" -ne 0 ] || { echo "cline still declares base_url"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness and a backend have to speak the same API" {
|
||||
# Anthropic's Messages API is not OpenAI-compatible. Pairing a harness
|
||||
# with a provider it has no client for is knowable at launch and produces
|
||||
# nothing but confusion later, so both sides declare what they speak.
|
||||
[ "$(lib manifest_get backend anthropic api_style)" = "anthropic" ]
|
||||
[ "$(lib manifest_get harness claude-code api_styles)" = "anthropic" ]
|
||||
# cline is the provider-agnostic one and says so.
|
||||
[ "$(lib manifest_get harness cline api_styles)" = "anthropic openai" ]
|
||||
|
||||
# A dialect outside the two is refused by the schema. An ABSENT one is not
|
||||
# - manifest_validate only type-checks the keys a file actually has, which
|
||||
# is true of every axis and not something this one changed - so bin/dejima
|
||||
# names the four a provider cannot work without where it reads them. That
|
||||
# refusal is covered in config.bats, at the launch.
|
||||
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/style.XXXXXX")"
|
||||
mkdir -p "$d/backends/probe"
|
||||
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\napi_style = sideways\n' \
|
||||
> "$d/backends/probe/backend.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
|
||||
[ "$status" -ne 0 ] || { echo "an unknown api_style validated"; return 1; }
|
||||
}
|
||||
|
||||
@test "the backend/harness matrix in the docs agrees with the manifests" {
|
||||
# A compatibility table is derived data: every cell is the provider's
|
||||
# api_style against the harness's api_styles. Written by hand it goes stale
|
||||
# the first time a provider is added, and a stale one is worse than none -
|
||||
# a reader trusts it and dejima refuses the launch.
|
||||
#
|
||||
# So it is recomputed here and compared cell by cell, rather than spot
|
||||
# -checked. A new backend or harness that nobody adds a row for fails
|
||||
# this, which is the point.
|
||||
# Both copies, because the README carries a shorter one and two copies of
|
||||
# derived data is two things to drift. Checked against the manifests
|
||||
# rather than against each other, so neither can be right by agreeing with
|
||||
# a wrong one.
|
||||
local doc rows
|
||||
for doc in "$REPO_ROOT/docs/providers.md" "$REPO_ROOT/README.md"; do
|
||||
rows="$(sed -n '/^| Backend | `claude-code` | `cline` |/,/^$/p' "$doc")"
|
||||
[ -n "$rows" ] || { echo "the matrix is gone from $doc, or its header changed"; return 1; }
|
||||
|
||||
local p h styles want got row
|
||||
for p in $(lib provider_list backend); do
|
||||
row="$(grep -E "^\| \`$p\` \|" <<< "$rows")" \
|
||||
|| { echo "no row for backend '$p'"; return 1; }
|
||||
# Column count first. A row with a doubled separator still answers
|
||||
# the cells this asks for by index, so without this a malformed row
|
||||
# passes - which is how one got in.
|
||||
local want_cols cols
|
||||
want_cols="$(awk -F'|' 'NR==1 {print NF}' <<< "$rows")"
|
||||
cols="$(awk -F'|' '{print NF}' <<< "$row")"
|
||||
[ "$cols" = "$want_cols" ] \
|
||||
|| { echo "$p's row has $cols columns, the header has $want_cols"; return 1; }
|
||||
local i=0
|
||||
for h in claude-code cline; do
|
||||
i=$((i + 1))
|
||||
styles="$(lib manifest_get harness "$h" api_styles)"
|
||||
if [[ " $styles " == *" $(lib manifest_get backend "$p" api_style) "* ]]; then
|
||||
want=yes
|
||||
else
|
||||
want=no
|
||||
fi
|
||||
got="$(awk -F'|' -v n=$((i + 2)) '{gsub(/^ +| +$/,"",$n); print $n}' <<< "$row")"
|
||||
[ "$got" = "$want" ] \
|
||||
|| { echo "$p x $h: the table says '$got', the manifests say '$want'"; return 1; }
|
||||
done
|
||||
done
|
||||
|
||||
# And every shipped harness has a column, or the table is answering for
|
||||
# fewer pairs than exist.
|
||||
for h in $(lib provider_list harness); do
|
||||
grep -q "\`$h\`" <<< "$rows" || { echo "no column for harness '$h' in $doc"; return 1; }
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
@test "an Anthropic API key is a backend, so it stays on the host too" {
|
||||
# The setup most organisations use: a console key on API billing, rather
|
||||
# than a subscription login. Without a backend to declare it, dejima has
|
||||
# no claudeAiOauth block to protect, skips the proxy, and mounts the
|
||||
# operator's real key into the container - so the common arrangement
|
||||
# would be the one where a long-lived, account-wide credential sits where
|
||||
# a compromised session can read it.
|
||||
lib manifest_validate backend anthropic-api-key
|
||||
[ "$(lib manifest_get backend anthropic-api-key credential)" = "static" ]
|
||||
[ "$(lib manifest_get backend anthropic-api-key upstream)" = "https://api.anthropic.com" ]
|
||||
[ "$(lib manifest_get backend anthropic-api-key api_style)" = "anthropic" ]
|
||||
# Anthropic's own header, bare - no scheme prefix. That pair is the whole
|
||||
# difference from the subscription backend: a token is a Bearer, a key is
|
||||
# x-api-key.
|
||||
[ "$(lib manifest_get backend anthropic-api-key auth_header)" = "x-api-key" ]
|
||||
[ "$(lib manifest_get backend anthropic-api-key auth_format)" = "{token}" ]
|
||||
[ "$(lib manifest_get backend anthropic auth_header)" = "Authorization" ]
|
||||
# And the other slot stripped, so a placeholder cannot travel beside the
|
||||
# injected key and be the one the API reads.
|
||||
[ "$(lib manifest_get backend anthropic-api-key strip_headers)" = "authorization" ]
|
||||
[ "$(lib manifest_get backend anthropic-api-key requires)" = "credential_file" ]
|
||||
}
|
||||
|
||||
@test "one API-billing backend serves both places the key can live" {
|
||||
# Claude Code's console login keeps its key as primaryApiKey in
|
||||
# ~/.claude.json; a key you hold yourself is a file that is nothing but
|
||||
# the key. Same endpoint, same header, same static credential - one
|
||||
# backend, with WHERE the key sits left to the operator.
|
||||
lib manifest_validate backend anthropic-api-key
|
||||
[ "$(lib manifest_get backend anthropic-api-key credential)" = "static" ]
|
||||
[ "$(lib manifest_get backend anthropic-api-key auth_header)" = "x-api-key" ]
|
||||
[ "$(lib manifest_get backend anthropic-api-key auth_format)" = "{token}" ]
|
||||
[ "$(lib manifest_get backend anthropic-api-key strip_headers)" = "authorization" ]
|
||||
# Defaulted to the console login, so the common case needs no config.
|
||||
[ "$(lib config_default backend.anthropic-api-key.credential_file)" = "~/.claude.json" ]
|
||||
[ "$(lib config_default backend.anthropic-api-key.credential_field)" = "primaryApiKey" ]
|
||||
# And clearable, which is what serves a bare key file.
|
||||
[ "$(lib config_type backend.anthropic-api-key.credential_field)" = "?jsonfield" ]
|
||||
# Two backends for Anthropic, not three: the split is the wire
|
||||
# arrangement, not where a file happens to be.
|
||||
run lib provider_dir backend anthropic-console
|
||||
[ "$status" -ne 0 ] || { echo "anthropic-console is back"; return 1; }
|
||||
[ "$(lib manifest_get backend anthropic credential)" = "anthropic-oauth" ]
|
||||
}
|
||||
|
||||
@test "the endpoint is identical across the anthropic backends" {
|
||||
# Three values describe Anthropic's API rather than any credential, so
|
||||
# they must not drift between the two backends that reach it. This is what
|
||||
# the duplication costs, and it is cheaper to test than to deduplicate.
|
||||
local field a b
|
||||
for field in upstream proxied_domain api_style; do
|
||||
a="$(lib manifest_get backend anthropic "$field")"
|
||||
b="$(lib manifest_get backend anthropic-api-key "$field")"
|
||||
[ -n "$a" ] || { echo "anthropic declares no $field"; return 1; }
|
||||
[ "$a" = "$b" ] \
|
||||
|| { echo "$field differs: anthropic=$a anthropic-api-key=$b"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "a credential_field is dot-separated names, not an expression" {
|
||||
# The proxy walks these segments itself, so a value that could name an
|
||||
# expression would be one that could name a computation. It is a config
|
||||
# type now, because where a key sits in a file is the operator's.
|
||||
local good bad
|
||||
for good in primaryApiKey a.b.c _x1; do
|
||||
lib _config_valid_value jsonfield "$good" \
|
||||
|| { echo "refused '$good'"; return 1; }
|
||||
done
|
||||
for bad in '.leading' 'trailing.' 'a..b' 'a[0]' 'a|keys' '$env' 'a b' '1abc'; do
|
||||
run lib _config_valid_value jsonfield "$bad"
|
||||
[ "$status" -ne 0 ] || { echo "accepted '$bad'"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "every backend section takes a proxy key, whatever its manifest says" {
|
||||
# Whether to withhold a credential is dejima's question, not the backend
|
||||
# author's, so core appends it rather than each manifest declaring it - a
|
||||
# backend that forgot would silently lose the setting.
|
||||
local b
|
||||
for b in $(lib provider_list backend); do
|
||||
[ "$(lib config_type "backend.$b.proxy")" = "?bool" ] \
|
||||
|| { echo "backend '$b' has no proxy key"; return 1; }
|
||||
done
|
||||
# No default, because unset is a third state: inherit [credentials] proxy.
|
||||
# A default of either value would decide for every backend at once.
|
||||
[ -z "$(lib config_default backend.anthropic.proxy)" ] \
|
||||
|| { echo "the per-backend proxy key ships a default"; return 1; }
|
||||
}
|
||||
|
||||
+11
-11
@@ -481,12 +481,12 @@ make_cert() {
|
||||
[ "$output" = "200" ] || { echo "a 2MiB body was refused with $output"; return 1; }
|
||||
}
|
||||
|
||||
# --- provider-generic behaviour (#171) ---------------------------------------
|
||||
# --- backend-generic behaviour (#171) ----------------------------------------
|
||||
#
|
||||
# The proxy no longer knows a provider's name: upstream, credential kind, auth
|
||||
# The proxy knows no backend's name: upstream, credential kind, auth
|
||||
# header and shape, what to strip and what to ensure are all arguments. These
|
||||
# exercise the second kind - a static key, which is what an OpenAI-compatible
|
||||
# hosted provider has - and the parts of the shape a provider can move.
|
||||
# hosted backend has - and the parts of the shape a backend can move.
|
||||
|
||||
# A static-key proxy against the same fake upstream. $1.. are extra flags.
|
||||
# Startup refusals run the proxy in the FOREGROUND and expect it to exit, so
|
||||
@@ -539,14 +539,14 @@ start_static() {
|
||||
[ "$(curl -s "http://127.0.0.1:$PORT/oauth-hits")" = "0" ]
|
||||
}
|
||||
|
||||
@test "the auth header and its format are the provider's, not Bearer by fiat" {
|
||||
# A provider that wants its key in a header of its own, unprefixed, needs
|
||||
@test "the auth header and its format are the backend's, not Bearer by fiat" {
|
||||
# A backend that wants its key in a header of its own, unprefixed, needs
|
||||
# no code here. Asserted together because the pair is the shape.
|
||||
start_static --auth-header x-goog-api-key --auth-format '{token}'
|
||||
run pcurl -d '{}' http://proxy/v1/chat/completions
|
||||
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
|
||||
# And the client's own Authorization placeholder is left alone rather than
|
||||
# silently rewritten: it is not this provider's auth header. Stripping it
|
||||
# silently rewritten: it is not this backend's auth header. Stripping it
|
||||
# is a separate declaration, which the next test covers.
|
||||
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer $PLACEHOLDER" ]
|
||||
}
|
||||
@@ -560,9 +560,9 @@ start_static() {
|
||||
}
|
||||
|
||||
@test "the auth header cannot be stripped out from under itself" {
|
||||
# Naming it in both places is a provider's mistake, and the safe reading
|
||||
# Naming it in both places is a backend's mistake, and the safe reading
|
||||
# is that the credential still goes: dropping it would send an
|
||||
# unauthenticated request and report the provider's 401 as the fault.
|
||||
# unauthenticated request and report the backend's 401 as the fault.
|
||||
#
|
||||
# Position as well as presence, because presence alone does not
|
||||
# distinguish the two paths. A stripped-then-re-added header arrives at
|
||||
@@ -582,8 +582,8 @@ start_static() {
|
||||
}
|
||||
|
||||
@test "no ensure-header means none is invented" {
|
||||
# anthropic-beta is one provider's requirement. A proxy that added it
|
||||
# anyway would put an Anthropic tell on every other provider's wire.
|
||||
# anthropic-beta is one backend's requirement. A proxy that added it
|
||||
# anyway would put an Anthropic tell on every other backend's wire.
|
||||
start_static
|
||||
run pcurl -d '{}' http://proxy/v1/chat/completions
|
||||
[ "$(jq -r '.headers["anthropic-beta"] // "absent"' <<<"$output")" = "absent" ]
|
||||
@@ -600,7 +600,7 @@ start_static() {
|
||||
|
||||
@test "a key carrying a control character is refused rather than sanitised" {
|
||||
# A key that needs sanitising was pasted wrong. Trimming it would send
|
||||
# mangled bytes and report the provider's rejection as the fault.
|
||||
# mangled bytes and report the backend's rejection as the fault.
|
||||
printf 'sk-good\x1bbad\n' > "$TD/key"
|
||||
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
|
||||
--upstream "http://127.0.0.1:$PORT" --credential-kind static
|
||||
|
||||
+31
-2
@@ -132,8 +132,11 @@ added="$(comm -13 \
|
||||
# itself the thing that makes the rollback refuse.
|
||||
# The singular is spelled out rather than derived: "harnesses" does not lose
|
||||
# an s to become "harness", and a manifest is named for the singular.
|
||||
#
|
||||
# Every axis, including backends: the newest one is exactly the one a stale
|
||||
# list would miss, because a list is only ever updated by whoever remembers.
|
||||
new_providers=""
|
||||
for pair in harnesses:harness forges:forge workflows:workflow; do
|
||||
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
@@ -144,9 +147,35 @@ for pair in harnesses:harness forges:forge workflows:workflow; do
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done
|
||||
|
||||
if [ -n "$added" ] || [ -n "$new_providers" ]; then
|
||||
# And the keys a provider DECLARES, which are config keys an operator writes
|
||||
# and an older dejima refuses - but which live in a manifest rather than in
|
||||
# core's schema, so the diff above cannot see them. `[harness.claude-code]
|
||||
# backend` is one: adopting it is what makes the rollback refuse, and nothing
|
||||
# said so.
|
||||
new_keys=""
|
||||
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
|
||||
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
|
||||
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
new_keys="$new_keys [$one.$name]|$k"
|
||||
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done
|
||||
|
||||
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
|
||||
[ -z "$added" ] || printf ' %s\n' $added
|
||||
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
|
||||
for entry in $new_keys; do
|
||||
printf ' %s %s\n' "${entry%%|*}" "${entry#*|}"
|
||||
done
|
||||
else
|
||||
echo " (none - a rollback accepts any config this release accepts)"
|
||||
fi
|
||||
|
||||
+33
-13
@@ -120,19 +120,13 @@ done
|
||||
|
||||
# Within-file parallelism is bats' own, and it needs flock. Without it bats
|
||||
# exits before running anything, so the fallback is stated rather than left to
|
||||
# look like a broken suite.
|
||||
if [ -z "$jobs" ]; then
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
jobs="$(nproc 2>/dev/null || echo 4)"
|
||||
else
|
||||
jobs=1
|
||||
fi
|
||||
# look like a broken suite. The size of it is decided after the selection,
|
||||
# below, because it depends on how many suites there are.
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
can_parallelize=1
|
||||
else
|
||||
can_parallelize=0
|
||||
fi
|
||||
bats_flags=()
|
||||
# Deliberately more concurrent tests than cores when the whole set runs: these
|
||||
# tests spend their time forking rather than computing, so oversubscribing
|
||||
# measured faster and levelled off rather than degrading.
|
||||
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
|
||||
|
||||
selected=()
|
||||
changed_paths=()
|
||||
@@ -189,6 +183,31 @@ done
|
||||
# Which lane ran, before anything else, because the whole point of having two
|
||||
# is that a run can be read back later without guessing which one it was. To
|
||||
# stderr, so --list's stdout is the suite names and nothing else.
|
||||
# How much concurrency to actually ask for.
|
||||
#
|
||||
# Every selected suite runs at once, so what lands on the machine is
|
||||
# suites x jobs. Asking each suite for `nproc` made that ten times the cores
|
||||
# on a ten-core box, and #295 is what that cost: the tests that wait on a
|
||||
# bounded deadline - a pty reaching raw mode, a shipper polling, an audit
|
||||
# finishing - blow through it on a machine that oversubscribed, and fail for
|
||||
# the load rather than for the thing they check.
|
||||
#
|
||||
# Oversubscription still helps, because these tests fork more than they
|
||||
# compute. It is a property of the whole run rather than of each suite, so it
|
||||
# is budgeted once and divided: roughly twice the cores, in total.
|
||||
if [ -z "$jobs" ]; then
|
||||
if [ "$can_parallelize" = 1 ] && [ "${#selected[@]}" -gt 0 ]; then
|
||||
cores="$(nproc 2>/dev/null || echo 4)"
|
||||
budget=$(( cores * 2 ))
|
||||
jobs=$(( (budget + ${#selected[@]} - 1) / ${#selected[@]} ))
|
||||
[ "$jobs" -ge 1 ] || jobs=1
|
||||
else
|
||||
jobs=1
|
||||
fi
|
||||
fi
|
||||
bats_flags=()
|
||||
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
|
||||
|
||||
{
|
||||
if [ "$lane" = changed ]; then
|
||||
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
|
||||
@@ -200,7 +219,8 @@ done
|
||||
printf 'lane: ALL - every confined suite (the pull-request gate)\n'
|
||||
printf ' not run: runtime, ci-runner - they need Docker and a real runner.\n'
|
||||
fi
|
||||
[ "$list_only" = 1 ] || printf ' %d test(s) at a time within each suite\n' "$jobs"
|
||||
[ "$list_only" = 1 ] || printf ' %d test(s) at a time within each suite, %d suites at once\n' \
|
||||
"$jobs" "${#selected[@]}"
|
||||
printf '\n'
|
||||
} >&2
|
||||
|
||||
|
||||
+13
-5
@@ -82,8 +82,8 @@ test_container_name() { echo "dejima-test-$1-$(basename "$BATS_RUN_TMPDIR")"; }
|
||||
#
|
||||
# dejima-credential-proxy defaults none of this any more (#171): which upstream,
|
||||
# which credential kind, which header and what shape are all the caller's to
|
||||
# state, because a default is one provider's arrangement wearing every
|
||||
# provider's name. Three copies of the same eight flags in this file would be
|
||||
# state, because a default is one backend's arrangement wearing every
|
||||
# backend's name. Three copies of the same eight flags in this file would be
|
||||
# three places for them to drift from what bin/dejima actually launches, so
|
||||
# there is one - and static.bats pins that bin/dejima still passes the same set.
|
||||
#
|
||||
@@ -191,6 +191,14 @@ harness_manifest_value() {
|
||||
sed -n "s/^$1 *= *//p" "$BATS_TEST_DIRNAME/../harnesses/claude-code/harness.conf"
|
||||
}
|
||||
|
||||
# The same, for the selected model backend. proxied_domain lives off the
|
||||
# harness manifest onto this one (#171): a harness reaches whichever endpoint
|
||||
# it is configured for, so the name the terminator answers for belongs to the
|
||||
# endpoint rather than to the program talking to it.
|
||||
backend_manifest_value() {
|
||||
sed -n "s/^$1 *= *//p" "$BATS_TEST_DIRNAME/../backends/anthropic/backend.conf"
|
||||
}
|
||||
|
||||
# The selected harness's image layer, on top of $1, tagged for this run so
|
||||
# teardown_file's sweep finds it. Prints the new tag.
|
||||
build_harness_layer() {
|
||||
@@ -417,7 +425,7 @@ setup_file() {
|
||||
-e ANTHROPIC_BASE_URL= \
|
||||
-e ANTHROPIC_AUTH_TOKEN= \
|
||||
-e SANDBOX_HARNESS_DOMAINS="$(harness_manifest_value api_domains)" \
|
||||
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
|
||||
-e SANDBOX_BACKEND_PROXIED_DOMAIN="$(backend_manifest_value proxied_domain)" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
@@ -1334,7 +1342,7 @@ EOF
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
|
||||
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
|
||||
-e SANDBOX_BACKEND_PROXIED_DOMAIN="$(backend_manifest_value proxied_domain)" \
|
||||
-e SANDBOX_ANTHROPIC_TRANSPORT=loopback \
|
||||
-e ANTHROPIC_AUTH_TOKEN=sandbox-credential-proxy-placeholder \
|
||||
-v "$TD:$TD" \
|
||||
@@ -1701,7 +1709,7 @@ EOF
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
|
||||
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
|
||||
-e SANDBOX_BACKEND_PROXIED_DOMAIN="$(backend_manifest_value proxied_domain)" \
|
||||
-v "$TD:$TD" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
+481
-59
@@ -538,6 +538,38 @@ _plugin_fixture() {
|
||||
printf '{}\n' > "$p/plugin-catalog-cache.json"
|
||||
}
|
||||
|
||||
# The same three declarations, for whichever harness is asked about. cline's
|
||||
# are new and claude-code's are long-standing, and a helper per harness would
|
||||
# drift. A nameref rather than eval: the array name is the only thing that
|
||||
# varies, and quoting it through two shells is how the first version of this
|
||||
# printed a literal "n" for every newline.
|
||||
_declared_for() { # <harness> <loader> <array-name>
|
||||
bash -c '
|
||||
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
|
||||
HOME=/h
|
||||
harness_plugin_load "$1"
|
||||
"$2"
|
||||
declare -n _arr="$3"
|
||||
printf "%s\n" ${_arr[@]+"${_arr[@]}"}
|
||||
' _ "$1" "$2" "$3"
|
||||
}
|
||||
|
||||
# The same, against a real home. emit_seed_from skips a source that does not
|
||||
# exist - a fresh install has none of this state - so a seed assertion needs
|
||||
# files on disk rather than the /h placeholder above.
|
||||
_declared_for_home() { # <home> <harness> <loader> <array-name>
|
||||
bash -c '
|
||||
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
|
||||
HOME="$1"
|
||||
harness_plugin_load "$2"
|
||||
"$3"
|
||||
declare -n _arr="$4"
|
||||
printf "%s\n" ${_arr[@]+"${_arr[@]}"}
|
||||
' _ "$1" "$2" "$3" "$4"
|
||||
}
|
||||
|
||||
_declared_config_dirs() {
|
||||
bash -c '
|
||||
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
@@ -955,10 +987,10 @@ _cline_prepare() {
|
||||
# there is no proxy that could hold a key back, so the key would have to
|
||||
# go in. That is the one thing this harness may not do quietly.
|
||||
local cfg
|
||||
cfg="$(_cline_cfg 'model = m' 'base_url = https://api.openai.com/v1')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = https://api.openai.com/v1")"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -ne 0 ] || { echo "accepted a hosted endpoint"; return 1; }
|
||||
[[ "$output" == *"refusing base_url"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"refusing [backend.self-hosted] base_url"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "cline refuses the credential modes it has no path through" {
|
||||
@@ -970,9 +1002,9 @@ _cline_prepare() {
|
||||
# URL through the environment that cline does not read.
|
||||
local cfg
|
||||
for cfg in 'proxy = off' 'transport = loopback'; do
|
||||
run _cline_prepare "$(_cline_cfg 'model = m' '[credentials]' "$cfg")" "$BATS_TEST_TMPDIR/ws"
|
||||
run _cline_prepare "$(_cline_cfg 'model = m' 'backend = anthropic' '[credentials]' "$cfg")" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -ne 0 ] || { echo "accepted $cfg"; return 1; }
|
||||
[[ "$output" == *"no way to reach Anthropic"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"no way to reach a proxied"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@@ -982,7 +1014,7 @@ _cline_prepare() {
|
||||
# and refusing there would be refusing the configuration that made this
|
||||
# harness worth adding.
|
||||
local cfg
|
||||
cfg="$(_cline_cfg 'model = m' 'base_url = http://127.0.0.1:11434/v1' \
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = http://127.0.0.1:11434/v1" \
|
||||
'[credentials]' 'proxy = off')"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
@@ -994,7 +1026,7 @@ _cline_prepare() {
|
||||
# R5 to protect and the endpoint is supported today.
|
||||
local addr cfg
|
||||
for addr in 127.0.0.1 10.1.2.3 192.168.1.5 172.17.0.1 localhost; do
|
||||
cfg="$(_cline_cfg 'model = m' "base_url = http://$addr:11434/v1")"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = http://$addr:11434/v1")"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -eq 0 ] || { echo "refused $addr: $output"; return 1; }
|
||||
done
|
||||
@@ -1010,7 +1042,7 @@ _cline_prepare() {
|
||||
# same store that decides what the container can reach: an endpoint that
|
||||
# fails this is one the session could not have connected to anyway.
|
||||
local cfg
|
||||
cfg="$(_cline_cfg 'model = m' 'base_url = https://llm.example.test/v1')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = https://llm.example.test/v1")"
|
||||
printf 'llm.example.test\n' > "$cfg/allowed-domains"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -eq 0 ] || { echo "refused an allowlisted endpoint: $output"; return 1; }
|
||||
@@ -1020,10 +1052,10 @@ _cline_prepare() {
|
||||
# The same endpoint without the grant. Nothing about the URL changed;
|
||||
# what changed is whether the operator asked for it.
|
||||
local cfg
|
||||
cfg="$(_cline_cfg 'model = m' 'base_url = https://llm.example.test/v1')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = https://llm.example.test/v1")"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -ne 0 ] || { echo "accepted an endpoint nothing granted"; return 1; }
|
||||
[[ "$output" == *"refusing base_url"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"refusing [backend.self-hosted] base_url"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "cline does not mistake a hostname for the private range it starts with" {
|
||||
@@ -1032,7 +1064,7 @@ _cline_prepare() {
|
||||
# as numbers for exactly this.
|
||||
local host cfg
|
||||
for host in 10.example.com 192.168.example.com 172.16.example.com; do
|
||||
cfg="$(_cline_cfg 'model = m' "base_url = http://$host/v1")"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = http://$host/v1")"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -ne 0 ] || { echo "accepted $host as a private address"; return 1; }
|
||||
done
|
||||
@@ -1043,7 +1075,7 @@ _cline_prepare() {
|
||||
# container holds is a placeholder, and the host-side proxy substitutes the
|
||||
# real credential on the way out. Confirmed end to end against the API.
|
||||
local cfg ws
|
||||
cfg="$(_cline_cfg 'model = claude-haiku-4-5-20251001')"
|
||||
cfg="$(_cline_cfg 'model = claude-haiku-4-5-20251001' 'backend = anthropic')"
|
||||
ws="$BATS_TEST_TMPDIR/ws"
|
||||
run _cline_prepare "$cfg" "$ws"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
@@ -1068,7 +1100,7 @@ _cline_prepare() {
|
||||
# survived every test that wrote providers.json by hand: those included the
|
||||
# field, and the plugin did not. It took a run against a real endpoint.
|
||||
local cfg ws f
|
||||
cfg="$(_cline_cfg 'model = m')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = anthropic')"
|
||||
ws="$BATS_TEST_TMPDIR/ws-ts"
|
||||
run _cline_prepare "$cfg" "$ws"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
@@ -1084,7 +1116,7 @@ _cline_prepare() {
|
||||
@test "cline's local-endpoint provider entry carries it too" {
|
||||
# Same field, the other branch. The bug was found on this one.
|
||||
local cfg ws f
|
||||
cfg="$(_cline_cfg 'model = m' 'base_url = http://127.0.0.1:8080/v1')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = http://127.0.0.1:8080/v1")"
|
||||
ws="$BATS_TEST_TMPDIR/ws-ts2"
|
||||
run _cline_prepare "$cfg" "$ws"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
@@ -1103,14 +1135,14 @@ _cline_prepare() {
|
||||
# being served, and dejima guessing one is worse than leaving Cline to its
|
||||
# catalogue for a model it does know.
|
||||
local cfg f
|
||||
cfg="$(_cline_cfg 'model = m' 'context_window = 15000')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = anthropic' 'context_window = 15000')"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
f="$(find "$cfg/state" -name providers.json | head -1)"
|
||||
run jq -r '.providers.anthropic.settings.contextWindow' "$f"
|
||||
[ "$output" = "15000" ] || { echo "not written as a number: $output"; return 1; }
|
||||
|
||||
cfg="$(_cline_cfg 'model = m')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = anthropic')"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw2"
|
||||
f="$(find "$cfg/state" -name providers.json | head -1)"
|
||||
run jq -r '.providers.anthropic.settings.contextWindow // "absent"' "$f"
|
||||
@@ -1124,7 +1156,7 @@ _cline_prepare() {
|
||||
# and the only thing that keeps one project's transcripts out of another's
|
||||
# session - #104 - is dejima handing each workspace its own data directory.
|
||||
local cfg a b
|
||||
cfg="$(_cline_cfg 'model = m')"
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = anthropic')"
|
||||
a="$(DEJIMA_CONFIG_DIR="$cfg" bash -c '
|
||||
source "$2/bin/dejima-provider-lib.sh"
|
||||
source "$2/bin/dejima-harness-lib.sh"
|
||||
@@ -3650,9 +3682,24 @@ registered_commands() {
|
||||
grep -q 'return 0' <<< "$output"
|
||||
}
|
||||
|
||||
@test "credential proxy is on by default, gated only by credentials.proxy" {
|
||||
grep -q 'config_get_bool credentials proxy' "$REPO_ROOT/bin/dejima"
|
||||
@test "credential proxy is on by default, and unset per backend means inherit" {
|
||||
grep -Pq 'credentials\.proxy\tbool\ton' "$REPO_ROOT/bin/dejima-config-lib.sh"
|
||||
# Every gate goes through the helper. A raw config_get_bool read here
|
||||
# would ignore [backend.<name>] proxy and re-widen the switch to every
|
||||
# backend at once.
|
||||
grep -q 'backend_proxy_enabled' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'backend_proxy_enabled' "$REPO_ROOT/bin/harness/cline.sh"
|
||||
run grep -rn 'config_get_bool credentials proxy' "$REPO_ROOT/bin" "$REPO_ROOT/docker"
|
||||
[ -z "$output" ] || { echo "an ungated read of the global switch: $output"; return 1; }
|
||||
# And the helper must not be config_get_bool underneath, which reports an
|
||||
# unset optional key as off - that would turn the proxy off for every
|
||||
# backend the moment the per-backend key existed.
|
||||
run sed -n '/^backend_proxy_enabled() {/,/^}/p' "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
[ -n "$output" ] || { echo "backend_proxy_enabled is not defined"; return 1; }
|
||||
grep -q 'config_get_bool' <<< "$output" \
|
||||
&& { echo "the helper reads an unset key as off"; return 1; }
|
||||
grep -q 'config_get credentials proxy' <<< "$output" \
|
||||
|| { echo "the helper does not fall back to the global switch"; return 1; }
|
||||
}
|
||||
|
||||
@test "bin/dejima mounts the proxy socket dir and masks nothing" {
|
||||
@@ -4959,6 +5006,38 @@ EOF
|
||||
|| { echo "the not-sandboxed list does not point at the explanation"; return 1; }
|
||||
}
|
||||
|
||||
@test "the microVM answer concedes the field's ordering rather than disputing it" {
|
||||
# The most frequently asked question about this project, and the one whose
|
||||
# answer is easiest to rewrite into something weaker than it should be. A
|
||||
# revision that starts claiming a container is as strong a primitive as a
|
||||
# per-workload VM kernel is the failure mode, because it turns a statement
|
||||
# about where effort went into a statement about isolation that is not
|
||||
# true. Pin the concession, not the prose around it.
|
||||
local tm="$REPO_ROOT/docs/threat-model.md" flat
|
||||
grep -q '^## Why a container, and not a microVM' "$tm" \
|
||||
|| { echo "the microVM question is unanswered again"; return 1; }
|
||||
flat="$(flat_prose "$tm")"
|
||||
grep -q "field's ordering is not in dispute" <<< "$flat" \
|
||||
|| { echo "the section no longer concedes that a VM is the stronger boundary"; return 1; }
|
||||
# Named, because "why not a VM" is three different questions and answering
|
||||
# one of them reads as answering all three.
|
||||
local name
|
||||
for name in gVisor Firecracker Kata; do
|
||||
grep -q "$name" <<< "$flat" || { echo "the answer does not mention $name"; return 1; }
|
||||
done
|
||||
# The falsifiable half. Without it this is a position rather than a claim.
|
||||
grep -q 'What would change the answer' "$tm" \
|
||||
|| { echo "nothing says what would overturn this"; return 1; }
|
||||
# Reached from the research agenda, which is what re-asks it.
|
||||
grep -q 'why-a-container-and-not-a-microvm' "$REPO_ROOT/docs/research-review.md" \
|
||||
|| { echo "the standing question does not point at the written answer"; return 1; }
|
||||
# And from the stack section of the README, which is where someone holding
|
||||
# the question is standing when it occurs to them. An answer nobody finds
|
||||
# gets asked as a question instead.
|
||||
grep -q 'why-a-container-and-not-a-microvm' "$REPO_ROOT/README.md" \
|
||||
|| { echo "the README does not surface the answer where the question arises"; return 1; }
|
||||
}
|
||||
|
||||
@test "the README lists the whole stack, so it is not a threat-model discovery" {
|
||||
# Someone deciding what to stand up reads the README and may never open
|
||||
# docs/threat-model.md at all. The detail belongs there and the list
|
||||
@@ -5285,7 +5364,7 @@ EOF
|
||||
|| { echo "the opening pitch is only $words words"; return 1; }
|
||||
}
|
||||
|
||||
@test "the README's provider lists match what is shipped" {
|
||||
@test "the README's axis lists match what is shipped" {
|
||||
# It listed claude-code as the only harness for as long as cline had been
|
||||
# shipping: a hand-maintained list of directories goes stale in the
|
||||
# direction of telling a reader the thing they came for does not exist.
|
||||
@@ -5488,34 +5567,67 @@ EOF
|
||||
# docs/*.md reference actually present in these scripts and checks the target
|
||||
# exists, rather than hardcoding the two names here too and letting a rename
|
||||
# desync the test from the code.
|
||||
@test "bin/dejima states the whole Anthropic arrangement to the credential proxy" {
|
||||
# The proxy defaults nothing about a provider any more (#171). That is the
|
||||
# point - a default is one provider's arrangement wearing every provider's
|
||||
# name - but it moves the burden onto the caller, and a caller that forgets
|
||||
# a flag does not fail here. It fails at launch, or worse, sends a request
|
||||
# without the header the provider needs and reports the API's rejection.
|
||||
@test "the Anthropic arrangement is declared in its manifest, not written in core" {
|
||||
# #171's whole point. Before the axis these values were flags on
|
||||
# bin/dejima's launch line; now they are a file, and adding a provider is
|
||||
# adding a directory rather than editing core.
|
||||
#
|
||||
# Every flag the Anthropic path needs, asserted on the launch itself rather
|
||||
# than anywhere in the file: the arguments have to be on this invocation.
|
||||
local launch
|
||||
launch="$(sed -n '/dejima-credential-proxy" \\/,/^ SANDBOX_ANTHROPIC_PROXY_PID=/p' "$REPO_ROOT/bin/dejima")"
|
||||
[ -n "$launch" ] || { echo "bin/dejima no longer launches the credential proxy"; return 1; }
|
||||
local flag
|
||||
for flag in --socket --credentials --upstream --credential-kind \
|
||||
--oauth-endpoint --oauth-client-id --auth-header \
|
||||
--auth-format --strip-header --ensure-header; do
|
||||
grep -q -- "$flag" <<< "$launch" \
|
||||
|| { echo "the launch passes no $flag"; return 1; }
|
||||
# The three that carry the security property rather than the shape:
|
||||
# x-api-key must not travel beside the injected Authorization, the OAuth
|
||||
# beta must be present or the API rejects the token, and the kind has to
|
||||
# be the refreshing one or a rotated token is never written back.
|
||||
local m="$REPO_ROOT/backends/anthropic/backend.conf"
|
||||
[ -f "$m" ] || { echo "the anthropic provider manifest is gone"; return 1; }
|
||||
local want
|
||||
for want in 'upstream *= *https://api\.anthropic\.com' \
|
||||
'proxied_domain *= *api\.anthropic\.com' \
|
||||
'credential *= *anthropic-oauth' \
|
||||
'oauth_endpoint *= *https://platform\.claude\.com' \
|
||||
'auth_header *= *Authorization' \
|
||||
'auth_format *= *Bearer \{token\}' \
|
||||
'strip_headers *=.*x-api-key' \
|
||||
'ensure_headers *=.*anthropic-beta=oauth-2025-04-20'; do
|
||||
grep -qE "^$want" "$m" \
|
||||
|| { echo "the manifest no longer declares: $want"; return 1; }
|
||||
done
|
||||
# And the values that carry the security property rather than just the
|
||||
# shape: x-api-key must not travel beside the injected Authorization, and
|
||||
# the OAuth beta must be present or the API rejects the token.
|
||||
grep -q -- '--strip-header x-api-key' <<< "$launch" \
|
||||
|| { echo "x-api-key is no longer stripped"; return 1; }
|
||||
grep -q -- 'anthropic-beta=oauth-2025-04-20' <<< "$launch" \
|
||||
|| { echo "the OAuth beta is no longer ensured"; return 1; }
|
||||
grep -q -- '--credential-kind anthropic-oauth' <<< "$launch" \
|
||||
|| { echo "the launch no longer asks for the refreshing credential kind"; return 1; }
|
||||
# And core writes none of it. A constant here would be the arrangement
|
||||
# living in two places, which is how they drift.
|
||||
local d="$REPO_ROOT/bin/dejima" body
|
||||
body="$(grep -v '^[[:space:]]*#' "$d")"
|
||||
for want in 'api\.anthropic\.com' 'platform\.claude\.com' \
|
||||
'oauth-2025-04-20' '9d1c250a'; do
|
||||
grep -qE -- "$want" <<< "$body" \
|
||||
&& { echo "core still writes a provider constant: $want"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "every field the backend manifest declares reaches the proxy's argv" {
|
||||
# A manifest key nothing passes through is a declaration that does
|
||||
# nothing, and the failure is silent: the proxy falls back to its own
|
||||
# default, or to none, and the session runs with an arrangement the
|
||||
# operator did not configure. Pinned on the launch block itself rather
|
||||
# than anywhere in the file.
|
||||
local launch
|
||||
launch="$(sed -n '/_proxy_args=(/,/dejima-credential-proxy" "\${_proxy_args\[@\]}"/p' "$REPO_ROOT/bin/dejima")"
|
||||
[ -n "$launch" ] || { echo "bin/dejima no longer builds the proxy argument list"; return 1; }
|
||||
local pair
|
||||
for pair in 'upstream:--upstream' 'auth_header:--auth-header' \
|
||||
'auth_format:--auth-format' 'strip_headers:--strip-header' \
|
||||
'ensure_headers:--ensure-header' \
|
||||
'oauth_endpoint:--oauth-endpoint' \
|
||||
'oauth_client_id:--oauth-client-id'; do
|
||||
grep -q -- "${pair##*:}" <<< "$launch" \
|
||||
|| { echo "no ${pair##*:} on the launch"; return 1; }
|
||||
grep -q -- "${pair%%:*}" <<< "$launch" \
|
||||
|| { echo "${pair%%:*} is never read from the manifest"; return 1; }
|
||||
done
|
||||
# The credential kind and file come from the provider too, not from a
|
||||
# hardcoded path to one harness's credential store.
|
||||
grep -q -- '--credential-kind "\$DEJIMA_BACKEND_CREDENTIAL_KIND"' <<< "$launch" \
|
||||
|| { echo "the credential kind is not the provider's"; return 1; }
|
||||
grep -q -- '--credentials "\$DEJIMA_BACKEND_CREDENTIAL_FILE"' <<< "$launch" \
|
||||
|| { echo "the credential file is not the provider's"; return 1; }
|
||||
}
|
||||
|
||||
@test "the credential proxy names no provider constant anywhere in its code" {
|
||||
@@ -5782,12 +5894,18 @@ EOF
|
||||
# providers.bats) - this fixture sidesteps that with the honest
|
||||
# "no forge, no push" pair rather than configuring a forge just to get
|
||||
# past validation.
|
||||
# [harness.claude-code] backend, because it has no default and never
|
||||
# will: one dejima could change later would move a session to another
|
||||
# endpoint without its config changing (#171). A launch fixture has to
|
||||
# state it like any other operator config.
|
||||
cat > "$DEJIMA_T/config/config" <<EOF
|
||||
[core]
|
||||
harness = claude-code
|
||||
ssh_key = $DEJIMA_T/sandbox-key
|
||||
forge = none
|
||||
workflow = local-only
|
||||
[harness.claude-code]
|
||||
backend = anthropic
|
||||
[audit]
|
||||
dir = $DEJIMA_T/audit
|
||||
EOF
|
||||
@@ -5836,7 +5954,12 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
# `script` rather than a pipe.
|
||||
startup_warning_launch_tty() {
|
||||
local keys="$1"
|
||||
printf '%s' "$keys" | HOME="$DEJIMA_T/home" PATH="$DEJIMA_T:$PATH" \
|
||||
# A leading newline for the pause dejima takes before the build when it
|
||||
# printed a notice worth reading. This fixture sets [core] ssh_key, which
|
||||
# is a deprecation, so that pause fires on every launch here - and it is
|
||||
# ambient rather than the thing any of these tests is about. Answered in
|
||||
# the helper so each caller's keys still mean what they say.
|
||||
printf '\n%s' "$keys" | HOME="$DEJIMA_T/home" PATH="$DEJIMA_T:$PATH" \
|
||||
DEJIMA_CONFIG_DIR="$DEJIMA_T/config" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$DEJIMA_T/docker-log" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= TERM=xterm-256color \
|
||||
@@ -6027,8 +6150,11 @@ q
|
||||
_harness_api_domains() {
|
||||
sed -n 's/^api_domains *= *//p' "$REPO_ROOT/harnesses/claude-code/harness.conf"
|
||||
}
|
||||
_harness_proxied_domain() {
|
||||
sed -n 's/^proxied_api_domain *= *//p' "$REPO_ROOT/harnesses/claude-code/harness.conf"
|
||||
# The endpoint the terminator stands in front of. On the PROVIDER manifest
|
||||
# since #171: a harness reaches whichever endpoint it is configured for, so
|
||||
# the name belongs to the endpoint rather than to the program talking to it.
|
||||
_provider_proxied_domain() {
|
||||
sed -n 's/^proxied_domain *= *//p' "$REPO_ROOT/backends/anthropic/backend.conf"
|
||||
}
|
||||
|
||||
@test "the selected harness's own domains are in the fixed list, not built into the image" {
|
||||
@@ -6058,20 +6184,20 @@ _harness_proxied_domain() {
|
||||
# allowlisted - and in the *fixed* list, not EXTRA_ALLOWED_DOMAINS, which
|
||||
# dns-proxy.sh exempts from stop-dns-rebind and init-firewall.sh treats as
|
||||
# non-fatal when it fails to resolve.
|
||||
local pd; pd="$(_harness_proxied_domain)"
|
||||
local pd; pd="$(_provider_proxied_domain)"
|
||||
[ -n "$pd" ] || { echo "the harness declares no proxied_api_domain"; return 1; }
|
||||
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=/tmp/proxy.sock; SANDBOX_HARNESS_PROXIED_DOMAIN='$pd'; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=/tmp/proxy.sock; SANDBOX_BACKEND_PROXIED_DOMAIN='$pd'; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
[ "$status" -eq 0 ]
|
||||
! grep -qx 'api.anthropic.com' <<< "$output"
|
||||
|
||||
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=; SANDBOX_HARNESS_PROXIED_DOMAIN='$pd'; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=; SANDBOX_BACKEND_PROXIED_DOMAIN='$pd'; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
[ "$status" -eq 0 ]
|
||||
grep -qx 'api.anthropic.com' <<< "$output"
|
||||
|
||||
# Unset (a bare `docker run` that passes no socket) must behave like empty.
|
||||
# Under `set -u`, as both consumers run, so that dropping the `:-` default
|
||||
# fails here rather than at container start.
|
||||
run bash -c "set -u; unset SANDBOX_ANTHROPIC_PROXY_SOCKET; SANDBOX_HARNESS_PROXIED_DOMAIN='$pd'; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
run bash -c "set -u; unset SANDBOX_ANTHROPIC_PROXY_SOCKET; SANDBOX_BACKEND_PROXIED_DOMAIN='$pd'; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
[ "$status" -eq 0 ]
|
||||
grep -qx 'api.anthropic.com' <<< "$output"
|
||||
|
||||
@@ -6930,17 +7056,17 @@ _emit_mount_at() {
|
||||
[ "$runs" -ge 1 ] || { echo "no compose run names the service"; return 1; }
|
||||
}
|
||||
|
||||
@test "the TLS terminator answers for the harness's declared domain, not a literal" {
|
||||
# The manifest declares proxied_api_domain; the entrypoint used the name
|
||||
# of one harness's API in the cert, the SAN and the /etc/hosts pin.
|
||||
@test "the TLS terminator answers for the backend's declared domain, not a literal" {
|
||||
# The backend manifest declares proxied_domain; the entrypoint used the
|
||||
# name of one API in the cert, the SAN and the /etc/hosts pin.
|
||||
local e="$SANDBOX_DIR/entrypoint.sh"
|
||||
run grep -nE '(CN=|DNS:|127\.0\.0\.1 )api\.anthropic\.com' "$e"
|
||||
[ "$status" -ne 0 ] || { echo "the terminator is pinned to one harness's API:"; echo "$output"; return 1; }
|
||||
grep -q 'SANDBOX_HARNESS_PROXIED_DOMAIN' "$e" \
|
||||
grep -q 'SANDBOX_BACKEND_PROXIED_DOMAIN' "$e" \
|
||||
|| { echo "the entrypoint does not read the declared domain"; return 1; }
|
||||
# And a harness that declares a proxy but no domain is refused, rather
|
||||
# And a proxy socket with no domain declared for it is refused, rather
|
||||
# than bringing up a terminator that intercepts nothing.
|
||||
grep -q 'declares no proxied_api_domain' "$e" \
|
||||
grep -q 'declares no proxied_domain' "$e" \
|
||||
|| { echo "an empty proxied domain is not refused"; return 1; }
|
||||
}
|
||||
|
||||
@@ -7073,7 +7199,7 @@ _emit_mount_at() {
|
||||
# how two of these failed.
|
||||
local rt="$REPO_ROOT/test/runtime.bats" sock dom
|
||||
sock=$(grep -c 'SANDBOX_ANTHROPIC_PROXY_SOCKET=.*proxy\.sock' "$rt")
|
||||
dom=$(grep -c 'SANDBOX_HARNESS_PROXIED_DOMAIN=' "$rt")
|
||||
dom=$(grep -c 'SANDBOX_BACKEND_PROXIED_DOMAIN=' "$rt")
|
||||
[ "$sock" -gt 0 ] || { echo "no proxy fixtures found - has this moved?"; return 1; }
|
||||
# One of the domain lines is the file-wide fixture, which mounts no
|
||||
# socket, so the domains must outnumber the sockets that go through a
|
||||
@@ -7541,3 +7667,299 @@ _plain_output() { printf '%s' "$output" | sed 's/\x1b\[[0-9;]*m//g'; }
|
||||
grep -qi 'no per-operator identity' <<< "$flat" \
|
||||
|| { echo "what Basic costs is unstated"; return 1; }
|
||||
}
|
||||
|
||||
@test "the four credential arrangements are documented with what each exposes" {
|
||||
# There is more than one kind of provider now, and they differ in exactly
|
||||
# one way that matters: three keep the credential on the host and one
|
||||
# mounts it. A reader choosing a provider needs that comparison in one
|
||||
# place rather than assembled from a section written when Anthropic OAuth
|
||||
# was the only path (#171).
|
||||
local sec="$REPO_ROOT/docs/security.md" flat
|
||||
flat="$(flat_prose "$sec")"
|
||||
grep -q '^| Arrangement | In the container | What a compromised session can take |' "$sec" \
|
||||
|| { echo "the arrangement table is gone or lost a column"; return 1; }
|
||||
local rows
|
||||
rows="$(sed -n '/^| Arrangement | In the container |/,/^$/p' "$sec")"
|
||||
local arrangement
|
||||
for arrangement in 'anthropic-oauth' 'credential = static' 'credential = none' 'No proxy'; do
|
||||
grep -qF "$arrangement" <<< "$rows" \
|
||||
|| { echo "no row for: $arrangement"; return 1; }
|
||||
done
|
||||
# The one that differs, said outright rather than left to the table.
|
||||
grep -qi 'first three keep the credential out; the fourth does not' <<< "$flat" \
|
||||
|| { echo "nothing says which arrangement mounts the credential"; return 1; }
|
||||
# The residual the proxied ones share. A page that only lists what is
|
||||
# prevented reads as a stronger claim than the code makes.
|
||||
grep -qi 'residual the first two share is use-while-running' <<< "$flat" \
|
||||
|| { echo "the use-while-running residual is undocumented"; return 1; }
|
||||
# Refreshing vs static, which is about the HOST-side value rather than
|
||||
# about what the container sees.
|
||||
grep -qi 'differ in what a leak elsewhere costs, not in what the' <<< "$flat" \
|
||||
|| { echo "oauth and static read as equivalent"; return 1; }
|
||||
# And the firewall asymmetry, with its limit attached. Without the limit
|
||||
# this reads as a route block, which it is not.
|
||||
grep -qi 'not in the egress allowlist while the proxy is on' <<< "$flat" \
|
||||
|| { echo "the allowlist asymmetry is undocumented"; return 1; }
|
||||
grep -qi 'defeats a lookup, not a route' <<< "$flat" \
|
||||
|| { echo "the asymmetry is stated without its limit"; return 1; }
|
||||
}
|
||||
|
||||
@test "the threat model owns the use-while-running residual" {
|
||||
# security.md is the control-by-control account; the threat model is where
|
||||
# a reader looks for what is NOT claimed, and this is the claim a
|
||||
# credential proxy most invites someone to overstate.
|
||||
local flat
|
||||
flat="$(flat_prose "$REPO_ROOT/docs/threat-model.md")"
|
||||
grep -qi 'cannot use the model API as you while it runs' <<< "$flat" \
|
||||
|| { echo "the not-claimed list does not carry it"; return 1; }
|
||||
grep -q 'security.md#four-arrangements-and-what-a-session-can-take-from-each' \
|
||||
"$REPO_ROOT/docs/threat-model.md" \
|
||||
|| { echo "nothing points at the arrangement comparison"; return 1; }
|
||||
}
|
||||
|
||||
@test "cline takes where the model is from the provider, and only from there" {
|
||||
# base_url used to live on the harness, so two settings could name
|
||||
# different places and an unset base_url meant Anthropic implicitly. It is
|
||||
# a provider key now (#171): a self-served endpoint is its own provider
|
||||
# carrying its own base_url, which is what lets an operator keep a local
|
||||
# model configured while using a hosted API.
|
||||
#
|
||||
# So the harness has no base_url at all, and setting one is an unknown key
|
||||
# rather than a second opinion.
|
||||
run bash -c 'source "$1"; source "$2"; config_type harness.cline.base_url' _ \
|
||||
"$REPO_ROOT/bin/dejima-config-lib.sh" "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
[ "$status" -ne 0 ] || { echo "cline still declares a base_url of its own"; return 1; }
|
||||
|
||||
# And an unset provider is a refusal rather than a default, naming both
|
||||
# kinds because for this harness either is a valid answer.
|
||||
local cfg
|
||||
cfg="$(_cline_cfg 'model = m')"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
|
||||
[ "$status" -ne 0 ] || { echo "launched with no provider"; return 1; }
|
||||
[[ "$output" == *"no [harness.cline] backend is set"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"self-hosted"* ]] || { echo "the self-served option is unnamed: $output"; return 1; }
|
||||
}
|
||||
|
||||
# The providers.json cline is actually given, for one config. Prints it as JSON.
|
||||
_cline_providers_json() {
|
||||
local cfg="$1" out
|
||||
_cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws" >/dev/null 2>&1 || return 1
|
||||
out="$(ls -d "$(sed -n 's/^state_dir *= *//p' "$cfg/config")"/*/settings 2>/dev/null | head -1)"
|
||||
[ -n "$out" ] || return 1
|
||||
cat "$out/providers.json"
|
||||
}
|
||||
|
||||
@test "cline's provider type follows the provider's api_style, not a second setting" {
|
||||
# Anthropic's Messages API and an OpenAI-compatible one are different wire
|
||||
# formats and cline has a client for each. Which one to write is the
|
||||
# selected provider's api_style, so an operator never has to keep a
|
||||
# dialect setting in step with an endpoint by hand.
|
||||
local j
|
||||
j="$(_cline_providers_json "$(_cline_cfg 'model = m' 'backend = anthropic')")" \
|
||||
|| { echo "anthropic path produced nothing"; return 1; }
|
||||
[ "$(jq -r .lastUsedProvider <<< "$j")" = anthropic ] || { echo "$j"; return 1; }
|
||||
# No baseUrl on this path: cline addresses the API by name and the
|
||||
# terminator already answers for it.
|
||||
[ "$(jq -r '.providers.anthropic.settings.baseUrl // "absent"' <<< "$j")" = absent ] \
|
||||
|| { echo "$j"; return 1; }
|
||||
|
||||
j="$(_cline_providers_json "$(_cline_cfg 'model = m' 'backend = openrouter')")" \
|
||||
|| { echo "openrouter path produced nothing"; return 1; }
|
||||
[ "$(jq -r .lastUsedProvider <<< "$j")" = openai-compatible ] || { echo "$j"; return 1; }
|
||||
# And addressed by the endpoint's REAL hostname, not a loopback one: the
|
||||
# terminator answers for that name inside the container and the host-side
|
||||
# proxy holds the key, which is what keeps the session looking like one
|
||||
# talking to the API directly.
|
||||
[ "$(jq -r '.providers["openai-compatible"].settings.baseUrl' <<< "$j")" \
|
||||
= "https://openrouter.ai/api/v1" ] || { echo "$j"; return 1; }
|
||||
# A placeholder, never a key. The real one is read host-side per request.
|
||||
[ "$(jq -r '.providers["openai-compatible"].settings.apiKey' <<< "$j")" \
|
||||
= "sandbox-credential-proxy-placeholder" ] || { echo "$j"; return 1; }
|
||||
|
||||
# A model the operator serves takes the same openai-compatible client, and
|
||||
# is the one path with no credential anywhere: nothing to withhold.
|
||||
j="$(_cline_providers_json "$(_cline_cfg 'model = m' 'backend = self-hosted' '[backend.self-hosted]' "base_url = http://127.0.0.1:8080/v1")")" \
|
||||
|| { echo "the self-served path produced nothing"; return 1; }
|
||||
[ "$(jq -r '.providers["openai-compatible"].settings.apiKey' <<< "$j")" \
|
||||
= "dejima-no-credential-needed" ] || { echo "$j"; return 1; }
|
||||
}
|
||||
|
||||
@test "docs/security.md states what the credential scan does not catch" {
|
||||
# A best-effort control that reads as a guarantee is worse than none: an
|
||||
# operator who thinks nothing can cross stops looking. These four limits
|
||||
# are the ones a later tidy-up is most likely to cut as hedging, and each
|
||||
# is a real hole someone could otherwise walk into.
|
||||
local doc; doc="$REPO_ROOT/docs/security.md"
|
||||
local prose; prose="$(flat_prose "$doc")"
|
||||
[[ "$prose" == *"What the scan does not catch"* ]] \
|
||||
|| { echo "the limits section is gone"; return 1; }
|
||||
# Whitespace, which is the trade that makes the rest work.
|
||||
[[ "$prose" == *"spaces in passphrases"* ]] \
|
||||
|| { echo "the whitespace limit is not stated"; return 1; }
|
||||
# And that it is a trade rather than an oversight.
|
||||
[[ "$prose" == *"deliberate trade"* ]] \
|
||||
|| { echo "the whitespace limit reads as an oversight"; return 1; }
|
||||
# Formats it cannot read.
|
||||
[[ "$prose" == *"format it cannot parse"* ]] \
|
||||
|| { echo "the unparseable-file limit is not stated"; return 1; }
|
||||
# And the boundary of what is scanned at all.
|
||||
[[ "$prose" == *"Anything dejima does not copy"* ]] \
|
||||
|| { echo "the scope limit is not stated"; return 1; }
|
||||
[[ "$prose" == *"read-write mounts"* ]] \
|
||||
|| { echo "read-write mounts are not named as unscanned"; return 1; }
|
||||
}
|
||||
|
||||
@test "every flag --help offers is one bin/dejima actually parses" {
|
||||
# --base-url outlived the setting it fed: the provider axis moved the
|
||||
# model's address onto the backend and deleted the flag, and the help page
|
||||
# kept advertising it. Passing it answered "unknown option", which is the
|
||||
# worst kind of documentation - it is read as a bug in dejima rather than
|
||||
# in the text.
|
||||
local flag missing=""
|
||||
while IFS= read -r flag; do
|
||||
[ -n "$flag" ] || continue
|
||||
# A flag can be any alternative in a case pattern - `--ro` is the
|
||||
# second of `--extra-readonly|--ro)` - so match it wherever it sits in
|
||||
# the list rather than only at the start.
|
||||
grep -qE "(^|[[:space:]|])${flag}[)|]" "$REPO_ROOT/bin/dejima" \
|
||||
|| missing="$missing $flag"
|
||||
done < <(sed -n '/^Options (each overrides/,/^$/p' <<< "$(bin/dejima --help 2>&1)" \
|
||||
| grep -oE '^\s+--[a-z-]+' | tr -d ' ' | sort -u)
|
||||
[ -z "$missing" ] || {
|
||||
echo "--help offers flags bin/dejima does not parse:$missing" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "cline brings the operator's own config in, and read-only" {
|
||||
# Everything here is either an instruction the agent follows or a program
|
||||
# cline runs. A session that could rewrite them would be choosing what
|
||||
# happens on the HOST the next time cline starts outside a container.
|
||||
local dirs d
|
||||
dirs="$(_declared_for cline harness_config_dirs_load DEJIMA_CONFIG_DIRS)"
|
||||
for d in /h/.agents/skills /h/.agents/hooks /h/.cline/hooks; do
|
||||
grep -q "^$d"$'\t'"ro" <<< "$dirs" \
|
||||
|| { echo "$d is not mounted read-only: $dirs"; return 1; }
|
||||
# scan, because on a Linux host these are routinely symlinks into a
|
||||
# dotfiles checkout and the target has to come with them.
|
||||
grep -q "^$d"$'\t'"ro"$'\t'"scan" <<< "$dirs" \
|
||||
|| { echo "$d is not scanned for symlink targets: $dirs"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "cline's global rules reach the one directory it reads, from both places" {
|
||||
# cline loads global rules from Documents/Cline/Rules and nowhere else -
|
||||
# ~/.agents/rules reads like its companion and is not read, checked by
|
||||
# planting a file in each and asking `cline config` which it had. So
|
||||
# ~/.agents/rules is supported by staging INTO the directory that is read.
|
||||
local h staged
|
||||
h="$(mktemp -d)"
|
||||
mkdir -p "$h/.agents/rules" "$h/Documents/Cline/Rules"
|
||||
printf 'a\n' > "$h/.agents/rules/aa-only-in-agents.md"
|
||||
printf 'b\n' > "$h/Documents/Cline/Rules/bb-only-in-docs.md"
|
||||
printf 'from-agents\n' > "$h/.agents/rules/cc-in-both.md"
|
||||
printf 'from-docs\n' > "$h/Documents/Cline/Rules/cc-in-both.md"
|
||||
printf 'host\n' > "$h/.agents/rules/00-dejima.md"
|
||||
printf '{}\n' > "$h/.agents/rules/dd-not-a-rule.json"
|
||||
staged="$(_declared_for_home "$h" cline harness_staged_config_load DEJIMA_STAGED_CONFIG)"
|
||||
|
||||
local f
|
||||
for f in aa-only-in-agents bb-only-in-docs; do
|
||||
grep -q "Documents/Cline/Rules/$f.md" <<< "$staged" \
|
||||
|| { echo "$f did not reach the rules directory: $staged"; rm -rf "$h"; return 1; }
|
||||
done
|
||||
# One entry for a name in both, or it is two bind mounts at one container
|
||||
# path. Documents wins: it is cline's own location for this.
|
||||
[ "$(grep -c "Documents/Cline/Rules/cc-in-both.md" <<< "$staged")" -eq 1 ] \
|
||||
|| { echo "a colliding name was staged twice: $staged"; rm -rf "$h"; return 1; }
|
||||
grep -q "$h/Documents/Cline/Rules/cc-in-both.md" <<< "$staged" \
|
||||
|| { echo "the .agents copy won the collision: $staged"; rm -rf "$h"; return 1; }
|
||||
# dejima's briefing wins its own name, and entrypoint.sh writes it after
|
||||
# the staged files - so staging a host file it overwrites is only noise.
|
||||
grep -q "00-dejima.md" <<< "$staged" \
|
||||
&& { echo "a host 00-dejima.md was staged: $staged"; rm -rf "$h"; return 1; }
|
||||
# Only what cline reads out of a rules directory.
|
||||
grep -q "dd-not-a-rule.json" <<< "$staged" \
|
||||
&& { echo "a non-rule file was staged: $staged"; rm -rf "$h"; return 1; }
|
||||
rm -rf "$h"
|
||||
}
|
||||
|
||||
@test "cline's rules directory under ~/.agents is deliberately not mounted" {
|
||||
# Not mounted where it sits, because cline does not read it there - it is
|
||||
# staged into Documents/Cline/Rules instead, which the test above covers.
|
||||
# A mount here would put the operator's rules somewhere nothing loads
|
||||
# them, which looks like support and is not.
|
||||
local dirs
|
||||
dirs="$(_declared_for cline harness_config_dirs_load DEJIMA_CONFIG_DIRS)"
|
||||
grep -q "^/h/.agents/rules"$'\t' <<< "$dirs" \
|
||||
&& { echo "~/.agents/rules is mounted, and cline does not read it"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "cline's AGENTS.md is staged, and its settings are seeded writable" {
|
||||
# AGENTS.md is instructions the agent follows, so the session gets a
|
||||
# root-owned read-only copy: writing the host's would choose what the next
|
||||
# session is told.
|
||||
local staged
|
||||
staged="$(_declared_for cline harness_staged_config_load DEJIMA_STAGED_CONFIG)"
|
||||
grep -q "^/h/.agents/AGENTS.md"$'\t'".agents/AGENTS.md" <<< "$staged" \
|
||||
|| { echo "AGENTS.md is not staged: $staged"; return 1; }
|
||||
|
||||
# The settings files cline rewrites as it runs. A read-only copy would
|
||||
# fail the write rather than the persistence, and the persistence is the
|
||||
# part that matters: cline_mcp_settings.json names commands cline runs.
|
||||
local seed f h
|
||||
h="$(mktemp -d)"; mkdir -p "$h/.cline"
|
||||
printf '{}\n' > "$h/.cline/settings.json"
|
||||
printf '{"mcpServers":{}}\n' > "$h/.cline/cline_mcp_settings.json"
|
||||
seed="$(_declared_for_home "$h" cline harness_seed_load DEJIMA_SEED)"
|
||||
for f in settings.json cline_mcp_settings.json; do
|
||||
grep -q ".cline/$f" <<< "$seed" \
|
||||
|| { echo "$f is not seeded writable: $seed"; rm -rf "$h"; return 1; }
|
||||
done
|
||||
rm -rf "$h"
|
||||
# And neither is a plain read-write mount of the host's file, which is the
|
||||
# one shape that would let a session reach it.
|
||||
local dirs
|
||||
dirs="$(_declared_for cline harness_config_dirs_load DEJIMA_CONFIG_DIRS)"
|
||||
grep -q "^/h/.cline"$'\t'"$" <<< "$dirs" \
|
||||
&& { echo "~/.cline is mounted read-write wholesale: $dirs"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "only a harness that can be resumed outside dejima says so" {
|
||||
# The sentence promising it lived in the core briefing, where it was
|
||||
# Claude Code's fact stated as everyone's - and a Cline session reading it
|
||||
# would send its operator somewhere that does not work. Core says nothing
|
||||
# about resuming now; each harness fragment says what is true of it.
|
||||
local core
|
||||
core="$(bash -c 'source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"; _briefing_core_closing_a')"
|
||||
grep -qi 'outside the container' <<< "$core" \
|
||||
&& { echo "core still promises a resume: $core"; return 1; }
|
||||
|
||||
# claude-code can, and offers the command.
|
||||
local cc
|
||||
cc="$(bash -c 'source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
WORKSPACE_DIR=/w briefing_compose none local-only claude-code')"
|
||||
grep -qi 'continue this exact session outside the container' <<< "$cc" \
|
||||
|| { echo "claude-code no longer offers a resume"; return 1; }
|
||||
grep -q 'claude --resume' <<< "$cc" \
|
||||
|| { echo "claude-code's resume command is gone"; return 1; }
|
||||
|
||||
# cline cannot, and says so rather than staying silent: an agent told
|
||||
# nothing will offer it anyway.
|
||||
local cl
|
||||
cl="$(bash -c 'source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
WORKSPACE_DIR=/w briefing_compose none local-only cline')"
|
||||
grep -qi 'cannot be continued outside dejima' <<< "$cl" \
|
||||
|| { echo "cline's briefing does not rule out resuming outside: $cl"; return 1; }
|
||||
grep -qi 'do not offer that' <<< "$cl" \
|
||||
|| { echo "cline's briefing does not tell the agent not to offer it"; return 1; }
|
||||
# And it does say what DOES work, or the agent has nothing to tell anyone.
|
||||
grep -qi 'launching dejima again' <<< "$cl" \
|
||||
|| { echo "cline's briefing does not say what continuing looks like"; return 1; }
|
||||
# No stale instructions for driving cline on the host.
|
||||
grep -q 'CLINE_DATA_DIR' <<< "$cl" \
|
||||
&& { echo "cline's briefing still tells the operator to run cline on the host"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user