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
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:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user