Make the credential proxy provider-generic, with Anthropic as one caller
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m29s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m4s
tests / confined (static) (pull_request_target) Successful in 5m34s
tests / confined (audit) (pull_request_target) Successful in 8m10s
tests / confined (config) (pull_request_target) Successful in 9m30s

This commit is contained in:
2026-08-29 17:31:35 -07:00
parent 817959acb9
commit f2cfd0d868
12 changed files with 498 additions and 94 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ set before opening a PR, not between edits.
| `bin/dejima-tui-lib.sh` | `test/remote.bats` and `test/project.bats` (both tools use it) |
| `bin/dejima-tty-suspend` | `test/static.bats` |
| `tools/` | `test/providers.bats` |
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
| `bin/dejima-credential-proxy`, `docker/credential-proxy-forwarder.js` | `test/proxy.bats` |
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
+1 -1
View File
@@ -1,7 +1,7 @@
.claude
web/node_modules
# Python bytecode. bin/dejima-anthropic-proxy is a python program, so anything
# Python bytecode. bin/dejima-credential-proxy is a python program, so anything
# that imports or compiles it leaves these beside it.
__pycache__/
*.pyc
+16 -4
View File
@@ -1616,7 +1616,7 @@ done
export SANDBOX_HARNESS_SEED
# The real Anthropic OAuth credential never enters the container: a host-side
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
# proxy injects it per request (bin/dejima-credential-proxy, docs/security.md).
# ~/.claude is not mounted whole, so the credential has no path into the
# container and nothing needs masking (#50, docs/security.md).
#
@@ -1643,11 +1643,23 @@ 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
SANDBOX_ANTHROPIC_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-anthropic-proxy.XXXXXX")"
SANDBOX_ANTHROPIC_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SANDBOX_ANTHROPIC_PROXY_DIR/proxy.sock"
"$SCRIPT_DIR/dejima-anthropic-proxy" \
# 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" &
--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" &
SANDBOX_ANTHROPIC_PROXY_PID=$!
for _ in $(seq 1 50); do
[ -S "$SANDBOX_ANTHROPIC_PROXY_SOCKET" ] && break
@@ -1,12 +1,24 @@
#!/usr/bin/env python3
# Host-side credential-injecting proxy for sandboxed Claude Code sessions.
# Host-side credential-injecting proxy for sandboxed sessions.
#
# The container only ever holds a placeholder token; this proxy (launched on
# the host by bin/dejima, reached via a bind-mounted unix socket and
# an in-container loopback forwarder) strips the placeholder and injects the
# real OAuth access token from ~/.claude/.credentials.json, so the credential
# itself never enters the container. It also owns token refresh: rotated
# tokens are written back to the real host file atomically, keeping
# host-side sessions logged in. See docs/security.md.
# the host by bin/dejima, reached via a bind-mounted unix socket and an
# in-container loopback forwarder) strips the placeholder and injects the real
# 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
# 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 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.
import argparse
import http.client
import json
@@ -19,8 +31,6 @@ import time
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlsplit
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
OAUTH_BETA = "oauth-2025-04-20"
# Hop-by-hop headers describe the connection to this proxy rather than the
# request, so they stop here (RFC 9110 7.6.1). Host is deliberately not in
# this set: it has to be rewritten for the upstream, and is replaced in place
@@ -30,28 +40,84 @@ HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
}
# Casing for the few headers this proxy has to supply when the client sent
# none of its own. Everything the client did send keeps the client's casing.
# Casing for the headers this proxy has to supply when the client sent none of
# 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.
SUPPLIED_NAMES = {
"host": "Host",
"authorization": "Authorization",
"anthropic-beta": "anthropic-beta",
"content-length": "Content-Length",
}
class Credentials:
def __init__(self, path, oauth_endpoint, margin):
class StaticCredential:
"""A key in a file, sent as-is. No expiry, so nothing to refresh."""
# Trailing newline stripped, because a key file is written by an editor or
# a `>` redirection as often as by a tool, and a newline inside a header
# value is not a cosmetic difference: it would either be rejected or split
# 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):
self.path = path
def token(self):
with open(self.path) as f:
value = f.read().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]
if bad:
raise RuntimeError("%s contains a control character" % self.path)
return value
def refresh(self, stale_token):
# Nothing to refresh. A 401 from a static key means the key is wrong,
# and retrying with the same bytes would turn one rejection into two.
raise RuntimeError("this credential cannot be refreshed")
@property
def refreshable(self):
return False
def startup_check(self):
self.token()
class OAuthCredential:
def __init__(self, path, oauth_endpoint, client_id, margin):
self.path = path
self.oauth_endpoint = oauth_endpoint
self.client_id = client_id
self.margin = margin
self.lock = threading.Lock()
@property
def refreshable(self):
return True
def startup_check(self):
# A plain read, not token(): this is a startup sanity check and must
# not trigger a refresh (that belongs to the first real request, so an
# external refresher gets a chance to win first).
self._read()["claudeAiOauth"]["accessToken"]
expires = self._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
# Concrete, not generic. This class reads Claude Code's own
# credential shape, so the remedy is a command the operator can
# run rather than an instruction to work out.
raise RuntimeError(
"the refresh token has expired - run `claude` on the host "
"and log in again first")
def _read(self):
with open(self.path) as f:
return json.load(f)
def access_token(self):
def token(self):
oauth = self._read()["claudeAiOauth"]
if oauth["expiresAt"] / 1000 - time.time() < self.margin:
self.refresh(stale_token=oauth["accessToken"])
@@ -75,7 +141,7 @@ class Credentials:
req = json.dumps({
"grant_type": "refresh_token",
"refresh_token": oauth["refreshToken"],
"client_id": OAUTH_CLIENT_ID,
"client_id": self.client_id,
})
conn.request("POST", u.path, body=req,
headers={"Content-Type": "application/json"})
@@ -121,6 +187,24 @@ class Credentials:
raise
class AuthShape:
"""How a provider 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
Anthropic OAuth beta and general enough to need no name of its own here.
"""
def __init__(self, header, fmt, strip, ensure):
self.header = header
self.format = fmt
# The auth header is never also stripped: it is replaced, and listing
# it in both places would drop the injected value instead of sending
# it.
self.strip = frozenset(n.lower() for n in strip) - {header.lower()}
self.ensure = ensure
class BodyTooLarge(Exception):
"""A request body over the cap. Its own type, not ValueError: _read_body
raises ValueError for a genuinely malformed request (a chunk length that
@@ -135,7 +219,7 @@ class Handler(BaseHTTPRequestHandler):
pass # per-request lines would interleave with the session's own UI
def log_error(self, fmt, *args):
sys.stderr.write("dejima-anthropic-proxy: " + fmt % args + "\n")
sys.stderr.write("dejima-credential-proxy: " + fmt % args + "\n")
# Capped, because the client is the sandboxed session and this runs on the
# host as the operator's user. Every other session-controlled size in this
@@ -207,25 +291,34 @@ class Handler(BaseHTTPRequestHandler):
# which is re-emitted in the client's own position instead.
conn.putrequest(self.command, self.path,
skip_host=True, skip_accept_encoding=True)
auth = self.server.auth
supplied = {
"host": u.netloc,
"authorization": "Bearer " + token,
auth.header.lower(): auth.format.replace("{token}", token),
"content-length": str(len(body)),
}
# anthropic-beta is left completely alone when the OAuth value is
# already among the client's own - which it is in the tls transport,
# where Claude Code believes it holds an OAuth credential. Re-joining
# a list that needs no change would normalize the client's spacing,
# which is the same class of tell as the casing.
betas = [b.strip() for v in self.headers.get_all("anthropic-beta", [])
for b in v.split(",")]
if OAUTH_BETA not in betas:
first = self.headers.get("anthropic-beta")
supplied["anthropic-beta"] = first + "," + OAUTH_BETA if first else OAUTH_BETA
# An ensure-header is left completely alone when its value is already
# among the client's own - as anthropic-beta's is in the tls transport,
# where the harness believes it holds an OAuth credential. Re-joining a
# list that needs no change would normalize the client's spacing, which
# is the same class of tell as the casing.
for name, wanted in auth.ensure:
low = name.lower()
present = [v.strip() for header in self.headers.get_all(low, [])
for v in header.split(",")]
if wanted in present:
continue
first = self.headers.get(low)
supplied[low] = first + "," + wanted if first else wanted
seen = set()
for name, value in self.headers.items():
low = name.lower()
if low in HOP_BY_HOP or low == "x-api-key":
# The strip set is the provider'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
# not this program's to guess.
if low in HOP_BY_HOP or low in auth.strip:
continue
if low in supplied:
# One replacement even for a repeated header, or the injected
@@ -236,6 +329,13 @@ class Handler(BaseHTTPRequestHandler):
value = supplied[low]
seen.add(low)
conn.putheader(name, value)
# 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
# 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.
for low, name in SUPPLIED_NAMES.items():
if low in supplied and low not in seen:
# Content-Length only when there is a body: a bodyless GET
@@ -257,19 +357,22 @@ class Handler(BaseHTTPRequestHandler):
return self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
try:
token = self.server.creds.access_token()
token = self.server.creds.token()
except Exception as e:
return self._fail(502, "credential proxy: could not load or refresh the "
"credential - see host terminal",
log_message="cannot load credentials: %s" % e)
try:
conn, resp = self._upstream(token, body)
if resp.status == 401:
if resp.status == 401 and self.server.creds.refreshable:
# Upstream disagrees with expiresAt - refresh once and retry.
# Only for a credential that can be refreshed: retrying a
# static key sends the same bytes and turns one rejection into
# two, with the second one looking like a flapping upstream.
resp.read()
conn.close()
self.server.creds.refresh(stale_token=token)
token = self.server.creds.access_token()
token = self.server.creds.token()
conn, resp = self._upstream(token, body)
except Exception as e:
return self._fail(502, "credential proxy: upstream request failed - "
@@ -315,9 +418,10 @@ class Handler(BaseHTTPRequestHandler):
class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def __init__(self, socket_path, upstream, creds):
def __init__(self, socket_path, upstream, creds, auth):
self.upstream = upstream
self.creds = creds
self.auth = auth
super().__init__(socket_path, Handler)
def get_request(self):
@@ -345,34 +449,76 @@ def main():
p = argparse.ArgumentParser()
p.add_argument("--socket", required=True)
p.add_argument("--credentials", required=True)
p.add_argument("--upstream", default="https://api.anthropic.com")
p.add_argument("--oauth-endpoint", default="https://platform.claude.com/v1/oauth/token")
# 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.
p.add_argument("--upstream", required=True)
p.add_argument("--credential-kind", required=True,
choices=("static", "anthropic-oauth"))
p.add_argument("--auth-header", default="Authorization")
# "{token}" rather than a scheme name, so a provider 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
# states what must not travel beside its credential, and inheriting
# another provider's list would be a guess.
p.add_argument("--strip-header", action="append", default=[],
metavar="NAME")
p.add_argument("--ensure-header", action="append", default=[],
metavar="NAME=VALUE")
p.add_argument("--oauth-endpoint")
p.add_argument("--oauth-client-id")
p.add_argument("--refresh-margin", type=int, default=300)
args = p.parse_args()
creds = Credentials(args.credentials, args.oauth_endpoint, args.refresh_margin)
if "{token}" not in args.auth_format:
sys.stderr.write("dejima-credential-proxy: --auth-format must contain "
"{token}, or the credential is never sent\n")
return 2
ensure = []
for item in args.ensure_header:
if "=" not in item:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
name, _, value = item.partition("=")
if not name or not value:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
ensure.append((name, value))
if args.credential_kind == "anthropic-oauth":
if not args.oauth_endpoint or not args.oauth_client_id:
sys.stderr.write("dejima-credential-proxy: anthropic-oauth needs "
"--oauth-endpoint and --oauth-client-id\n")
return 2
creds = OAuthCredential(args.credentials, args.oauth_endpoint,
args.oauth_client_id, args.refresh_margin)
else:
creds = StaticCredential(args.credentials)
try:
# A plain read, not access_token(): this is just a startup sanity
# check and must not trigger a refresh (that belongs to the first
# real request, so an external refresher gets a chance to win first).
creds._read()["claudeAiOauth"]["accessToken"]
creds.startup_check()
except Exception as e:
sys.stderr.write(
"dejima-anthropic-proxy: unusable credentials file %s: %s\n"
"dejima-credential-proxy: unusable credential %s: %s\n"
% (args.credentials, e))
return 1
expires = creds._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
sys.stderr.write(
"dejima-anthropic-proxy: the refresh token in %s has "
"expired - run `claude` on the host and log in again first\n"
% args.credentials)
return 1
auth = AuthShape(args.auth_header, args.auth_format,
args.strip_header, ensure)
# In the spelling the provider 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:
SUPPLIED_NAMES.setdefault(name.lower(), name)
if os.path.exists(args.socket):
os.unlink(args.socket)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds, auth)
try:
server.serve_forever()
except KeyboardInterrupt:
+1 -1
View File
@@ -273,7 +273,7 @@ COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
COPY docker/sandbox-clipboard.lua /usr/local/share/nvim/runtime/plugin/sandbox-clipboard.lua
COPY docker/entrypoint.sh /usr/local/bin/
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
COPY docker/credential-proxy-forwarder.js /usr/local/bin/credential-proxy-forwarder.js
COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# The two guards that do not need the harness's help. git-guard shadows git on
# PATH; forge-guard is linked over a forge CLI by entrypoint.sh when a workflow
@@ -1,6 +1,6 @@
// TCP front door for the host-side credential proxy, which listens on a
// bind-mounted unix socket (no host TCP exposure - see
// bin/dejima-anthropic-proxy). Deliberately no HTTP awareness in either
// bin/dejima-credential-proxy). Deliberately no HTTP awareness in either
// mode: bytes in, bytes out.
//
// tls (default): terminates TLS for api.anthropic.com on :443, which
@@ -29,7 +29,7 @@ const port = parseInt(
10,
);
if (!socketPath) {
console.error("anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
console.error("credential-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
process.exit(1);
}
@@ -47,7 +47,7 @@ if (transport === "tls") {
const keyPath = process.env.SANDBOX_ANTHROPIC_TLS_KEY;
if (!certPath || !keyPath) {
console.error(
"anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
"credential-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
);
process.exit(1);
}
+3 -3
View File
@@ -348,8 +348,8 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ]; then
fi
# TCP front door for the host-side credential proxy (see bin/dejima and
# bin/dejima-anthropic-proxy): the proxy listens on a bind-mounted unix
# socket, and anthropic-proxy-forwarder.js pipes bytes into it. Only started
# bin/dejima-credential-proxy): the proxy listens on a bind-mounted unix
# 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
@@ -438,7 +438,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
# the single most ordinary variable a corporate environment sets.
export NODE_EXTRA_CA_CERTS="$tls_dir/cert.pem"
fi
gosu "$SANDBOX_USER" node /usr/local/bin/anthropic-proxy-forwarder.js &
gosu "$SANDBOX_USER" node /usr/local/bin/credential-proxy-forwarder.js &
# Node's own startup (V8 init, module load, then finally binding the
# listen socket) isn't instant - without this wait, a request that
# arrives right after this script proceeds (an automated test's
+1 -1
View File
@@ -489,7 +489,7 @@ harness-specific path resolving to nothing is the honest answer.
| 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` |
| 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-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
| 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` |
| Briefing delivery | **by which of three routes**, not whether. Claude Code takes the briefing as a system prompt, through the flag its manifest names in `briefing_args`, because a `SessionStart` payload past roughly nine kilobytes is persisted and replaced with a preview - so the hook route silently under-delivered a briefing carrying a project's `.dejima.md`. Cline takes no such flag and registers no hook, so it declares a `briefing_path` and reads a file `entrypoint.sh` writes root-owned and read-only. A harness doing neither still gets it through the hook. What the briefing *says* is none of theirs: composing it host-side is core's, and the container-side part - this container's id, the declared-versus-installed gap, the project's `.dejima.md` - is one script every route calls | `hooks/dejima-briefing-render.sh`, `hooks/sandbox-context.sh`, `docker/entrypoint.sh` |
+9 -3
View File
@@ -571,7 +571,7 @@ session, where the credential deliberately never goes.
enters the container. It isn't mounted: `~/.claude` is not shared as a
whole (see the mount inventory below), so there is no path from
inside the container to that file. `dejima` starts a host-side proxy
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
(`bin/dejima-credential-proxy`) on a per-session unix socket and mounts a
fake credential in place of the real one - the secrets are placeholders,
while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId` are copied so
the session's UI stays accurate. Its expiry is a year out rather than
@@ -579,7 +579,7 @@ session, where the credential deliberately never goes.
refresh token that isn't real, logging the session out. Inside the
container,
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
(`credential-proxy-forwarder.js`) whose certificate is generated at startup
and trusted only there, and which pipes raw bytes into the proxy's socket.
The proxy injects the real access token per request and owns OAuth refresh
- rotated tokens are written back to the real host file atomically, so
@@ -588,7 +588,13 @@ session, where the credential deliberately never goes.
`ANTHROPIC_AUTH_TOKEN`, nothing marking the session as unusual. The proxy
forwards the client's header block verbatim - same names, casing, order and
repeats, and no `Accept-Encoding` it didn't send - changing only
`Authorization`, and `anthropic-beta` if the OAuth value isn't there. Left
`Authorization`, and `anthropic-beta` if the OAuth value isn't there.
Neither of those is a name the proxy holds: which header carries the
credential, what shape it takes, what must not travel beside it and which
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
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
+173 -7
View File
@@ -7,7 +7,7 @@ PLACEHOLDER="sandbox-credential-proxy-placeholder"
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
PROXY="$REPO_ROOT/bin/dejima-anthropic-proxy"
PROXY="$REPO_ROOT/bin/dejima-credential-proxy"
TD="$(mktemp -d)"
SOCK="$TD/proxy.sock"
CREDS="$TD/credentials.json"
@@ -27,10 +27,21 @@ EOF
chmod 600 "$CREDS"
}
# The Anthropic arrangement, spelled the way bin/dejima spells it. The proxy
# itself now defaults none of this (#171), so a test that omitted a flag would
# be exercising a configuration no launch produces - which is why these are
# here in full rather than left to defaults, and why static.bats pins that
# bin/dejima still passes the same set.
start_proxy() {
"$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" "$@" 2>"$TD/stderr" &
--credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/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" "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
@@ -268,8 +279,13 @@ teardown() {
@test "an already-expired refresh token is fatal at startup, before the socket exists" {
write_creds 7200
sed -i 's/"refreshTokenExpiresAt":[0-9]*/"refreshTokenExpiresAt":1000/' "$CREDS"
run "$PROXY" --socket "$SOCK" --credentials "$CREDS" --upstream "http://127.0.0.1:$PORT"
[ "$status" -ne 0 ]
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" \
--oauth-client-id probe
# 1, not just nonzero: an argument mistake exits 2, and this test would
# otherwise pass on one while proving nothing about the expiry check.
[ "$status" -eq 1 ]
[[ "$output" == *"log in"* ]]
[ ! -S "$SOCK" ]
}
@@ -278,7 +294,7 @@ teardown() {
start_proxy
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" &
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
FWD_PID=$!
ok=""
for _ in $(seq 1 50); do
@@ -347,7 +363,7 @@ start_forwarder() { # start_forwarder <transport> <port>; extra env passes thro
SANDBOX_ANTHROPIC_PROXY_PORT="$2" \
SANDBOX_ANTHROPIC_TLS_CERT="$TD/cert.pem" \
SANDBOX_ANTHROPIC_TLS_KEY="$TD/key.pem" \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
FWD_PID=$!
for _ in $(seq 1 50); do
(exec 3<>"/dev/tcp/127.0.0.1/$2") 2>/dev/null && return 0
@@ -422,7 +438,7 @@ make_cert() {
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js"
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_ANTHROPIC_TLS_CERT"* ]]
}
@@ -464,3 +480,153 @@ make_cert() {
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "200" ] || { echo "a 2MiB body was refused with $output"; return 1; }
}
# --- provider-generic behaviour (#171) ---------------------------------------
#
# The proxy no longer knows a provider'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.
# 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
# every one of them is bounded by `timeout`. Without it, a check that stopped
# refusing does not fail the test - the proxy starts, serves forever, and the
# test hangs until the whole suite is killed, which reports as no result rather
# than as a wrong one. Found by mutating two of these and watching them time
# out instead of going red.
start_static() {
printf 'sk-test-static-key\n' > "$TD/key"
chmod 600 "$TD/key"
"$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" \
--credential-kind static "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
}
@test "a static key is injected, and the placeholder never reaches upstream" {
start_static
run pcurl -d '{"hello":1}' http://proxy/v1/chat/completions
[ "$status" -eq 0 ]
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
[[ "$output" != *"$PLACEHOLDER"* ]]
[ "$(jq -r '.body' <<<"$output")" = '{"hello":1}' ]
}
@test "a static key file's trailing newline does not reach the header" {
# Written by an editor or a shell redirection as often as by a tool. A
# newline in a header value is not cosmetic: it would be rejected or split
# the block.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
}
@test "a static credential is never refreshed, so a 401 is reported once" {
# Refreshing needs somewhere to refresh against, and a static key has
# none. Retrying would send the same bytes and turn one rejection into
# two, which reads as a flapping upstream rather than a wrong key.
# The same route the OAuth retry test uses: 401 on the first call, echo
# after. An OAuth credential refreshes and the second call succeeds; a
# static one has nowhere to refresh against, so the 401 is what the
# session sees.
start_static
run pcurl -o /dev/null -w '%{http_code}' -d '{}' http://proxy/echo-401-once
[ "$output" = "401" ]
# And no refresh was attempted anywhere, which the fake counts for us.
[ "$(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
# 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
# is a separate declaration, which the next test covers.
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer $PLACEHOLDER" ]
}
@test "a header named in strip-header does not travel beside the injected one" {
start_static --auth-header x-goog-api-key --auth-format '{token}' \
--strip-header authorization
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization // "absent"' <<<"$output")" = "absent" ]
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
}
@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
# is that the credential still goes: dropping it would send an
# unauthenticated request and report the provider'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
# the END of the block from the supplied-names pass; a replaced one keeps
# the slot the client gave it. Both send the credential, and only one of
# them leaves the request looking like the client wrote it - which is the
# property the whole header-emission dance exists for.
start_static --strip-header authorization
run pcurl -H "Content-Type: application/json" -H "X-Stainless-Lang: js" \
-d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
local auth_at type_at
auth_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("authorization")' <<<"$output")"
type_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("content-type")' <<<"$output")"
[ "$auth_at" -lt "$type_at" ] \
|| { echo "Authorization moved to position $auth_at, after Content-Type at $type_at"; return 1; }
}
@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.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers["anthropic-beta"] // "absent"' <<<"$output")" = "absent" ]
}
@test "an unusable static key is fatal at startup, before the socket exists" {
: > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static
[ "$status" -eq 1 ]
[[ "$output" == *"empty"* ]]
[ ! -S "$SOCK" ]
}
@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.
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
[ "$status" -eq 1 ]
[[ "$output" == *"control character"* ]]
[ ! -S "$SOCK" ]
}
@test "an auth-format with no token placeholder is refused" {
# It would start, answer requests, and send every one of them without a
# credential. Refusing at parse time is the difference between a
# configuration error and a silently unauthenticated session.
printf 'k\n' > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static \
--auth-format 'Bearer'
[ "$status" -eq 2 ]
[[ "$output" == *"{token}"* ]]
[ ! -S "$SOCK" ]
}
@test "anthropic-oauth without its endpoint or client id is refused" {
write_creds 7200
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth
[ "$status" -eq 2 ]
[[ "$output" == *"oauth-endpoint"* ]]
[ ! -S "$SOCK" ]
}
+34 -13
View File
@@ -78,6 +78,29 @@ network_name() { echo "dejima-test-net-$(basename "$BATS_RUN_TMPDIR")"; }
# long-lived runner, until someone clears it by hand.
test_container_name() { echo "dejima-test-$1-$(basename "$BATS_RUN_TMPDIR")"; }
# The Anthropic arrangement, in one place.
#
# 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
# 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.
#
# Sets PROXY_PID, which every caller's teardown already kills.
start_credential_proxy() { # <socket> <credentials> <upstream> <oauth-endpoint>
"$BATS_TEST_DIRNAME/../bin/dejima-credential-proxy" \
--socket "$1" --credentials "$2" --upstream "$3" \
--credential-kind anthropic-oauth \
--oauth-endpoint "$4" \
--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" &
PROXY_PID=$!
}
# Every container here must go on network_name(), never the default bridge:
# dns-proxy.sh forwards to Docker's embedded resolver at 127.0.0.11, which
# only exists on a user-defined network. On the default bridge dnsmasq has
@@ -1290,17 +1313,16 @@ EOF'
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$TD/credentials.json" \
"http://127.0.0.1:$(cat "$TD/port")" \
"http://127.0.0.1:$(cat "$TD/port")/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# UnixStreamServer binds at umask default, so the socket lands 755, and
# connecting to a unix socket needs *write* permission on it. In the real
# flow the connecting uid is the one that bound it, so that is fine;
# under CI it is root binding and node connecting, and the whole chain
# this test follows fails at its first hop. Widened rather than left to
# dejima-anthropic-proxy, which has no such problem in production.
# dejima-credential-proxy, which has no such problem in production.
# Unconditional: if the loop above timed out, "No such file" here says so
# more clearly than a test that fails on a bare false condition.
chmod 666 "$TD/proxy.sock"
@@ -1569,13 +1591,13 @@ EOF
@test "live end-to-end: claude -p through the real proxy (RUN_LIVE_API=1 only)" {
[ "${RUN_LIVE_API:-0}" = "1" ] || skip "set RUN_LIVE_API=1 to hit the real API"
TD="$BATS_TEST_TMPDIR"
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$HOME/.claude/.credentials.json" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$HOME/.claude/.credentials.json" \
"https://api.anthropic.com" \
"https://platform.claude.com/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$BATS_TEST_DIRNAME/../docker/anthropic-proxy-forwarder.js" &
node "$BATS_TEST_DIRNAME/../docker/credential-proxy-forwarder.js" &
FWD_PID=$!
sleep 0.5
run env ANTHROPIC_BASE_URL=http://127.0.0.1:18399 \
@@ -1664,10 +1686,9 @@ s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$TD/credentials.json" \
"http://127.0.0.1:$(cat "$TD/port")" \
"http://127.0.0.1:$(cat "$TD/port")/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# See the loopback test above for why this chmod is here.
chmod 666 "$TD/proxy.sock"
+60 -7
View File
@@ -3507,9 +3507,9 @@ registered_commands() {
}
@test "the forwarder is baked into the image and started by the entrypoint when a socket is set" {
grep -q 'COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js' "$SANDBOX_DIR/Dockerfile"
grep -q 'COPY docker/credential-proxy-forwarder.js /usr/local/bin/credential-proxy-forwarder.js' "$SANDBOX_DIR/Dockerfile"
grep -q 'if \[ -n "\${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" \]' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'gosu "\$SANDBOX_USER" node /usr/local/bin/anthropic-proxy-forwarder.js' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'gosu "\$SANDBOX_USER" node /usr/local/bin/credential-proxy-forwarder.js' "$SANDBOX_DIR/entrypoint.sh"
}
@test "entrypoint unsets empty ANTHROPIC_* vars so a disabled proxy changes nothing" {
@@ -4599,10 +4599,10 @@ EOF
# for how the model credential is kept out of the container.
local sec
sec="$(sed -n '/^## The model API credential/,/^## /p' "$REPO_ROOT/docs/security.md")"
grep -q 'dejima-anthropic-proxy' <<< "$sec" \
grep -q 'dejima-credential-proxy' <<< "$sec" \
|| { echo "the proxy is not documented under its own heading"; return 1; }
sec="$(sed -n '/^## The exit-time audit/,/^## /p' "$REPO_ROOT/docs/security.md")"
run grep -c 'dejima-anthropic-proxy' <<< "$sec"
run grep -c 'dejima-credential-proxy' <<< "$sec"
[ "$output" -eq 0 ] || { echo "the audit section has absorbed the proxy again"; return 1; }
}
@@ -5408,7 +5408,7 @@ EOF
}
# bin/dejima names docs/configuration.md in user-facing diagnostics and
# comments; bin/dejima, bin/dejima-anthropic-proxy and bin/dejima-audit all
# comments; bin/dejima, bin/dejima-credential-proxy and bin/dejima-audit all
# name docs/security.md the same way. Before the docs restructure these named
# the README, which cannot be renamed. Now they name files that can be. None
# of these are grep'd out of one canonical string the way docs/remote-access.md
@@ -5416,9 +5416,62 @@ 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 "docs/*.md references in bin/dejima, bin/dejima-anthropic-proxy and bin/dejima-audit resolve to real files" {
@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.
#
# 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; }
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; }
}
@test "the credential proxy names no provider constant anywhere in its code" {
# The whole of #171: a proxy that knows one provider's name has to grow a
# branch for every other. The credential KIND is allowed to say anthropic,
# since the OAuth file shape genuinely is Anthropic's, and so is the CLI
# that names the choices - what must not come back is a hostname, an
# endpoint or a header value baked into the request path.
local f="$REPO_ROOT/bin/dejima-credential-proxy" body
# The whole file, comments excluded. Argument parsing was excluded here at
# first, which let a default of --upstream https://api.anthropic.com back
# in without failing - and a default is exactly where this regresses,
# because it is the form that keeps working for the one provider while
# being wrong for every other. Comments stay excluded: this is about what
# the code does, not what it is allowed to explain.
body="$(grep -v '^[[:space:]]*#' "$f")"
local bad
for bad in 'api.anthropic.com' 'platform.claude.com' 'oauth-2025-04-20' \
'9d1c250a'; do
grep -q -- "$bad" <<< "$body" \
&& { echo "the proxy has learned a provider constant again: $bad"; return 1; }
done
return 0
}
@test "docs/*.md references in bin/dejima, bin/dejima-credential-proxy and bin/dejima-audit resolve to real files" {
local f doc missing=0
for f in bin/dejima bin/dejima-anthropic-proxy bin/dejima-audit; do
for f in bin/dejima bin/dejima-credential-proxy bin/dejima-audit; do
for doc in $(grep -ohE 'docs/[a-z-]+\.md' "$REPO_ROOT/$f" | sort -u); do
if [ ! -f "$REPO_ROOT/$doc" ]; then
echo "$f references $doc, which does not exist" >&2