Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1374b21ea | ||
|
|
6a2b36740e | ||
|
|
bc994ccf80 | ||
|
|
5f2b51d2ce | ||
|
|
e8ea09a347 | ||
|
|
b232636960 | ||
|
|
839715605c | ||
|
|
19f5e6e452 | ||
|
|
b23ea0edb2 | ||
|
|
c83e54e35d | ||
|
|
f2cfd0d868 | ||
|
|
817959acb9 | ||
|
|
a133ba4b7e | ||
|
|
9db269d8ba | ||
|
|
17ef233229 | ||
|
|
3c68d868de | ||
|
|
851da62449 | ||
|
|
38cd96a06b | ||
|
|
0d8e241801 | ||
|
|
e44ddc01ff | ||
|
|
88bb0394d8 | ||
|
|
185c862559 | ||
|
|
309f2269c4 | ||
|
|
bc979ced2d | ||
|
|
540ee183ab | ||
|
|
c99dfcb7ec | ||
|
|
0e6b96963a | ||
|
|
2e0bc4153b | ||
|
|
59c27df8c4 | ||
|
|
0fceaaeeb3 | ||
|
|
3cdcd289e1 | ||
|
|
79fe71a14c | ||
|
|
3dd3022dd3 | ||
|
|
9a612f7b97 | ||
|
|
ad03d02f4a | ||
|
|
f638c40b76 | ||
|
|
ed57f646a3 | ||
|
|
90a264c63c | ||
|
|
53abc0ae21 | ||
|
|
52a7a21f8a | ||
|
|
549e5eb351 | ||
|
|
4e093f0c4a | ||
|
|
d1f8a54883 | ||
|
|
b71cd7617d | ||
|
|
f43ffdcf6a | ||
|
|
c03800e435 | ||
|
|
e73c6df114 | ||
|
|
3185d9fb5c | ||
|
|
b5ac1b3b46 | ||
|
|
ea3e9c09e8 | ||
|
|
d082e6a75c | ||
|
|
77b2d02866 | ||
|
|
0c2ceba35b | ||
|
|
4d1bb99180 | ||
|
|
4163ec12cd | ||
|
|
028fd8fb7f | ||
|
|
69713202ad | ||
|
|
decf60fa9e | ||
|
|
5c0f4b80c3 | ||
|
|
1587a154fd | ||
|
|
17602234de | ||
|
|
7585c4e8b6 | ||
|
|
a5b35c3750 | ||
|
|
4bdf3a7f14 | ||
|
|
6f9b93517e | ||
|
|
6bb5d0ef3c | ||
|
|
7b82328541 | ||
|
|
4c63db4891 | ||
|
|
8368ffb67a | ||
|
|
edc55d258f | ||
|
|
a41bae1b2f | ||
|
|
88de8b8670 | ||
|
|
baff452a22 | ||
|
|
aa78ff896f |
+32
-27
@@ -24,42 +24,47 @@ relaunch so the derived image is rebuilt), not installing it in-session.
|
||||
If it is missing and you cannot get a rebuild, say the suites were not run
|
||||
rather than reporting a change as tested.
|
||||
|
||||
## Run the suite for what you changed, not all of them
|
||||
## Two lanes: one for iterating, one for the pull request
|
||||
|
||||
The suites are independent and cover different areas. Running all of them for
|
||||
every edit is the single biggest drag on iteration here — reach for the whole
|
||||
set before opening a PR, not between edits.
|
||||
Between edits:
|
||||
|
||||
| You changed | Run |
|
||||
| --- | --- |
|
||||
| `bin/dejima-audit` | `test/audit.bats` |
|
||||
| `bin/dejima-config-lib.sh`, `install.sh` | `test/config.bats` |
|
||||
| `bin/dejima-provider-lib.sh`, `forges/`, `workflows/`, `harnesses/` | `test/providers.bats` |
|
||||
| `bin/dejima-attach`, `-ttyd`, `-notify-watch`, `web/`, `systemd/` | `test/remote.bats` |
|
||||
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
|
||||
| `bin/dejima-update-check` | `test/config.bats` |
|
||||
| `bin/dejima-anthropic-proxy`, `docker/anthropic-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) |
|
||||
```sh
|
||||
bash test/run-confined.sh --changed
|
||||
```
|
||||
|
||||
When the whole set genuinely is wanted, run it concurrently rather than one
|
||||
file after another:
|
||||
It reads `git diff` — committed, uncommitted and untracked — maps each path to
|
||||
the suites that cover it, and runs only those. `--list` prints the selection
|
||||
without running anything, and a path given as an argument stands in for one git
|
||||
would have reported, so `--changed --list docker/Dockerfile` answers "what would
|
||||
this change run?" directly.
|
||||
|
||||
Before opening a pull request, and for any claim that a change is tested:
|
||||
|
||||
```sh
|
||||
bash test/run-confined.sh
|
||||
```
|
||||
|
||||
The suites share no state, so they run together safely: about seven minutes
|
||||
instead of about twenty-five. It reports what each suite ran as well as what
|
||||
failed, because a suite that cannot start - a syntax error, or duplicate test
|
||||
names, which bats refuses outright - exits nonzero having run nothing, and
|
||||
that reads as "no failures" to anything counting only failures.
|
||||
That is the gate: every confined suite, no selection. Each run prints which lane
|
||||
it was, and `--changed` says in as many words that it is not the gate. **A green
|
||||
`--changed` run is not a tested change** — it is evidence about the suites it
|
||||
names and nothing about the ones it skipped.
|
||||
|
||||
Two things that are not in that table because they defeat it: `bin/dejima`
|
||||
itself and `bin/dejima-config-lib.sh` are read by nearly every other suite, so a
|
||||
change to either is one of the cases where the whole set genuinely applies. And
|
||||
a change to a shipped doc or a `docker/` file usually needs `static.bats` *as
|
||||
well as* whatever else it touched — that suite asserts on files it does not own.
|
||||
The map lives in `suites_for()` in `test/run-confined.sh` rather than in a table
|
||||
here, so that it runs instead of being remembered, and so an agent cannot map a
|
||||
change to the wrong suite by hand. A path matching no rule selects every suite:
|
||||
a path nobody has mapped is a path nobody has decided about. When you add a
|
||||
file that a specific suite covers, add the rule — `static.bats` checks the map
|
||||
names only suites that exist, but nothing can tell that a rule is too narrow.
|
||||
|
||||
Both lanes run the tests within each file concurrently as well as the files
|
||||
themselves, which is most of where the wall clock went. It is bats' own
|
||||
`--jobs`, and it needs `flock` rather than GNU parallel; without `flock` the
|
||||
script falls back to one at a time and says so.
|
||||
|
||||
Each run reports what every suite ran as well as what failed, because a suite
|
||||
that cannot start — a syntax error, or duplicate test names, which bats refuses
|
||||
outright — exits nonzero having run nothing, and that reads as "no failures" to
|
||||
anything counting only failures.
|
||||
|
||||
**CI runs `test/runtime.bats` on every pull request**, as a separate
|
||||
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
|
||||
|
||||
@@ -87,6 +87,7 @@ jobs:
|
||||
- config
|
||||
- providers
|
||||
- conversation
|
||||
- share
|
||||
name: confined (${{ matrix.suite }})
|
||||
container:
|
||||
# Debian bookworm plus a node binary - node is what actions/checkout
|
||||
@@ -163,9 +164,16 @@ jobs:
|
||||
# they fail on the terminal rather than on anything they mean to check.
|
||||
# xterm-256color ships in ncurses-base, which is Essential - nothing to
|
||||
# install. Set on the command rather than in env: so it survives su.
|
||||
#
|
||||
# --jobs runs the tests within the file concurrently. bats needs GNU
|
||||
# parallel only to parallelize ACROSS files; within one file it uses its
|
||||
# own flock-based semaphore, and flock is in util-linux, which Debian
|
||||
# marks Essential - so nothing here has to install anything for it.
|
||||
# --no-parallelize-across-files is what tells bats that, since it
|
||||
# otherwise refuses --jobs outright when GNU parallel is absent.
|
||||
- run: |
|
||||
chown -R node:node .
|
||||
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
|
||||
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
|
||||
|
||||
shellcheck:
|
||||
# Its own job rather than a matrix entry: it installs a different package,
|
||||
@@ -425,8 +433,18 @@ jobs:
|
||||
# SKIP_BUILD below then reuses this image rather than skipping a build.
|
||||
# The image is still built fresh from this commit, so the property the
|
||||
# test file defaults to - never assert against a stale image - holds.
|
||||
# Named under localhost/ so a stray push cannot resolve to Docker Hub,
|
||||
# like every other image this repo builds (#53).
|
||||
#
|
||||
# This step and runtime.bats must agree on that name, and they come from
|
||||
# different commits: this file is read from the BASE branch (see the
|
||||
# pull_request_target note at the top) while the checkout above is the
|
||||
# branch under test. So a rename here lands in two steps - the base
|
||||
# tagging both names first, then this - and a future one has to do the
|
||||
# same. A single pull request that changes both sides fails, because the
|
||||
# half that runs is the half it did not change.
|
||||
- name: Build the sandbox image
|
||||
run: docker build -t dejima:latest -f docker/Dockerfile .
|
||||
run: docker build -t localhost/dejima:latest -f docker/Dockerfile .
|
||||
|
||||
- run: bats test/runtime.bats
|
||||
env:
|
||||
|
||||
+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
|
||||
|
||||
+137
-26
@@ -446,13 +446,9 @@ if [ "$PRINT_CONFIG" = 1 ]; then
|
||||
# nothing would show this machine's own credentials as though they were
|
||||
# the identity's - the exact confusion the refusal exists to prevent, in
|
||||
# the one command an operator runs to check what they configured.
|
||||
_identity=""
|
||||
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
|
||||
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
|
||||
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
|
||||
echo "anything in $(config_path)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The forge is read here rather than taken from DEJIMA_FORGE_NAME, which
|
||||
# is resolved further down, past this early exit.
|
||||
identity_validate "$(config_get core forge)" || exit 1
|
||||
while IFS= read -r _key; do
|
||||
_section="${_key%.*}"
|
||||
_leaf="${_key##*.}"
|
||||
@@ -626,11 +622,18 @@ SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
# operator names in [mounts] readonly, which is computed at run time. Listed
|
||||
# whatever [epitaph] enabled says: turning the feature off stops new
|
||||
# epitaphs being written, it does not empty the store.
|
||||
#
|
||||
# The per-project share root is here because the ~/shared mount is only per
|
||||
# project while nothing else hands the whole of it over. A session's own share
|
||||
# arrives at ~/shared without going through this list; naming the root that
|
||||
# holds every project's would put the pile back, which is the thing the split
|
||||
# removed.
|
||||
GUARDED_STORES=()
|
||||
_guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
|
||||
"$(config_get epitaph dir)")
|
||||
"$(config_get epitaph dir)" \
|
||||
"$(share_projects_root "$(config_get core share_dir)")")
|
||||
}
|
||||
|
||||
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
|
||||
@@ -648,8 +651,9 @@ _refuse_guarded_mount() {
|
||||
_paths_overlap "$real" "$guarded_real" || continue
|
||||
echo "Refusing $kind: $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "Even read-only, that hands the session every project's approvals or the" >&2
|
||||
echo "record of what earlier sessions were caught doing." >&2
|
||||
echo "Even read-only, that hands the session every project's approvals, the" >&2
|
||||
echo "record of what earlier sessions were caught doing, or every project's" >&2
|
||||
echo "handoff files." >&2
|
||||
exit 1
|
||||
done
|
||||
}
|
||||
@@ -689,8 +693,9 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
echo "Refusing $(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path"): $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "A writable mount there would let a session approve its own domains and" >&2
|
||||
echo "dependencies, delete the audit findings it is named in, or plant code" >&2
|
||||
echo "that runs unsandboxed on the host the next time you start an agent." >&2
|
||||
echo "dependencies, delete the audit findings it is named in, read and rewrite" >&2
|
||||
echo "every project's handoff files, or plant code that runs unsandboxed on the" >&2
|
||||
echo "host the next time you start an agent." >&2
|
||||
exit 1
|
||||
done
|
||||
# A path in both lists would be resolved by whichever mount Docker
|
||||
@@ -825,8 +830,10 @@ fi
|
||||
# confirm" is not a reason to allow it.
|
||||
DEJIMA_FORGE_REPO_FLAGS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_flags)"
|
||||
DEJIMA_FORGE_REPO_ENV="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_env)"
|
||||
DEJIMA_FORGE_REPO_PATHS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_paths)"
|
||||
DEJIMA_FORGE_REPO=""
|
||||
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ]; then
|
||||
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ] ||
|
||||
[ -n "$DEJIMA_FORGE_REPO_PATHS" ]; then
|
||||
_origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
|
||||
if [ -n "$_origin_url" ]; then
|
||||
_origin_url="${_origin_url%/}"
|
||||
@@ -976,9 +983,35 @@ fi
|
||||
# touching the actual project checkout. Persists across sessions (it's a
|
||||
# real host directory, not a container volume) since the point is sharing
|
||||
# things with the host, not just scratch space for one run.
|
||||
SANDBOX_SHARE_DIR="$(config_get core share_dir)"
|
||||
#
|
||||
# One directory per project, not one for the host. `[core] share_dir` was
|
||||
# mounted whole, so every session saw the handoff files of every session
|
||||
# before it and of every session running beside it, across projects - the same
|
||||
# boundary ~/.claude is already sliced per project to hold. What the container
|
||||
# sees is still ~/shared, so nothing an agent has been told changes; what it
|
||||
# cannot see any more is another project's.
|
||||
SANDBOX_SHARE_ROOT="$(config_get core share_dir)"
|
||||
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
|
||||
mkdir -p "$SANDBOX_SHARE_DIR"
|
||||
|
||||
# Files an operator left in share_dir before the split are still there and are
|
||||
# no longer mounted anywhere. Said every launch while that is true, because
|
||||
# the alternative is a file that was handed to a session yesterday silently
|
||||
# not arriving today.
|
||||
share_loose="$(find "$SANDBOX_SHARE_ROOT" -mindepth 1 -maxdepth 1 \
|
||||
! -name projects -printf '%f\n' 2>/dev/null | sort)" || true
|
||||
if [ -n "$share_loose" ]; then
|
||||
echo "Note: $SANDBOX_SHARE_ROOT holds entries no session can see any more," >&2
|
||||
echo " because ~/shared is now this project's own subdirectory of it:" >&2
|
||||
while IFS= read -r _entry; do
|
||||
[ -n "$_entry" ] || continue
|
||||
printf ' %s\n' "$_entry" >&2
|
||||
done <<< "$share_loose"
|
||||
echo " Move them into a project's share with 'dejima-share adopt <path>'," >&2
|
||||
echo " or delete them. 'dejima-share list' shows what each project holds." >&2
|
||||
fi
|
||||
unset share_loose _entry
|
||||
|
||||
# ~/.claude is not mounted as a whole (see the mount inventory in
|
||||
# docker-compose.yml) - every path that crosses into the container is named
|
||||
# individually, and Docker answers a missing bind source by creating a
|
||||
@@ -1425,6 +1458,7 @@ extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
|
||||
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
|
||||
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
|
||||
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
|
||||
extra_mounts+=(-e "DEJIMA_FORGE_REPO_PATHS=$DEJIMA_FORGE_REPO_PATHS")
|
||||
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
|
||||
|
||||
# The harness's own configuration, mounted at the mode it declared, and -
|
||||
@@ -1617,7 +1651,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).
|
||||
#
|
||||
@@ -1644,11 +1678,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
|
||||
@@ -1870,6 +1916,59 @@ fi
|
||||
# or one after a Dockerfile change, builds the image and takes minutes rather
|
||||
# than seconds - the line names that, since it is the case where someone is
|
||||
# most likely to give up and interrupt.
|
||||
# What this launch is, before the long silent part. The startup output is
|
||||
# otherwise a list of checks with no point at which it says what it is, and
|
||||
# the facts below are the ones that differ between one launch and the next -
|
||||
# the ones an operator otherwise has to remember or go and read (#211).
|
||||
#
|
||||
# Deliberately not a config dump. The value is seeing at a glance that this
|
||||
# launch is not configured like the last one, so what is here is the selection
|
||||
# plus the switches whose state changes what a session can do.
|
||||
#
|
||||
# In bin/dejima's own palette rather than the pickers': dejima-color-lib.sh
|
||||
# and dejima-tui-lib.sh both define C_OK and C_WARN, with different values, so
|
||||
# sourcing the second here would silently recolour every warning this script
|
||||
# already prints. Unifying the two palettes is worth doing and is not this.
|
||||
startup_banner() {
|
||||
[ "$(config_get_bool ui banner)" = "1" ] || return 0
|
||||
# Nothing at all when stderr is not a terminal: a redirected launch is a
|
||||
# log, and this is decoration in it.
|
||||
[ -t 2 ] || return 0
|
||||
|
||||
local branch model domains firewall remote epitaph
|
||||
branch="$(git -C "$WORKSPACE_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null)" || branch=""
|
||||
model="$(harness_config_get model 2>/dev/null)" || model=""
|
||||
# The count, not the list: the list is long and the number is what changes.
|
||||
domains="$(printf '%s' "${ALLOWED_DOMAINS:-}" | wc -w)"
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
firewall="${C_OK}on${C_RESET} ($domains domains)"
|
||||
else
|
||||
# The one switch worth a colour of its own: everything else here is a
|
||||
# choice, and this one is a boundary that is not in force.
|
||||
firewall="${C_WARN}off${C_RESET}"
|
||||
fi
|
||||
remote="$(remote_enabled && echo on || echo off)"
|
||||
epitaph="$([ "$(config_get_bool epitaph enabled)" = 1 ] && echo on || echo off)"
|
||||
|
||||
# Labels dim, values not. The labels are how you find the value you came
|
||||
# for; the values are what differs between one launch and the next.
|
||||
# C_BADGE, the same name the pickers set the mark in. Two surfaces
|
||||
# spelling the wordmark two different colours is the drift a shared
|
||||
# palette exists to stop.
|
||||
printf '\n %sdejima%s %s%s%s' \
|
||||
"$C_BADGE" "$C_RESET" "$C_KEY" "$(basename -- "$WORKSPACE_DIR")" "$C_RESET" >&2
|
||||
[ -z "$branch" ] || printf ' %s%s%s %s' "$C_DIM" "$GLYPH_SEP" "$C_RESET" "$branch" >&2
|
||||
printf '\n %sharness%s %s %sforge%s %s %sworkflow%s %s\n' \
|
||||
"$C_DIM" "$C_RESET" "$DEJIMA_HARNESS_NAME${model:+ ($model)}" \
|
||||
"$C_DIM" "$C_RESET" "$DEJIMA_FORGE_NAME" \
|
||||
"$C_DIM" "$C_RESET" "$DEJIMA_WORKFLOW_NAME" >&2
|
||||
printf ' %sfirewall%s %s %sremote%s %s %sepitaph%s %s\n\n' \
|
||||
"$C_DIM" "$C_RESET" "$firewall" \
|
||||
"$C_DIM" "$C_RESET" "$remote" \
|
||||
"$C_DIM" "$C_RESET" "$epitaph" >&2
|
||||
}
|
||||
startup_banner
|
||||
|
||||
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
||||
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
|
||||
"$C_DIM" "$C_RESET" >&2
|
||||
@@ -1909,17 +2008,29 @@ fi
|
||||
# below will report any change to it.
|
||||
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
|
||||
echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
|
||||
echo "computing base (bin/, hooks/, docker/, install.sh) is writable this" >&2
|
||||
echo "session; the exit audit will report any changes to review." >&2
|
||||
echo "computing base (bin/, hooks/, docker/, test/, install.sh, and the" >&2
|
||||
echo "provider and tool manifests) is writable this session; the exit audit" >&2
|
||||
echo "will report any changes to review." >&2
|
||||
fi
|
||||
|
||||
# Every image this builds is named under localhost/, which Docker reads as a
|
||||
# registry host rather than as the first path component of a Docker Hub name
|
||||
# (#53). A bare `dejima:node` resolves to `docker.io/library/dejima:node` the
|
||||
# moment anyone pushes it, and none of these images is meant to leave the host
|
||||
# that built them. Pulling the base still goes to Hub, deliberately: that is
|
||||
# where `node:22` is.
|
||||
#
|
||||
# One prefix, used by every tag below and by the compose file's default, so
|
||||
# there is no second place for a bare name to reappear.
|
||||
SANDBOX_IMAGE_REPO="localhost/dejima"
|
||||
|
||||
# Compose builds the shared base image under its own tag, explicitly - with
|
||||
# image: now honoring $SANDBOX_IMAGE, letting the derived tag leak into this
|
||||
# build would rebuild the base Dockerfile under the derived name.
|
||||
SANDBOX_IMAGE="dejima:${SANDBOX_USER}" \
|
||||
SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" \
|
||||
docker compose -f "$COMPOSE_FILE" build sandbox
|
||||
|
||||
SANDBOX_IMAGE="dejima:${SANDBOX_USER}"
|
||||
SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}"
|
||||
|
||||
# Each layer builds FROM whatever the previous one left in $SANDBOX_IMAGE, so
|
||||
# global packages are shared by every project instead of being rebuilt into
|
||||
@@ -1978,7 +2089,7 @@ fi
|
||||
|
||||
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
||||
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ]; then
|
||||
tag="dejima:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
|
||||
tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
|
||||
generate_spec_dockerfile "$SANDBOX_IMAGE" "$SANDBOX_HARNESS_INSTALL" \
|
||||
"$SANDBOX_HARNESS_IMAGE_DIRS" "$SANDBOX_HARNESS_IMAGE_ENV" \
|
||||
"$SANDBOX_HARNESS_IMAGE_FILES" \
|
||||
@@ -2006,7 +2117,7 @@ for _tool in $DEJIMA_TOOLS; do
|
||||
_tool_install="$(manifest_get tool "$_tool" install)"
|
||||
_tool_env="$(manifest_get tool "$_tool" image_env)"
|
||||
_tool_layout="$(manifest_get tool "$_tool" install_layout)"
|
||||
tag="dejima:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
|
||||
tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
|
||||
DEJIMA_TARBALL_STRIP="$_tool_layout" \
|
||||
generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
|
||||
> "$SANDBOX_DERIVED_DOCKERFILE"
|
||||
@@ -2024,10 +2135,10 @@ done
|
||||
unset _tool _tool_install _tool_env _tool_layout
|
||||
|
||||
if [ -s "$(config_root)/dependencies" ]; then
|
||||
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
|
||||
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-global" "$(config_root)"
|
||||
fi
|
||||
if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
|
||||
build_dep_layer "dejima:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
|
||||
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
|
||||
fi
|
||||
export SANDBOX_IMAGE
|
||||
|
||||
|
||||
+93
-13
@@ -29,11 +29,14 @@ Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
||||
|
||||
Scope, which decides who the grant is for and how long it lasts:
|
||||
(none) the running container, right now, until it exits. Finds it
|
||||
if exactly one is running; refuses if none or several.
|
||||
if exactly one is running. With several, it offers them to
|
||||
pick from on a terminal, and lists them and stops anywhere
|
||||
else - a prompt is no use to a script.
|
||||
--container <id> the same, for a named container. Use this when a session
|
||||
told you its own id rather than letting anything guess.
|
||||
With several running, they are listed by project and by
|
||||
the number the dejima-attach picker gives each one.
|
||||
the number the dejima-attach picker gives each one. On a
|
||||
terminal it offers them to pick from instead.
|
||||
--project [path] every future session in one project. Also writes the domain
|
||||
to that project's .dejima/allowed-domains, so it is shared
|
||||
with everyone working on it. Path defaults to $PWD.
|
||||
@@ -63,6 +66,11 @@ source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
# reason init-firewall.sh and dns-proxy.sh share this file.
|
||||
# shellcheck source=../docker/fixed-domains.sh
|
||||
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
# The palette, heading and prompt dejima-attach uses. Picking one session out
|
||||
# of several is the same job here, and an operator holding one terminal should
|
||||
# not meet two different answers to it.
|
||||
# shellcheck source=dejima-tui-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
||||
@@ -146,7 +154,15 @@ fi
|
||||
#
|
||||
# Reads only. Nothing here decides which container is acted on - that is the
|
||||
# caller's --container, or the single running one.
|
||||
describe_containers() {
|
||||
# Fills CT_IDS, CT_NAMES, CT_PROJECTS and CT_ATTACH, in the order docker
|
||||
# listed them, and measures the widest project and name for the renderers
|
||||
# below. Padding to the widest value rather than to a guess is not cosmetic
|
||||
# here: "dejima-sandbox-run-" plus twelve hex digits is 31 characters, so a
|
||||
# fixed 28 ran every name straight into the project beside it and there was
|
||||
# no telling where one ended.
|
||||
CT_IDS=(); CT_NAMES=(); CT_PROJECTS=(); CT_ATTACH=()
|
||||
CT_W_NAME=0; CT_W_PROJECT=0; CT_HAS_ATTACH=0
|
||||
collect_containers() {
|
||||
local listing="$1" name id project row n=0 idx
|
||||
local -A attach_index=()
|
||||
while IFS=$'\t' read -r _ _ _ _ _ row; do
|
||||
@@ -158,12 +174,61 @@ describe_containers() {
|
||||
[ -n "$name" ] || continue
|
||||
project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
|
||||
[ -n "$project" ] && project="$(basename "$project")"
|
||||
project="${project:-?}"
|
||||
idx="${attach_index[${id:0:12}]:-}"
|
||||
printf ' %-28s %-24s %s\n' "$name" "${project:-?}" \
|
||||
"${idx:+(dejima-attach $idx)}"
|
||||
CT_IDS+=("$id"); CT_NAMES+=("$name"); CT_PROJECTS+=("$project")
|
||||
CT_ATTACH+=("$idx")
|
||||
[ -z "$idx" ] || CT_HAS_ATTACH=1
|
||||
[ "${#name}" -le "$CT_W_NAME" ] || CT_W_NAME="${#name}"
|
||||
[ "${#project}" -le "$CT_W_PROJECT" ] || CT_W_PROJECT="${#project}"
|
||||
done <<< "$listing"
|
||||
}
|
||||
|
||||
# The non-interactive form: a list to read, with the flag that acts on a row.
|
||||
# This is what a script gets, and what an agent is shown when it asks the
|
||||
# operator to run one of these.
|
||||
describe_containers() {
|
||||
local i
|
||||
for i in "${!CT_IDS[@]}"; do
|
||||
printf ' %-*s %-*s %s\n' \
|
||||
"$CT_W_PROJECT" "${CT_PROJECTS[$i]}" \
|
||||
"$CT_W_NAME" "${CT_NAMES[$i]}" \
|
||||
"${CT_ATTACH[$i]:+(dejima-attach ${CT_ATTACH[$i]})}"
|
||||
done
|
||||
}
|
||||
|
||||
# The interactive form. Same rows, numbered, under dejima-attach's heading -
|
||||
# an operator with several sessions open is choosing between the same things
|
||||
# in both tools, and picking by project is the only way they can.
|
||||
#
|
||||
# Headed, where dejima-attach's rows are not: there the coloured state word
|
||||
# says what each column is, and vertical space on a phone is the scarce
|
||||
# thing. Here the columns are two names and a number, which say nothing about
|
||||
# themselves.
|
||||
pick_container() {
|
||||
local i
|
||||
tui_banner 'running sessions'
|
||||
# The attach column is absent, not blank, when no session has a number -
|
||||
# remote access off is the ordinary case, and a labelled empty column
|
||||
# reads as something that failed.
|
||||
if [ "$CT_HAS_ATTACH" -eq 1 ]; then
|
||||
printf ' %s%-*s %-*s %s%s\n' "$C_SUBTLE" \
|
||||
"$CT_W_PROJECT" "project" "$CT_W_NAME" "container" "attach" "$C_OFF"
|
||||
else
|
||||
printf ' %s%-*s %s%s\n' "$C_SUBTLE" \
|
||||
"$CT_W_PROJECT" "project" "container" "$C_OFF"
|
||||
fi
|
||||
for i in "${!CT_IDS[@]}"; do
|
||||
printf ' %s%2d%s %s%-*s%s %s%-*s%s %s%s%s\n' \
|
||||
"$C_WARN" "$((i + 1))" "$C_OFF" \
|
||||
"$C_BOLD$C_TEXT" "$CT_W_PROJECT" "${CT_PROJECTS[$i]}" "$C_OFF" \
|
||||
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
|
||||
done
|
||||
tui_pick "${#CT_IDS[@]}" "allow $domain in"
|
||||
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
|
||||
}
|
||||
|
||||
session_wanted=1
|
||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||
[ -n "$container" ] || session_wanted=0
|
||||
@@ -177,15 +242,30 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
echo "No running dejima container found." >&2
|
||||
exit 1
|
||||
elif [ "$count" -gt 1 ]; then
|
||||
echo "Multiple running dejima containers found; specify one with --container:" >&2
|
||||
echo "" >&2
|
||||
describe_containers "$containers" >&2
|
||||
echo "" >&2
|
||||
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
|
||||
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
|
||||
exit 1
|
||||
collect_containers "$containers"
|
||||
# A person at a terminal gets to choose; anything else gets the
|
||||
# list and the flag. Both ends are checked because the prompt is
|
||||
# useless without somewhere to draw it and something to read the
|
||||
# answer from, and half of either is how a script ends up hung on
|
||||
# a read nobody can answer.
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
pick_container
|
||||
else
|
||||
echo "Multiple running dejima containers found; specify one with --container:" >&2
|
||||
echo "" >&2
|
||||
describe_containers >&2
|
||||
echo "" >&2
|
||||
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
|
||||
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
container="${containers%%$'\t'*}"
|
||||
# Only when nothing above chose one. This line used to be reachable
|
||||
# only in the single-container case, because the multi-container
|
||||
# branch exited - now that the picker returns from it, an
|
||||
# unconditional assignment here would silently discard the choice and
|
||||
# act on whichever session docker happened to list first.
|
||||
[ -n "$container" ] || container="${containers%%$'\t'*}"
|
||||
fi
|
||||
|
||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||
|
||||
+12
-76
@@ -57,55 +57,20 @@ SESSIONS_DIR="$STATE_DIR/sessions"
|
||||
|
||||
# gruvbox dark, every value upstream's and tagged with its color.table row:
|
||||
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
|
||||
# The names are carried so a wrong hex is catchable by reading.
|
||||
#
|
||||
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
|
||||
# 38;2 to its nearest colour rather than printing the escape, so the fallback
|
||||
# already exists one layer down.
|
||||
#
|
||||
# Empty unless stdout is a colour-capable terminal, which keeps --list a
|
||||
# machine interface (ttyd feeds run ids back in) and is also how the bats
|
||||
# suite reads this script.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
|
||||
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
|
||||
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
|
||||
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
|
||||
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
|
||||
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
|
||||
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
|
||||
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
|
||||
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
|
||||
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
|
||||
GLYPH_BAR='▏'; GLYPH_DOT='●'
|
||||
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
|
||||
# would shred a 3-byte box-drawing character, and every alternative costs
|
||||
# a loop or a subshell to draw a fixed line. Both are the width of the
|
||||
# heading they sit under ("dejima | live sessions"), and are next to it so
|
||||
# they stay that way.
|
||||
RULE_LINE='──────────────────────'
|
||||
else
|
||||
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
|
||||
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
|
||||
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
|
||||
# because no-colour implies no-UTF-8, but because the two terminals this
|
||||
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
|
||||
# are exactly the ones that mangle box drawing, and one flag is easier to
|
||||
# reason about than two.
|
||||
GLYPH_BAR='|'; GLYPH_DOT='*'
|
||||
RULE_LINE='----------------------'
|
||||
fi
|
||||
# The palette, the heading and the prompt come from the shared library: this
|
||||
# and dejima-allow both ask an operator to pick a session, and two copies of
|
||||
# that would drift apart on the terminal an operator is actually holding.
|
||||
# shellcheck source=dejima-tui-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||
|
||||
# Narrow deliberately. The phone is this script's main front door, and the
|
||||
# rule underlines the heading instead of spanning the table because the
|
||||
# table's width follows the longest project name - a rule that chased it would
|
||||
# wrap on a phone exactly when the rows did.
|
||||
banner() {
|
||||
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
|
||||
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
|
||||
"$C_MUTED" "$C_OFF"
|
||||
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
|
||||
}
|
||||
# Cleared first: this is a front door, and it is navigated to rather than
|
||||
# scrolled to. dejima-allow's picker shares the banner and deliberately not
|
||||
# this - see tui_clear.
|
||||
banner() { tui_clear; tui_banner 'live sessions'; }
|
||||
|
||||
# Coloured by meaning, not by name: attachable is the one an operator wants to
|
||||
# find, and stale/no-tmux are the two that will refuse if picked.
|
||||
@@ -263,7 +228,7 @@ list_sessions() {
|
||||
# ever race it. Removing it under a live session is not a leak
|
||||
# being tidied but a session being broken - Docker recreates the
|
||||
# bind source root-owned, hooks/dejima-notify.sh then fails on
|
||||
# every Notification and Stop for the rest of the run, and nothing
|
||||
# every notification for the rest of the run, and nothing
|
||||
# rewrites the registry entry to make the session visible again.
|
||||
continue
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
@@ -438,37 +403,8 @@ picker() {
|
||||
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
||||
done
|
||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits
|
||||
# far better than arrow keys, and this is the phone's entry point.
|
||||
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
|
||||
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
|
||||
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
|
||||
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
|
||||
# returns nonzero immediately, and exiting 1 with nothing printed reads as
|
||||
# a broken front door. attach() reasons about the same case explicitly.
|
||||
read -r choice || exit 0
|
||||
case "$choice" in
|
||||
q|Q|'') exit 0 ;;
|
||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||
esac
|
||||
# Normalized to base 10 once, here, because the range check and the
|
||||
# subscript below do not read a number the same way: `[` is POSIX test and
|
||||
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
|
||||
# "010" - which a phone keypad makes easy to type - passes the check as 10
|
||||
# and indexes as 8, and the caller is attached to a DIFFERENT session than
|
||||
# the one they picked, with nothing printed to say so.
|
||||
#
|
||||
# It fixes the two ugly shapes as well. "09" is a value here rather than
|
||||
# the "invalid octal constant" arithmetic error it is to $(( )); and a
|
||||
# number past int64 wraps to one the range check can answer for, where
|
||||
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
|
||||
# expression expected" - which makes the `if` false, so "Out of range."
|
||||
# is never printed and the subscript below dies on an unbound element.
|
||||
choice="$((10#$choice))"
|
||||
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
|
||||
echo "Out of range." >&2
|
||||
exit 1
|
||||
fi
|
||||
tui_pick "${#ids[@]}" 'attach to'
|
||||
choice="$TUI_CHOICE"
|
||||
attach "${ids[$((choice - 1))]}"
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -966,7 +966,11 @@ tcb_snapshot() {
|
||||
# here. Both decide what the next launch permits, which is what the
|
||||
# TCB list is for. A visible edit stays a normal reviewable diff - the
|
||||
# split above only reports what git cannot see.
|
||||
for dir in bin hooks docker test forges workflows harnesses; do
|
||||
# tools/ for the same reason as the provider directories, and it was
|
||||
# missed when that layer landed: a tool.conf names a tarball URL and
|
||||
# the sha256 checked against it, and the host's image build installs
|
||||
# what it points at as root.
|
||||
for dir in bin hooks docker test forges workflows harnesses tools; do
|
||||
if [ -d "$ws/$dir" ]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
stage_entry "tcb:${f#"$ws"/}" "$f"
|
||||
|
||||
+60
-9
@@ -44,25 +44,76 @@ sanitize_body() {
|
||||
LC_ALL=C tr -d '\000-\010\013-\037\177' | _sanitize_c1_bidi
|
||||
}
|
||||
|
||||
# One palette, and it is the terminal's own.
|
||||
#
|
||||
# gruvbox is dejima's brand, and the values below ARE gruvbox on a gruvbox
|
||||
# terminal - every colour here is an ANSI slot that a gruvbox theme fills with
|
||||
# the exact hex the marks use (bright green #b8bb26, bright yellow #fabd2f,
|
||||
# bright red #fb4934, bright blue #83a598, bright black #928374). What it does
|
||||
# not do is hardcode them.
|
||||
#
|
||||
# It used to, in dejima-tui-lib.sh, and that broke on any terminal whose
|
||||
# background dejima does not control. Measured against a light background:
|
||||
# light1 #ebdbb2 - the colour the session picker drew project names in - came
|
||||
# out at 1.21:1, bright yellow at 1.49:1, bright green at 1.82:1. Nothing in
|
||||
# the palette cleared 3:1 on both a dark and a light background, because a
|
||||
# palette drawn for #282828 cannot.
|
||||
#
|
||||
# An indexed colour cannot have that problem: the theme's author chose it
|
||||
# against their own background. So dejima gets its brand on the terminals set
|
||||
# up for it, and legibility on the rest, and those are the same decision.
|
||||
#
|
||||
# The one place dejima keeps exact hexes is web/src/page.css, where it sets
|
||||
# the background itself and so owns both sides of the contrast.
|
||||
#
|
||||
# Empty unless the fd is a colour-capable terminal, which keeps machine
|
||||
# interfaces (dejima-attach --list, which ttyd feeds run ids back from)
|
||||
# parseable and is how the bats suite reads these scripts.
|
||||
color_init() {
|
||||
local fd="${1:-1}"
|
||||
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
|
||||
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
|
||||
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
|
||||
C_RULE=""; C_ERR=""; C_LINK=""
|
||||
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
|
||||
# because no-colour implies no-UTF-8, but because the two terminals this
|
||||
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
|
||||
# are exactly the ones that mangle box drawing, and one flag is easier to
|
||||
# reason about than two.
|
||||
GLYPH_SEP='|'; GLYPH_BAR='|'; GLYPH_DOT='*'; GLYPH_RULE='-'
|
||||
[ -t "$fd" ] || return 0
|
||||
[ -z "${NO_COLOR+x}" ] || return 0
|
||||
case "${TERM:-}" in ''|dumb) return 0 ;; esac
|
||||
|
||||
C_RESET=$'\033[0m'
|
||||
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
|
||||
C_WARN=$'\033[33m' # yellow
|
||||
C_INFO=$'\033[36m' # cyan
|
||||
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
|
||||
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
|
||||
C_DIM=$'\033[2m' # dim - the audit's own framing around content
|
||||
C_ADD=$'\033[32m' # green - a diff's added lines
|
||||
C_DEL=$'\033[31m' # red - a diff's removed lines
|
||||
C_OK=$'\033[32m' # green
|
||||
C_OFF="$C_RESET" # the pickers' name for it
|
||||
C_BOLD=$'\033[1m'
|
||||
C_KEY="$C_BOLD" # bold - the keystroke an operator has to press
|
||||
# Default foreground, deliberately empty. This was light1 #ebdbb2, which
|
||||
# is gruvbox's own foreground - so on a gruvbox terminal it is what the
|
||||
# terminal already draws with, and on a light one it was invisible.
|
||||
C_TEXT=""
|
||||
C_DIM=$'\033[2m'
|
||||
C_SUBTLE="$C_DIM" # dark4 receded; dim recedes against any background
|
||||
C_RULE="$C_DIM" # dark2, ditto
|
||||
C_MUTED=$'\033[90m' # bright black - gruvbox gray_245 #928374
|
||||
C_OK=$'\033[92m' # bright green - gruvbox #b8bb26
|
||||
C_ADD="$C_OK"
|
||||
C_WARN=$'\033[93m' # bright yellow - gruvbox #fabd2f
|
||||
C_ERR=$'\033[91m' # bright red - gruvbox #fb4934
|
||||
C_DEL="$C_ERR"
|
||||
C_CRIT=$'\033[1;91m' # bold bright red - runs unsandboxed host code
|
||||
C_INFO=$'\033[96m' # bright cyan - gruvbox aqua #8ec07c
|
||||
C_LINK=$'\033[94m' # bright blue - gruvbox #83a598
|
||||
C_PATH=$'\033[1;96m' # bold - the file or key a finding is about
|
||||
# No ANSI slot is orange, and gruvbox's #fe8019 is the wordmark's colour.
|
||||
# Bold bright yellow is the nearest thing every terminal has, and it is
|
||||
# the accent rather than the brand exactly.
|
||||
C_BADGE=$'\033[1;93m'
|
||||
GLYPH_SEP='▏'; GLYPH_BAR='▏'; GLYPH_DOT='●'; GLYPH_RULE='─'
|
||||
}
|
||||
|
||||
|
||||
# The colour for a tier name, so the mapping lives in one place rather than
|
||||
# at each site that prints one.
|
||||
tier_color() {
|
||||
|
||||
@@ -45,6 +45,7 @@ core.command ?identifier
|
||||
core.identity ?identifier
|
||||
core.tools ?list:identifier
|
||||
core.firewall bool on
|
||||
ui.banner bool on
|
||||
core.update_check bool on
|
||||
core.share_dir path ~/.local/share/dejima/shared
|
||||
core.ssh_key ?path
|
||||
@@ -418,7 +419,7 @@ _config_extra_section_known() { return 1; }
|
||||
|
||||
_config_section_known() {
|
||||
case "$1" in
|
||||
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
|
||||
core|github|mounts|credentials|logging|audit|remote-access|epitaph|ui) return 0 ;;
|
||||
*) _config_extra_section_known "$1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -526,7 +527,19 @@ _config_parse() {
|
||||
return 1
|
||||
fi
|
||||
if ! _config_valid_value "$type" "$value"; then
|
||||
echo "invalid value for [$section] $key in $conf: $value" >&2
|
||||
# The value is echoed so the operator can see what was wrong with
|
||||
# it, except for the userinfo of a URL. A url-typed value is the
|
||||
# one kind here that carries a credential - ntfy takes its
|
||||
# authentication as https://<user>:<token>@<host>/<topic> - and a
|
||||
# value only reaches this line by being malformed, so the mistake
|
||||
# would be what wrote the token into a terminal or a journal.
|
||||
# Everything after the @ is kept: the host and path are what make
|
||||
# the message useful.
|
||||
local shown="$value"
|
||||
case "$type:$value" in
|
||||
*url:*://*@*) shown="${value%%://*}://<redacted>@${value#*@}" ;;
|
||||
esac
|
||||
echo "invalid value for [$section] $key in $conf: $shown" >&2
|
||||
return 1
|
||||
fi
|
||||
# Last assignment wins, which is why this overwrites rather than
|
||||
|
||||
@@ -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:
|
||||
+46
-2
@@ -83,11 +83,36 @@ fi
|
||||
# Refusing rather than sanitizing, because the config is operator-written:
|
||||
# a value that can't be used as intended means they got something wrong, and
|
||||
# this is the one place they say where their phone notifications go.
|
||||
#
|
||||
# The value is not echoed back whole. An ntfy server that wants credentials
|
||||
# takes them in the URL - https://<user>:<token>@<host>/<topic>, which
|
||||
# docs/remote-access.md documents - and a misconfiguration should not be the
|
||||
# thing that writes that token into the journal, where it stays.
|
||||
redacted_url() {
|
||||
case "$1" in
|
||||
*://*@*) printf '%s://<redacted>@%s' "${1%%://*}" "${1#*@}" ;;
|
||||
*) printf '%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_url() {
|
||||
case "$2" in
|
||||
http://*|https://*) ;;
|
||||
*)
|
||||
echo "$1 in $(config_path) must be an http:// or https:// URL: $2" >&2
|
||||
echo "$1 in $(config_path) must be an http:// or https:// URL: $(redacted_url "$2")" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# The URL is handed to curl through a config file on stdin rather than as
|
||||
# an argument (see the push below), and that format is line-oriented with
|
||||
# its own quoting: a newline in the value would start a second directive,
|
||||
# and a quote or a backslash would end or escape the string. The `url`
|
||||
# config type's own character set already excludes all three, so this
|
||||
# refuses a value that arrived some other way rather than sanitizing one
|
||||
# that did - the same reading as the scheme check above.
|
||||
case "$2" in
|
||||
*[!A-Za-z0-9:/._~?=%@+-]*)
|
||||
echo "$1 in $(config_path) has a character dejima will not send: $(redacted_url "$2")" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -168,12 +193,31 @@ push() {
|
||||
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
|
||||
# -sS exists precisely to keep transport errors on stderr, and under a
|
||||
# systemd user unit that is the journal, which is where they belong.
|
||||
#
|
||||
# The URL goes in on stdin, not in argv. It is the one value here that can
|
||||
# carry a credential, and every argument of every process on this host is
|
||||
# readable by any local account through /proc/<pid>/cmdline - so a token in
|
||||
# argv is a token published to the machine for as long as the request runs,
|
||||
# and `ps` is enough to read it. Nothing else on this line is secret: the
|
||||
# title, the body and the click URL are all composed from host data.
|
||||
#
|
||||
# This also retires the `-K/path` hazard the scheme check above was half
|
||||
# of: a URL that is not a URL can no longer be read as an option, because
|
||||
# it is no longer in the option list at all. Both checks stay anyway, and
|
||||
# the second one is now what keeps this format from gaining a directive.
|
||||
# Redirected from a process substitution rather than piped into. `printf |
|
||||
# curl` is a pipeline under `set -o pipefail`, so a curl that exits before
|
||||
# draining stdin leaves printf holding a closed pipe and the pipeline
|
||||
# reports 141 - a "push failed" line for a push that succeeded. A
|
||||
# redirection is not a pipeline and has no such status to inherit. It also
|
||||
# keeps `--config -` in argv rather than a /dev/fd path, which is a fixed
|
||||
# string a test can compare.
|
||||
curl -fsS --max-time 10 --retry 2 \
|
||||
-H "Title: $title" \
|
||||
-H "Priority: default" \
|
||||
"${click[@]}" \
|
||||
-d "$body" \
|
||||
-- "$NTFY_URL" >/dev/null \
|
||||
--config - >/dev/null < <(printf 'url = "%s"\n' "$NTFY_URL") \
|
||||
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
|
||||
}
|
||||
|
||||
|
||||
+306
-54
@@ -28,31 +28,48 @@ MAX_MANIFEST_LINES=200
|
||||
# filling this on first use meant filling it again in every `$(...)` that
|
||||
# reached it, which was a fork apiece and hundreds of them per launch.
|
||||
_PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
|
||||
provider_root() {
|
||||
# Assigns rather than prints, so a caller reaching it in a loop does not pay a
|
||||
# `$( )` fork per call. provider_root below is the printing form for everyone
|
||||
# who wants a value in a substitution anyway.
|
||||
_provider_root_into() {
|
||||
if [ -n "${DEJIMA_PROVIDER_ROOT:-}" ]; then
|
||||
printf '%s' "$DEJIMA_PROVIDER_ROOT"
|
||||
printf -v "$1" '%s' "$DEJIMA_PROVIDER_ROOT"
|
||||
return 0
|
||||
fi
|
||||
# Only the repo path is cached. The test hook is re-read every call, so a
|
||||
# test that repoints it mid-process still gets the directory it just set.
|
||||
[ -n "$_PROVIDER_ROOT_CACHE" ] || _PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
|
||||
printf '%s' "$_PROVIDER_ROOT_CACHE"
|
||||
printf -v "$1" '%s' "$_PROVIDER_ROOT_CACHE"
|
||||
}
|
||||
|
||||
_provider_axis_dir() {
|
||||
case "$1" in
|
||||
forge) printf 'forges' ;;
|
||||
workflow) printf 'workflows' ;;
|
||||
harness) printf 'harnesses' ;;
|
||||
provider_root() {
|
||||
local _root
|
||||
_provider_root_into _root
|
||||
printf '%s' "$_root"
|
||||
}
|
||||
|
||||
# Same assign-instead-of-print split as _provider_root_into, for the same
|
||||
# reason: provider_dir is reached once per manifest read.
|
||||
_provider_axis_dir_into() {
|
||||
case "$2" in
|
||||
forge) printf -v "$1" 'forges' ;;
|
||||
workflow) printf -v "$1" 'workflows' ;;
|
||||
harness) printf -v "$1" 'harnesses' ;;
|
||||
# Not an axis in the select-one sense the other three are: a launch
|
||||
# enables zero or more tools, and none is the default. It is here
|
||||
# because everything else about it is the same - a manifest that is
|
||||
# data, validated by the same schema machinery, never sourced.
|
||||
tool) printf 'tools' ;;
|
||||
*) echo "unknown provider axis: $1" >&2; return 1 ;;
|
||||
tool) printf -v "$1" 'tools' ;;
|
||||
*) echo "unknown provider axis: $2" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_provider_axis_dir() {
|
||||
local _sub
|
||||
_provider_axis_dir_into _sub "$1" || return 1
|
||||
printf '%s' "$_sub"
|
||||
}
|
||||
|
||||
_provider_manifest_name() {
|
||||
case "$1" in
|
||||
forge) printf 'forge.conf' ;;
|
||||
@@ -72,6 +89,18 @@ valid_provider_name() {
|
||||
[[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]
|
||||
}
|
||||
|
||||
# `readlink -m` answers memoized per path, because the containment check below
|
||||
# resolves the same axis directory once per provider and provider_dir is
|
||||
# reached from inside each `$( manifest_get ... )`. What is cached is one
|
||||
# path's resolution, not a verdict: every caller still runs the containment
|
||||
# comparison and the -d check itself.
|
||||
declare -gA _PROVIDER_REAL_CACHE=()
|
||||
_provider_resolve_into() {
|
||||
[ -n "${_PROVIDER_REAL_CACHE[$2]+set}" ] \
|
||||
|| _PROVIDER_REAL_CACHE["$2"]="$(readlink -m "$2")"
|
||||
printf -v "$1" '%s' "${_PROVIDER_REAL_CACHE[$2]}"
|
||||
}
|
||||
|
||||
# Resolved once per root+axis+name. The two readlinks are the containment
|
||||
# check, and it is called on every manifest read; keying the cache on the
|
||||
# resolved root rather than just the name is what keeps a test that repoints
|
||||
@@ -79,19 +108,19 @@ valid_provider_name() {
|
||||
declare -gA _PROVIDER_DIR_CACHE=()
|
||||
provider_dir() {
|
||||
local axis="$1" name="$2" sub root axis_root dir_real id
|
||||
sub="$(_provider_axis_dir "$axis")" || return 1
|
||||
_provider_axis_dir_into sub "$axis" || return 1
|
||||
if ! valid_provider_name "$name"; then
|
||||
echo "invalid $axis name: $name" >&2
|
||||
return 1
|
||||
fi
|
||||
root="$(provider_root)"
|
||||
_provider_root_into root
|
||||
id="$root:$axis:$name"
|
||||
if [ -n "${_PROVIDER_DIR_CACHE[$id]+set}" ]; then
|
||||
printf '%s' "${_PROVIDER_DIR_CACHE[$id]}"
|
||||
return 0
|
||||
fi
|
||||
axis_root="$(readlink -m "$root/$sub")"
|
||||
dir_real="$(readlink -m "$root/$sub/$name")"
|
||||
_provider_resolve_into axis_root "$root/$sub"
|
||||
_provider_resolve_into dir_real "$root/$sub/$name"
|
||||
case "$dir_real/" in
|
||||
"$axis_root"/*) ;;
|
||||
*) echo "refusing $axis '$name': it resolves outside $axis_root" >&2
|
||||
@@ -122,17 +151,28 @@ _config_extra_type_valid() {
|
||||
esac
|
||||
}
|
||||
|
||||
provider_list() {
|
||||
local axis="$1" sub entry name
|
||||
sub="$(_provider_axis_dir "$axis")" || return 1
|
||||
for entry in "$(provider_root)/$sub"/*/; do
|
||||
# Fills _PROVIDER_LIST_OUT rather than printing, so the warm-up at the bottom
|
||||
# of this file can walk all three axes without a `$( )` fork apiece.
|
||||
declare -ga _PROVIDER_LIST_OUT=()
|
||||
_provider_list_into() {
|
||||
local axis="$1" sub root entry name
|
||||
_PROVIDER_LIST_OUT=()
|
||||
_provider_axis_dir_into sub "$axis" || return 1
|
||||
_provider_root_into root
|
||||
for entry in "$root/$sub"/*/; do
|
||||
[ -d "$entry" ] || continue
|
||||
name="$(basename "$entry")"
|
||||
name="${entry%/}"
|
||||
name="${name##*/}"
|
||||
valid_provider_name "$name" || continue
|
||||
printf '%s\n' "$name"
|
||||
_PROVIDER_LIST_OUT+=("$name")
|
||||
done
|
||||
}
|
||||
|
||||
provider_list() {
|
||||
_provider_list_into "$1" || return 1
|
||||
[ "${#_PROVIDER_LIST_OUT[@]}" -eq 0 ] || printf '%s\n' "${_PROVIDER_LIST_OUT[@]}"
|
||||
}
|
||||
|
||||
# Manifest schema. Same three-column shape as the config table: key, type,
|
||||
# default. Types are the config file's own, so one validator set covers both.
|
||||
_manifest_table() {
|
||||
@@ -151,6 +191,7 @@ issue_command ?command
|
||||
merge_block ?command
|
||||
repo_flags ?list:flag
|
||||
repo_env ?identifier
|
||||
repo_paths ?list:repopath
|
||||
identity_keys ?list:identifier
|
||||
disclose ?disclosures
|
||||
keys ?list:keyspec
|
||||
@@ -183,7 +224,7 @@ EOF
|
||||
requires ?list:identifier
|
||||
push bool on
|
||||
blocked_branches ?list:identifier
|
||||
block_merge bool off
|
||||
block_merge bool on
|
||||
requires_worktree bool off
|
||||
autonomy enum:commit|push-branch|open-pr commit
|
||||
briefing ?relpath
|
||||
@@ -303,6 +344,22 @@ _provider_valid_value() {
|
||||
# subcommand or a value.
|
||||
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
|
||||
;;
|
||||
repopath)
|
||||
# A repository named in a path rather than in a flag:
|
||||
# `<subcommand>:<first path segment>`, as in `api:repos` for
|
||||
# `gh api repos/<owner>/<name>/...`.
|
||||
#
|
||||
# Scoped to a subcommand because the alternative is comparing every
|
||||
# positional argument that happens to contain two slashes, and a
|
||||
# false refusal on an issue title is worse than the miss - this is
|
||||
# a fast-fail, so a wrong yes costs an honest agent a working
|
||||
# command. The guard applies it only when the subcommand is argv's
|
||||
# first word.
|
||||
case "$value" in *:*) ;; *) return 1 ;; esac
|
||||
local sub="${value%%:*}" seg="${value#*:}"
|
||||
[[ "$sub" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 1
|
||||
[[ "$seg" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]
|
||||
;;
|
||||
relpath)
|
||||
# Joined to a provider directory, so no absolute paths and no
|
||||
# traversal - a manifest may only name a file it ships.
|
||||
@@ -496,12 +553,45 @@ manifest_validate() {
|
||||
#
|
||||
# Failures are ignored: a missing or unreadable directory is not this loop's
|
||||
# to report, and provider_dir refuses it again when something asks.
|
||||
for _axis in forge workflow harness; do
|
||||
for _name in $(provider_list "$_axis" 2>/dev/null); do
|
||||
provider_dir "$_axis" "$_name" >/dev/null 2>&1 || true
|
||||
#
|
||||
# One `readlink -m` for every path rather than two per provider. It takes
|
||||
# several operands and prints one resolved path per line, so the warm-up costs
|
||||
# a single process instead of one per directory - which is most of what
|
||||
# sourcing this file costs, and is paid by every `bin/dejima` invocation and
|
||||
# every test that drives the library in a fresh shell. The batch only fills
|
||||
# the resolution cache; provider_dir still does its own containment check
|
||||
# against what it finds there.
|
||||
_provider_warm_dirs() {
|
||||
local axis sub root name path i=0
|
||||
local -a axes=() names=() paths=() lines=()
|
||||
_provider_root_into root
|
||||
for axis in forge workflow harness; do
|
||||
_provider_axis_dir_into sub "$axis" || continue
|
||||
paths+=("$root/$sub")
|
||||
_provider_list_into "$axis" || continue
|
||||
for name in "${_PROVIDER_LIST_OUT[@]}"; do
|
||||
axes+=("$axis")
|
||||
names+=("$name")
|
||||
paths+=("$root/$sub/$name")
|
||||
done
|
||||
done
|
||||
done
|
||||
unset _axis _name
|
||||
mapfile -t lines < <(readlink -m -- "${paths[@]}" 2>/dev/null)
|
||||
# A path containing a newline would desync the one-line-per-operand
|
||||
# pairing. Nothing shipped can produce one - names are validated and the
|
||||
# root comes from `pwd` - but a mismatched count means the pairing cannot
|
||||
# be trusted, so the batch is dropped rather than cached wrong. provider_dir
|
||||
# then resolves each path itself, exactly as it would have.
|
||||
if [ "${#lines[@]}" -eq "${#paths[@]}" ]; then
|
||||
for path in "${paths[@]}"; do
|
||||
_PROVIDER_REAL_CACHE["$path"]="${lines[$i]}"
|
||||
i=$((i + 1))
|
||||
done
|
||||
fi
|
||||
for i in "${!names[@]}"; do
|
||||
provider_dir "${axes[$i]}" "${names[$i]}" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
_provider_warm_dirs
|
||||
|
||||
_manifest_parse() {
|
||||
local axis="$1" name="$2" dir="$3" id="$4" file line key value type line_count
|
||||
@@ -751,23 +841,198 @@ identity_exists() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# The credential keys the selected identity leaves to the section beneath it,
|
||||
# as "<section> <key>" lines, empty when there are none.
|
||||
#
|
||||
# Only the selected forge's, because refusing over a credential belonging to a
|
||||
# forge this launch does not use would be refusing over a value nothing reads.
|
||||
#
|
||||
# "Set" means written in the config file, not resolved from a schema default.
|
||||
# A forge whose credential key carries a default still hands an identity that
|
||||
# names none the machine's own path, and that is not caught here - see
|
||||
# docs/configuration.md, which says so rather than leaving it to be found.
|
||||
identity_borrowed_credentials() {
|
||||
local name="$1" forge="$2" key
|
||||
if ! config_is_set "identity.$name" ssh_key && config_is_set core ssh_key; then
|
||||
printf 'core ssh_key
|
||||
'
|
||||
fi
|
||||
for key in $(manifest_get forge "$forge" identity_keys); do
|
||||
if ! config_is_set "identity.$name" "$key" \
|
||||
&& config_is_set "forge.$forge" "$key"; then
|
||||
printf 'forge.%s %s
|
||||
' "$forge" "$key"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Whether the selected identity, if there is one, is one a session can act
|
||||
# under without borrowing from anyone.
|
||||
#
|
||||
# Two refusals, one reason. A name with no section resolves to nothing and
|
||||
# falls through to the machine's own credentials; a section that names only
|
||||
# some of them falls through for the rest. Either way a session launched as
|
||||
# this identity acts under another account, and nothing says so. Pushing under
|
||||
# the wrong key fails loudly; pushing under the default key when you asked for
|
||||
# another one does not fail at all, which is the direction worth being careful
|
||||
# about.
|
||||
#
|
||||
# One function rather than a check at each caller: bin/dejima's --print-config
|
||||
# needs the same answer, because every value it prints resolves THROUGH the
|
||||
# identity and printing this machine's credentials under an identity's name is
|
||||
# the confusion the refusal exists to prevent.
|
||||
#
|
||||
# Guarded on the config existing rather than swallowing config_get's stderr:
|
||||
# with no file at all it says so, and a selection validated without one is a
|
||||
# legitimate case this must stay silent for.
|
||||
# The identity in force: [core] identity when it names one, and otherwise the
|
||||
# reserved `default` section when the config has one.
|
||||
#
|
||||
# `default` is what [core] ssh_key was without a name. Giving it one is what
|
||||
# lets a host state its ordinary account the same way it states any other,
|
||||
# with a git name and a forge credential beside the key rather than those
|
||||
# coming from three different places.
|
||||
#
|
||||
# Empty when neither applies, which is the pre-identity config and still a
|
||||
# supported one.
|
||||
# Assigns rather than prints, and every caller uses this form. identity_get
|
||||
# runs on every config_get through bin/dejima's wrapper, and `$(...)` around
|
||||
# an answer this cheap costs a fork per read - which is most of what a launch
|
||||
# spends on config.
|
||||
identity_selected_into() {
|
||||
local __var="$1" __name=""
|
||||
if config_exists; then
|
||||
__name="$(config_get core identity)" || __name=""
|
||||
if [ -z "$__name" ] && _identity_section_present default; then
|
||||
__name=default
|
||||
fi
|
||||
fi
|
||||
printf -v "$__var" '%s' "$__name"
|
||||
}
|
||||
|
||||
identity_selected() {
|
||||
local _n
|
||||
identity_selected_into _n
|
||||
printf '%s' "$_n"
|
||||
}
|
||||
|
||||
# Whether a section has any key in the parsed config, read straight off the
|
||||
# parse rather than through identity_exists.
|
||||
#
|
||||
# identity_exists walks every forge's manifest to decide which keys count, and
|
||||
# bin/dejima's config_get wrapper reaches this on every single read - which
|
||||
# turned a --print-config from three seconds into forty. It does not need the
|
||||
# manifest: the parser refuses an unknown key outright, so anything present
|
||||
# under identity.<name> in a config that validated is already one of the keys
|
||||
# identity_exists would have accepted.
|
||||
_identity_section_present() {
|
||||
local name="$1" k
|
||||
config_exists || return 1
|
||||
config_validate || return 1
|
||||
for k in "${!_CFG_SEEN[@]}"; do
|
||||
case "$k" in "identity.$name".*) return 0 ;; esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
identity_validate() {
|
||||
local forge="$1" name="" borrowed section key
|
||||
name="$(identity_selected)"
|
||||
|
||||
# [core] ssh_key is the default identity under its old name, so a config
|
||||
# holding both says the same thing twice and dejima would have to pick.
|
||||
# Refused rather than ranked: which key a session pushed with is not a
|
||||
# thing to work out from precedence rules.
|
||||
if identity_exists default && config_is_set core ssh_key; then
|
||||
echo "[core] ssh_key is set and so is [identity.default], which is the same" >&2
|
||||
echo "thing under two names - the key a session gets when none is asked for." >&2
|
||||
echo "Move it into [identity.default] and remove [core] ssh_key." >&2
|
||||
echo "See docs/configuration.md, \"Identities\"." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Deprecated, not removed: it is how every config written before
|
||||
# identities existed says this, and those still work. Warned on every
|
||||
# launch that has it set, not only the ones using it - a config with
|
||||
# [core] ssh_key and a named identity is one migration away from the
|
||||
# refusal above, and finding that out on the launch that breaks is worse
|
||||
# than being told now.
|
||||
#
|
||||
# Every launch rather than once, because there is nowhere to record
|
||||
# "once" that a session could not reach, and a deprecation nobody sees
|
||||
# until removal is not a deprecation.
|
||||
if config_is_set core ssh_key; then
|
||||
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf ' under its old name.\n' >&2
|
||||
if [ -n "$name" ]; then
|
||||
printf ' This launch runs as %s%s%s, so it is not the key being used here.\n' \
|
||||
"${C_KEY:-}" "$name" "${C_RESET:-}" >&2
|
||||
else
|
||||
printf ' Nothing else names an account, so it is the key this launch uses.\n' >&2
|
||||
fi
|
||||
printf ' Move it into an [identity.default] section, which can carry the git\n' >&2
|
||||
printf ' name and the forge credential belonging to that same account:\n\n' >&2
|
||||
# The value in the file, not through config_get: in this process
|
||||
# config_get is bin/dejima's wrapper, so with a named identity in
|
||||
# force it answers with THAT identity's key - and printing it as the
|
||||
# thing to move would name a key the operator is not being asked to
|
||||
# touch.
|
||||
printf ' [identity.default]\n ssh_key = %s\n\n' \
|
||||
"$(_config_get_from_file core ssh_key)" >&2
|
||||
fi
|
||||
|
||||
[ -n "$name" ] || return 0
|
||||
|
||||
if ! identity_exists "$name"; then
|
||||
echo "[core] identity is '$name', and no [identity.$name] section sets" >&2
|
||||
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
|
||||
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
|
||||
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
borrowed="$(identity_borrowed_credentials "$name" "$forge")"
|
||||
[ -n "$borrowed" ] || return 0
|
||||
echo "[identity.$name] leaves a credential to the section beneath it, so a" >&2
|
||||
echo "session launched as '$name' would act under whichever account owns:" >&2
|
||||
while IFS=$'\t' read -r section key; do
|
||||
[ -n "$key" ] || continue
|
||||
echo " [$section] $key" >&2
|
||||
done <<< "$borrowed"
|
||||
echo "An identity is how one session's account is kept separate from another's;" >&2
|
||||
echo "set each of those under [identity.$name], or unset it in the section named." >&2
|
||||
echo "See docs/configuration.md, \"Identities\"." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
identity_get() {
|
||||
local section="$1" key="$2" name value pname
|
||||
[ "$section.$key" != "core.identity" ] || return 1
|
||||
name="$(config_get core identity)" || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
# Which keys an identity could own, before working out which identity is
|
||||
# in force: this runs on every config_get, and all but a handful of keys
|
||||
# are ones no identity can answer for. Resolving the identity first meant
|
||||
# doing that work ~40 times a launch to discard it ~35 of those times.
|
||||
case "$section" in
|
||||
core) [ "$key" = ssh_key ] || return 1 ;;
|
||||
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
|
||||
identity.*) return 1 ;;
|
||||
*) return 1 ;;
|
||||
core) [ "$key" = ssh_key ] || return 1 ;;
|
||||
forge.*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
identity_selected_into name
|
||||
[ -n "$name" ] || return 1
|
||||
# The manifest lookup last, for the same reason: it forks, and by here
|
||||
# there is an identity that might actually answer.
|
||||
case "$section" in
|
||||
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
|
||||
esac
|
||||
value="$(config_get "identity.$name" "$key")" || return 1
|
||||
[ -n "$value" ] || return 1
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# Whether one forge declared this key as a person's rather than a project's.
|
||||
# Whether one forge declared this key as naming an account rather than the
|
||||
# forge itself - a credential a session acts under, not a setting like an API
|
||||
# URL that every session on this host shares.
|
||||
_forge_owns_identity_key() {
|
||||
local forge="$1" key="$2" k
|
||||
for k in $(manifest_get forge "$forge" identity_keys); do
|
||||
@@ -782,7 +1047,7 @@ _forge_owns_identity_key() {
|
||||
# globally would be a new way to get the same answer.
|
||||
identity_git_field() {
|
||||
local field="$1" name
|
||||
name="$(config_get core identity)" || return 1
|
||||
name="$(identity_selected)" || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
config_get "identity.$name" "$field"
|
||||
}
|
||||
@@ -862,22 +1127,7 @@ provider_validate_selection() {
|
||||
# quietly absent, because an operator who has them everywhere else would
|
||||
# otherwise have to read a manifest to find out they are gone here.
|
||||
#
|
||||
# An identity that names nothing is refused rather than ignored. Falling
|
||||
# through to the machine's own credentials would mean pushing under the
|
||||
# wrong account with nothing saying so, which is the failure this feature
|
||||
# exists to remove rather than one it may introduce.
|
||||
# Guarded on the config existing rather than swallowing config_get's
|
||||
# stderr: with no file at all it says so, and a selection validated
|
||||
# without one is a legitimate case this must stay silent for.
|
||||
local _identity=""
|
||||
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
|
||||
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
|
||||
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
|
||||
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
|
||||
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
|
||||
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
|
||||
return 1
|
||||
fi
|
||||
identity_validate "$forge" || return 1
|
||||
|
||||
# A name outside this set is a hard error rather than an unknown guard
|
||||
# treated as absent: a typo would otherwise silently turn a guard off,
|
||||
@@ -1114,10 +1364,12 @@ the user's machine, with
|
||||
|
||||
What this means:
|
||||
- The workspace directory is bind-mounted read-write and is the real
|
||||
project. ~/shared is also a real, persistent host directory (not part of
|
||||
the project) - use it to hand the user a file (a screenshot, a temp
|
||||
script) without touching the project checkout, or to pick up something
|
||||
they left there for you. Your harness keeps its own state on the host too,
|
||||
project. ~/shared is also a real, persistent host directory (this
|
||||
project's own, and not part of its checkout) - use it to hand the user a
|
||||
file (a screenshot, a temp script) without touching the project checkout,
|
||||
or to pick up something they left there for you. It outlives this session
|
||||
and every session against this project shares it, so treat what is in it
|
||||
as readable by all of them. Your harness keeps its own state on the host too,
|
||||
so this session's transcript outlives the container - the work is
|
||||
recoverable on the host with the command at the end of this
|
||||
briefing.{{writable_mounts}} Everything else is disposable: installed system
|
||||
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/bin/bash
|
||||
# Where a project's ~/shared lives on the host, and what is in it.
|
||||
#
|
||||
# `[core] share_dir` holds one directory per project, and a launch mounts only
|
||||
# that project's at ~/shared. This is the host side of that: where to put a
|
||||
# file so the next session against a project finds it, what every project is
|
||||
# holding, and how to move files left over from when share_dir was mounted
|
||||
# whole.
|
||||
#
|
||||
# Nothing here is a security control. The boundary is the mount bin/dejima
|
||||
# sets up; this is the operator's view of what is on the other side of it.
|
||||
#
|
||||
# Usage: dejima-share <path|list|adopt> [<path>]
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
cat <<'DEJIMA_SHARE_HELP'
|
||||
dejima-share - the host side of a project's ~/shared
|
||||
|
||||
Usage: dejima-share <path|list|adopt> [path]
|
||||
|
||||
path [path] print the host directory a launch against `path` mounts at
|
||||
~/shared, creating it if it does not exist yet - so a file
|
||||
copied there is waiting for the next session
|
||||
list every project's share directory, with its size and when it
|
||||
was last written, plus anything left loose in share_dir
|
||||
adopt [path] move whatever is loose in share_dir's top level into `path`'s
|
||||
share directory, after showing it and asking
|
||||
|
||||
path defaults to the current directory.
|
||||
|
||||
A launch mounts <share_dir>/projects/<slug> at ~/shared, where slug is the
|
||||
project's absolute path with every non-alphanumeric character replaced by a
|
||||
dash - the same key the audit and epitaph stores use. Only that directory
|
||||
crosses into the container, so one project's session cannot read another's
|
||||
handoff files.
|
||||
|
||||
Files that were in share_dir's top level before this split are still there and
|
||||
are no longer mounted anywhere. `adopt` is for moving them somewhere a session
|
||||
will see them; it never overwrites.
|
||||
|
||||
Nothing here deletes anything by itself, and dejima never prunes share_dir. It
|
||||
grows until you clear it out - `list` is what tells you what has gone stale.
|
||||
|
||||
See docs/configuration.md.
|
||||
DEJIMA_SHARE_HELP
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# The provider library rather than the config one directly: [core] harness and
|
||||
# the other provider-typed keys resolve against the manifests, so config
|
||||
# validation refuses an ordinary config without it.
|
||||
# shellcheck source=dejima-provider-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-slug-lib.sh"
|
||||
config_validate || exit 1
|
||||
|
||||
action="${1:-}"
|
||||
[ -n "$action" ] || { echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2; exit 1; }
|
||||
|
||||
SHARE_ROOT="$(config_get core share_dir)"
|
||||
PROJECTS_ROOT="$(share_projects_root "$SHARE_ROOT")"
|
||||
|
||||
# A workspace argument becomes a mount path, so it is resolved the way
|
||||
# bin/dejima resolves the one it is about to mount rather than used as typed.
|
||||
# A path that does not exist is a typo, and answering it with a share
|
||||
# directory nothing will ever mount is worse than refusing.
|
||||
resolve_workspace() {
|
||||
local given="${1:-$PWD}"
|
||||
[ -d "$given" ] || { echo "no such directory: $given" >&2; exit 1; }
|
||||
(cd "$given" && pwd)
|
||||
}
|
||||
|
||||
# Whatever is in share_dir's top level that is not the per-project root. Every
|
||||
# one of these was visible to every session before the split and is visible to
|
||||
# none now.
|
||||
loose_entries() {
|
||||
find "$SHARE_ROOT" -mindepth 1 -maxdepth 1 ! -name projects -printf '%f\n' \
|
||||
2>/dev/null | sort
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
path)
|
||||
ws="$(resolve_workspace "${2:-}")"
|
||||
dir="$(share_dir_for "$SHARE_ROOT" "$ws")"
|
||||
# Created here rather than only at launch, because the point of asking
|
||||
# is usually to put something in it before the launch happens.
|
||||
mkdir -p "$dir"
|
||||
printf '%s\n' "$dir"
|
||||
;;
|
||||
|
||||
list)
|
||||
if [ ! -d "$PROJECTS_ROOT" ]; then
|
||||
echo "No project has a share directory yet ($PROJECTS_ROOT does not exist)."
|
||||
else
|
||||
found=0
|
||||
while IFS= read -r d; do
|
||||
[ -n "$d" ] || continue
|
||||
found=1
|
||||
# du and date rather than a stat of the directory itself: the
|
||||
# directory's own mtime changes when an entry is added or
|
||||
# removed and not when one is written, which would report a
|
||||
# share nobody has touched in months as fresh.
|
||||
size="$(du -sh "$PROJECTS_ROOT/$d" 2>/dev/null | cut -f1)"
|
||||
newest="$(find "$PROJECTS_ROOT/$d" -type f -printf '%T@\n' 2>/dev/null \
|
||||
| sort -rn | head -1)"
|
||||
if [ -n "$newest" ]; then
|
||||
when="$(date -d "@${newest%.*}" '+%Y-%m-%d' 2>/dev/null || echo '?')"
|
||||
else
|
||||
when="empty"
|
||||
fi
|
||||
printf '%-10s %-12s %s\n' "${size:-?}" "$when" "$d"
|
||||
done < <(find "$PROJECTS_ROOT" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \
|
||||
2>/dev/null | sort)
|
||||
[ "$found" = 1 ] || echo "No project has a share directory yet."
|
||||
fi
|
||||
|
||||
loose="$(loose_entries)" || true
|
||||
if [ -n "$loose" ]; then
|
||||
echo
|
||||
echo "Loose in $SHARE_ROOT, mounted into no session:"
|
||||
while IFS= read -r e; do
|
||||
[ -n "$e" ] || continue
|
||||
printf ' %s\n' "$e"
|
||||
done <<< "$loose"
|
||||
echo
|
||||
echo "Move them with 'dejima-share adopt <project path>', or delete them."
|
||||
fi
|
||||
echo
|
||||
echo "Nothing prunes this. Clear out what you no longer need: everything"
|
||||
echo "in a project's share is readable by every future session against it."
|
||||
;;
|
||||
|
||||
adopt)
|
||||
ws="$(resolve_workspace "${2:-}")"
|
||||
dest="$(share_dir_for "$SHARE_ROOT" "$ws")"
|
||||
loose="$(loose_entries)" || true
|
||||
if [ -z "$loose" ]; then
|
||||
echo "Nothing loose in $SHARE_ROOT - every entry is already under projects/."
|
||||
exit 0
|
||||
fi
|
||||
echo "Moving into $ws's share ($dest):"
|
||||
while IFS= read -r e; do
|
||||
[ -n "$e" ] || continue
|
||||
if [ -e "$dest/$e" ]; then
|
||||
printf ' %-30s SKIP - %s already has one\n' "$e" "$dest"
|
||||
else
|
||||
printf ' %s\n' "$e"
|
||||
fi
|
||||
done <<< "$loose"
|
||||
echo
|
||||
# One question, after showing everything, the way dejima-project
|
||||
# approve asks - and read from stdin for the same reason it does, so a
|
||||
# non-interactive caller answering no is the default rather than an
|
||||
# error about /dev/tty. A move is not undoable by this tool, and the
|
||||
# files are the operator's.
|
||||
printf 'Move them? [y/N] '
|
||||
read -r reply || reply=n
|
||||
case "$reply" in
|
||||
y|Y) ;;
|
||||
*) echo "Nothing moved."; exit 0 ;;
|
||||
esac
|
||||
mkdir -p "$dest"
|
||||
moved=0
|
||||
while IFS= read -r e; do
|
||||
[ -n "$e" ] || continue
|
||||
# Never overwrites: a name that already exists in the destination
|
||||
# is left where it is and reported, because the two files are both
|
||||
# the operator's and this cannot know which one they meant.
|
||||
if [ -e "$dest/$e" ]; then
|
||||
echo "Skipped $e - $dest already has one" >&2
|
||||
continue
|
||||
fi
|
||||
mv -n "$SHARE_ROOT/$e" "$dest/$e"
|
||||
moved=$((moved + 1))
|
||||
done <<< "$loose"
|
||||
echo "Moved $moved entr$([ "$moved" = 1 ] && echo y || echo ies) into $dest"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
+19
-5
@@ -1,12 +1,15 @@
|
||||
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
|
||||
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
|
||||
# bin/dejima-epitaph (to decide whose transcripts to keep). Not on PATH and
|
||||
# not executable.
|
||||
# Shared library: the ~/.claude/projects slug scheme, which slugs belong to a
|
||||
# given workspace, and the share_dir layout keyed by that slug. Sourced by
|
||||
# bin/dejima (to decide what to mount), bin/dejima-epitaph (to decide whose
|
||||
# transcripts to keep) and bin/dejima-share. Not on PATH and not executable.
|
||||
#
|
||||
# One definition, because the callers must not disagree. A slug the mount
|
||||
# exposes but a caller treats as foreign is a session whose work is silently
|
||||
# left behind; a slug a caller treats as the workspace's own but the mount
|
||||
# does not expose is a hole that nothing reports.
|
||||
# does not expose is a hole that nothing reports. The share layout is here for
|
||||
# the same reason: bin/dejima mounts a directory and dejima-share tells an
|
||||
# operator where to put files in it, and those two naming different places
|
||||
# would be an operator dropping a file where nothing will ever read it.
|
||||
|
||||
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
|
||||
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
|
||||
@@ -14,6 +17,17 @@
|
||||
# runs inside the container, the other is a separate library).
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
# Every per-project share directory lives under this one subdirectory of
|
||||
# `[core] share_dir` rather than directly in it, which is what makes "left
|
||||
# over from before the split" a decidable question: anything at share_dir's
|
||||
# top level other than this directory predates the layout, because nothing
|
||||
# writes there any more. It is also the directory to mount over sshfs - one
|
||||
# entry per project, with no loose files mixed in.
|
||||
share_projects_root() { printf '%s/projects' "${1%/}"; }
|
||||
|
||||
# The host directory mounted at ~/shared for a launch against $2.
|
||||
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
#
|
||||
|
||||
@@ -178,10 +178,35 @@ focus_opt=()
|
||||
colors_opt=()
|
||||
[ -z "$button_colors" ] || colors_opt=(-t "buttonColors=$button_colors")
|
||||
|
||||
# The sixteen ANSI slots xterm.js draws with, which without this are its own
|
||||
# defaults - white on black, a bright red, a magenta. dejima's own output
|
||||
# reaches a terminal in indexed colour precisely so the terminal's theme fills
|
||||
# those slots (see dejima-color-lib.sh), and here dejima IS the terminal: the
|
||||
# browser has no gruvbox to inherit, so the front door showed the stock
|
||||
# palette while the same session over SSH showed the operator's.
|
||||
#
|
||||
# So the exact hexes belong here, by the same rule as web/src/page.css: they
|
||||
# are the two surfaces where dejima owns the background. A static test keeps
|
||||
# the values they share in step.
|
||||
#
|
||||
# Upstream gruvbox dark, by its own names. `background` and `foreground` are
|
||||
# bg0/light1, which is what a gruvbox terminal is set to.
|
||||
theme_opt=(-t 'theme={
|
||||
"background":"#282828","foreground":"#ebdbb2",
|
||||
"cursor":"#ebdbb2","cursorAccent":"#282828",
|
||||
"selectionBackground":"#504945",
|
||||
"black":"#282828","red":"#cc241d","green":"#98971a","yellow":"#d79921",
|
||||
"blue":"#458588","magenta":"#b16286","cyan":"#689d6a","white":"#a89984",
|
||||
"brightBlack":"#928374","brightRed":"#fb4934","brightGreen":"#b8bb26",
|
||||
"brightYellow":"#fabd2f","brightBlue":"#83a598","brightMagenta":"#d3869b",
|
||||
"brightCyan":"#8ec07c","brightWhite":"#ebdbb2"
|
||||
}')
|
||||
|
||||
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
|
||||
--index "$index_html" \
|
||||
${font_opt[@]+"${font_opt[@]}"} \
|
||||
${prefix_opt[@]+"${prefix_opt[@]}"} \
|
||||
${focus_opt[@]+"${focus_opt[@]}"} \
|
||||
${colors_opt[@]+"${colors_opt[@]}"} \
|
||||
"${theme_opt[@]}" \
|
||||
"$SCRIPT_DIR/dejima-attach"
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# The small terminal interface dejima-attach and dejima-allow share.
|
||||
#
|
||||
# Two tools ask an operator to pick one running session out of several, and
|
||||
# both had to be readable on whatever terminal the operator happened to be
|
||||
# holding. This is the part that does not depend on which of them is asking:
|
||||
# the palette, the heading, and the prompt.
|
||||
#
|
||||
# What is NOT here is the row. Each tool lists a different thing from a
|
||||
# different source - the registry of remote-enabled sessions on one side,
|
||||
# `docker ps` on the other - so the columns are the tool's own, measured by
|
||||
# the tool against its own data.
|
||||
#
|
||||
# Sourced, never run.
|
||||
|
||||
# The palette is dejima-color-lib.sh's, and there is only one of it. This file
|
||||
# used to carry a second, in hardcoded gruvbox truecolor, which collided with
|
||||
# that one on C_OK and C_WARN and broke on any background dejima does not own -
|
||||
# see the comment there for the contrast measurements that decided it.
|
||||
#
|
||||
# Sourced and initialised here rather than left to the caller, because both
|
||||
# callers draw to stdout and this file's functions assume the names exist.
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/dejima-color-lib.sh"
|
||||
color_init 1
|
||||
|
||||
# The rule under the heading, the width of the heading itself. Built rather
|
||||
# than written out because the heading is now the caller's: two literals kept
|
||||
# next to two headings stay in step only while nobody adds a third.
|
||||
#
|
||||
# A loop rather than `tr`, which is byte-oriented and would shred a 3-byte box
|
||||
# drawing character into three copies of nothing.
|
||||
tui_rule() {
|
||||
local n="$1" i out=""
|
||||
for ((i = 0; i < n; i++)); do out+="$GLYPH_RULE"; done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# "dejima | <subtitle>", underlined to its own width.
|
||||
# A blank screen to draw a front door on, scrollback included.
|
||||
#
|
||||
# The picker is reached by navigating to it - the web toolbar's SESS button is
|
||||
# a full page load - and it drew on top of whatever the previous session had
|
||||
# left on screen, so the rows arrived interleaved with the tail of the session
|
||||
# just left. Over SSH it was invisible, because a fresh connection to the
|
||||
# forced command usually starts on an empty screen (#213).
|
||||
#
|
||||
# 3J as well as 2J: without it a swipe up on a phone lands in the middle of
|
||||
# the previous session's output, which is the same confusion one gesture away.
|
||||
#
|
||||
# Its own function rather than part of tui_banner, because dejima-allow's
|
||||
# picker must NOT do this: that one is a prompt inside a shell the operator is
|
||||
# already using, and wiping their scrollback to ask one question is worse than
|
||||
# the interleaving this fixes.
|
||||
#
|
||||
# Rides the same switch as the colour and the glyphs, for the reason stated
|
||||
# above them: one flag is easier to reason about than two, and the terminals
|
||||
# that mangle box drawing are the ones these bytes should not reach either. So
|
||||
# NO_COLOR turns this off as well - a reading of it this file has already
|
||||
# committed to, and the alternative is a picker that answers to two different
|
||||
# ideas of "plain".
|
||||
tui_clear() {
|
||||
[ -n "$C_OFF" ] || return 0
|
||||
printf '\033[H\033[2J\033[3J'
|
||||
}
|
||||
|
||||
tui_banner() {
|
||||
local subtitle="$1"
|
||||
printf '\n %s%sdejima%s %s%s%s %s%s%s\n' \
|
||||
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
|
||||
"$C_MUTED" "$subtitle" "$C_OFF"
|
||||
# 6 for the wordmark, 3 for the bar and the spaces either side.
|
||||
printf ' %s%s%s\n\n' "$C_RULE" "$(tui_rule $((9 + ${#subtitle})))" "$C_OFF"
|
||||
}
|
||||
|
||||
# Asks for one of 1..$1 and leaves it in TUI_CHOICE. $2 is the verb the
|
||||
# prompt opens with. Exits the caller outright on anything meaning "not this
|
||||
# time" - q, an empty line, or no terminal to read from.
|
||||
#
|
||||
# The answer comes back in a variable rather than on stdout because those
|
||||
# exits have to be the caller's. In `$(tui_pick ...)` the function runs in a
|
||||
# subshell, where `exit 0` ends the subshell and hands the caller an empty
|
||||
# string it would then use as an index.
|
||||
#
|
||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
|
||||
# better than arrow keys, and dejima-attach is the phone's entry point.
|
||||
TUI_CHOICE=""
|
||||
tui_pick() {
|
||||
local count="$1" verb="$2" choice
|
||||
TUI_CHOICE=""
|
||||
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
|
||||
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
|
||||
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
|
||||
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
|
||||
# returns nonzero immediately, and exiting 1 with nothing printed reads as
|
||||
# a broken front door.
|
||||
read -r choice || exit 0
|
||||
case "$choice" in
|
||||
q|Q|'') exit 0 ;;
|
||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||
esac
|
||||
# Normalized to base 10 once, here, because the range check and the
|
||||
# subscript the caller does with this do not read a number the same way:
|
||||
# `[` is POSIX test and decimal-only, while $(( )) reads a leading zero as
|
||||
# OCTAL. Without this, "010" - which a phone keypad makes easy to type -
|
||||
# passes the check as 10 and indexes as 8, and the caller acts on a
|
||||
# DIFFERENT session than the one picked, with nothing printed to say so.
|
||||
#
|
||||
# It fixes the two ugly shapes as well. "09" is a value here rather than
|
||||
# the "invalid octal constant" arithmetic error it is to $(( )); and a
|
||||
# number past int64 wraps to one the range check can answer for, where
|
||||
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
|
||||
# expression expected" - which makes the `if` false, so "Out of range."
|
||||
# is never printed and the caller dies on an unbound element.
|
||||
choice="$((10#$choice))"
|
||||
if [ "$choice" -lt 1 ] || [ "$choice" -gt "$count" ]; then
|
||||
echo "Out of range." >&2
|
||||
exit 1
|
||||
fi
|
||||
TUI_CHOICE="$choice"
|
||||
}
|
||||
+11
-5
@@ -67,12 +67,18 @@ REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
|
||||
# often the operator is told.
|
||||
CHECK_INTERVAL=3600
|
||||
|
||||
# Not ~/.local/share/dejima, where the audit store and the epitaphs live: those
|
||||
# are the operator's records, each with a config key pointing at it. This is a
|
||||
# timestamp and a hash that can be deleted at any moment with no loss, which is
|
||||
# what XDG's state directory is for.
|
||||
# Alongside everything else dejima keeps on the host. This was under
|
||||
# XDG_STATE_HOME, which is where a throwaway timestamp belongs by the letter
|
||||
# of the spec - but it made ~/.local/state a fifth place dejima writes, for
|
||||
# one file, and `mkdir -p` created that base directory on hosts that had no
|
||||
# other use for it. One root is worth more than the distinction.
|
||||
#
|
||||
# No config key, where the four directories beside it have one: those are
|
||||
# records an operator reads or a directory that gets mounted, and this is a
|
||||
# hash that can be deleted at any moment with no loss. Nothing is served by
|
||||
# being able to move it.
|
||||
_cache_file() {
|
||||
printf '%s/dejima/update-check' "${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||
printf '%s/.local/share/dejima/update-check' "$HOME"
|
||||
}
|
||||
|
||||
# Only the transports that cannot name a command to run. `ext::` is refused by
|
||||
|
||||
@@ -10,8 +10,17 @@
|
||||
# reason this is code is the pairing in the second block, which resolves one
|
||||
# directory tree through the filenames of another.
|
||||
|
||||
# Registers the waiting/done notification handlers into dejima's settings
|
||||
# layer, in the shape Claude Code reads them.
|
||||
# Registers the `done` notification handler into dejima's settings layer, in
|
||||
# the shape Claude Code reads them.
|
||||
#
|
||||
# Stop only, though the hook and the host-side watcher both understand
|
||||
# `waiting` as well. Claude Code raises Notification when it needs permission
|
||||
# to run something and when the prompt has sat idle, and a dejima session runs
|
||||
# with permission prompts off - so the first never happens, and what is left
|
||||
# is a timer that expires shortly after the Stop it follows. Registering both
|
||||
# put two pushes on the operator's phone for one event, a minute apart, the
|
||||
# second saying nothing the first had not. `waiting` stays in the enum for a
|
||||
# harness that can say it without having stopped first.
|
||||
#
|
||||
# Claude Code merges hook entries across settings layers rather than letting
|
||||
# the higher one replace the lower, so these are added to whatever the
|
||||
@@ -25,8 +34,7 @@
|
||||
harness_register_notify() {
|
||||
local settings="$1" tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
|
||||
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
"$settings" > "$tmp"; then
|
||||
mv "$tmp" "$settings"
|
||||
else
|
||||
|
||||
+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);
|
||||
}
|
||||
@@ -34,7 +34,13 @@ services:
|
||||
# $SANDBOX_IMAGE lets bin/dejima point a run at a derived,
|
||||
# per-project image instead, without this build ever producing one
|
||||
# under that name itself - see bin/dejima.
|
||||
image: ${SANDBOX_IMAGE:-dejima:${SANDBOX_USER:-node}}
|
||||
# localhost/ so this can never resolve to Docker Hub: Docker reads a first
|
||||
# component containing a dot or colon, or exactly "localhost", as a
|
||||
# registry host, and a bare `dejima:node` is `docker.io/library/dejima:node`
|
||||
# to anything that pushes (#53). bin/dejima holds the same prefix in
|
||||
# SANDBOX_IMAGE_REPO and passes it in; this default is for a compose
|
||||
# invocation by hand.
|
||||
image: ${SANDBOX_IMAGE:-localhost/dejima:${SANDBOX_USER:-node}}
|
||||
# Run tini as PID 1 so orphaned processes get reaped. Without it PID 1 is
|
||||
# the session's own command (claude, or nvim under SANDBOX_NVIM_CONFIG),
|
||||
# which never reaps: every process-substitution child the test suite and
|
||||
|
||||
@@ -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
|
||||
|
||||
+64
-9
@@ -28,8 +28,10 @@
|
||||
# to a forge here.
|
||||
# DEJIMA_FORGE_REPO the "<owner>/<name>" of the repository the session
|
||||
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
|
||||
# DEJIMA_FORGE_REPO_ENV the spellings this forge's CLI
|
||||
# accepts for naming a different one.
|
||||
# DEJIMA_FORGE_REPO_ENV / DEJIMA_FORGE_REPO_PATHS the
|
||||
# spellings this forge's CLI accepts for naming a
|
||||
# different one: a flag, an environment variable, and a
|
||||
# path segment under a named subcommand.
|
||||
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
|
||||
set -u
|
||||
|
||||
@@ -44,7 +46,8 @@ fi
|
||||
# Outside a dejima container, or with nothing to do, this is a pass-through.
|
||||
if [ -z "${DEJIMA:-}" ] ||
|
||||
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ]; }; then
|
||||
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; }; then
|
||||
exec "$real" "$@"
|
||||
fi
|
||||
|
||||
@@ -120,7 +123,8 @@ fi
|
||||
# Refused rather than skipped when the launch resolved no repository at all: a
|
||||
# workspace with no origin has nothing for --repo to legitimately name, and
|
||||
# "cannot confirm" is not a reason to let it through.
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
|
||||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
|
||||
refuse_repo() {
|
||||
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
|
||||
exit 1
|
||||
@@ -154,15 +158,33 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# A one-dash flag may carry its value with no
|
||||
# separator - `-Rowner/name` - which two-dash flags do
|
||||
# not do.
|
||||
# One-dash flags only; a two-dash flag carries its
|
||||
# value with `=` or as the next argument, both above.
|
||||
case "$flag" in
|
||||
--*) continue ;;
|
||||
-?) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case "$arg" in "$flag"?*) value="${arg#"$flag"}" ;; *) continue ;; esac
|
||||
# Short flags cluster, so the letter can sit anywhere
|
||||
# in one: `-Rowner/name`, `-wR owner/name` and
|
||||
# `-wRowner/name` all name a repository and none of
|
||||
# them is `-R` as its own argument (#243). Split at the
|
||||
# letter the way getopt does - what follows it in the
|
||||
# same argument is the value, and nothing following it
|
||||
# means the next argument is.
|
||||
letter="${flag#-}"
|
||||
# A cluster, not a value that happens to start with a
|
||||
# dash: one dash, then the letter somewhere in a run of
|
||||
# letters. Without the second check, `--search foo-R/x`
|
||||
# style arguments would be mined for flag letters.
|
||||
case "$arg" in -[!-]*) ;; *) continue ;; esac
|
||||
rest="${arg#-}"
|
||||
case "$rest" in *"$letter"*) ;; *) continue ;; esac
|
||||
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
|
||||
value="${rest#*"$letter"}"
|
||||
if [ -z "$value" ]; then
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
[ -n "$value" ] || continue
|
||||
@@ -170,6 +192,39 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# A repository named in a path rather than in a flag - `gh api
|
||||
# repos/<owner>/<name>/issues` reaches any repository the token does,
|
||||
# without a --repo anywhere in it (#243).
|
||||
#
|
||||
# Only when the declared subcommand is argv's first word. The alternative
|
||||
# is inspecting every positional that contains two slashes, which would
|
||||
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
|
||||
# honest agent a working command, which is the wrong way for a fast-fail to
|
||||
# be wrong.
|
||||
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
|
||||
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
|
||||
for spec in "${repo_paths[@]}"; do
|
||||
[ "$1" = "${spec%%:*}" ] || continue
|
||||
segment="${spec#*:}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
# gh substitutes {owner} and {repo} from the checkout, so a
|
||||
# path carrying them names this repository by construction and
|
||||
# comparing the literal braces would refuse it.
|
||||
case "$arg" in *'{'*) continue ;; esac
|
||||
path="${arg#/}"
|
||||
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
|
||||
path="${path#"$segment"/}"
|
||||
owner="${path%%/*}"
|
||||
path="${path#*/}"
|
||||
name="${path%%/*}"
|
||||
[ -n "$owner" ] && [ -n "$name" ] || continue
|
||||
same_repo "$owner/$name" \
|
||||
|| refuse_repo "the path '$arg'" "$owner/$name"
|
||||
done
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- the authorship disclosure ---------------------------------------------
|
||||
|
||||
+251
-172
@@ -2,10 +2,10 @@
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
Day-to-day configuration once dejima is installed and running: what a
|
||||
project declares versus what you approve, config parity with your host agent
|
||||
harness setup, a different editor, writable host directories, shared
|
||||
scratch space, frontend testing, and what the sandbox knows about itself.
|
||||
Day-to-day configuration once dejima is installed and running. The reference
|
||||
table below is every key there is; the sections after it cover what a project
|
||||
declares versus what you approve, what carries over from your host, mounts and
|
||||
scratch space, and what the sandbox knows about itself.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
@@ -26,12 +26,13 @@ config file behind them.
|
||||
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
||||
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
||||
| `[core] command` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
|
||||
| `[core] identity` | unset | which `[identity.<name>]` section supplies this project's credentials and git name; also settable per launch with `--identity`. A name with no section is refused rather than falling back |
|
||||
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
|
||||
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
|
||||
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord |
|
||||
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
|
||||
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
|
||||
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
|
||||
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
|
||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
|
||||
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
|
||||
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
|
||||
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
|
||||
@@ -49,7 +50,7 @@ config file behind them.
|
||||
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
|
||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
|
||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
|
||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
|
||||
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
|
||||
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
||||
@@ -78,12 +79,11 @@ opting into when you pick one.
|
||||
## Global and per-project domains and dependencies
|
||||
|
||||
Allowed domains and dependencies each stack through their own layers. The
|
||||
layers **add** to each other, so a narrower layer can only widen what a
|
||||
broader one already permits: nothing at the project layer can withdraw a
|
||||
built-in domain, and nothing at the container layer can withdraw a project
|
||||
one. Ordering them least specific first is how they are listed below and how
|
||||
they are applied, and it means the set a session gets is the union of every
|
||||
layer that applies to it, never an override of one by another.
|
||||
layers **add** to each other, so a narrower layer can only widen what a broader
|
||||
one already permits: the set a session gets is the union of every layer that
|
||||
applies to it, never an override of one by another. Nothing at the project
|
||||
layer can withdraw a built-in domain, and nothing at the container layer can
|
||||
withdraw a project one.
|
||||
|
||||
| Config | Layers, least specific first |
|
||||
| --- | --- |
|
||||
@@ -102,61 +102,79 @@ A project can declare what it needs in two committed files:
|
||||
# tarball:<name>@<sha256>:<url> release archive
|
||||
```
|
||||
|
||||
### Declaring that the history is evidence
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a session could write these files; they are read only to
|
||||
compare against reality and suggest a command. Approvals live outside the
|
||||
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
|
||||
tree) - global at its root, per project under `projects/<slug>/` - written
|
||||
only by tools you run on the host:
|
||||
|
||||
A third file, approved the same way as the two above:
|
||||
|
||||
```
|
||||
.dejima/corpus # one rule per line; today, commit-per-step
|
||||
```sh
|
||||
dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # review and approve the declared set
|
||||
dejima-allow --project ~/myproject crates.io
|
||||
dejima-allow --global crates.io # applies to every project
|
||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
dejima-dep add --global cargo:cargo-nextest
|
||||
```
|
||||
|
||||
An approved `commit-per-step` adds a paragraph to the session briefing
|
||||
requiring **one commit per plan step that writes a file**, rather than one
|
||||
commit per task, and explaining why. Absent or unapproved, nothing is added
|
||||
and nothing changes, which is the default for every project that has not asked
|
||||
for it.
|
||||
Two of those tools also write the declaration, so an approval you make by
|
||||
hand does not stay invisible to everyone else on the project:
|
||||
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
|
||||
approval first - that is the part that grants - and report separately on the
|
||||
declaration, so a project you can't write to still gets you the approval.
|
||||
`--global` writes no declaration at all: there is no project to declare in.
|
||||
|
||||
It goes through approval despite granting no capability, and that is
|
||||
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
|
||||
session able to turn this on by writing a file would be writing the *next*
|
||||
session's system prompt. `bin/dejima` reads the approval store and never a
|
||||
project's declarations - a static test holds that it does not so much as name
|
||||
that directory.
|
||||
The declaration those two write still grants nothing, and writing to it does
|
||||
not promote it. That it is session-writable is exactly why both tools refuse to
|
||||
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
|
||||
The refusal is the `open` itself rather than a check before it, so it holds even
|
||||
against a session still running while you type the command - see
|
||||
[Declaration files are session-writable](security.md#declaration-files-are-session-writable).
|
||||
The same write reads the file back to skip an already-declared entry, and that
|
||||
read is capped, since a session can make its own `.dejima/<kind>` arbitrarily
|
||||
large for free.
|
||||
|
||||
An unknown rule is reported but never offered for approval. One that nothing
|
||||
implements would approve cleanly, sit in the store and do nothing, which reads
|
||||
as being in force when it is not.
|
||||
Neither writer has an inverse for the declaration. `dejima-dep remove
|
||||
--project` withdraws the approval and deliberately leaves the declaration
|
||||
alone (it says so, since `dejima-project approve` will otherwise offer the
|
||||
entry again); `dejima-allow --project` has no `remove` at all. Taking
|
||||
something back from the project itself means editing `.dejima/` and
|
||||
committing that.
|
||||
|
||||
**Check the forge's merge policy before relying on this.** The briefing tells
|
||||
the agent how to commit, but the setting that can undo all of it is yours: a
|
||||
squash-merge is survivable, because a clone fetches remote branches and the
|
||||
step commits stay reachable through the branch ref, but deleting the branch
|
||||
afterwards removes the last thing pointing at them. Several forges offer
|
||||
"delete branch on merge" and some default it on. Measured both ways on a
|
||||
throwaway repository: squash-merge with the branch retained left every step
|
||||
commit reachable in a fresh clone; the same merge with the branch deleted first
|
||||
left none.
|
||||
`dejima-project status`/`approve` classify a declared entry against
|
||||
the union of global and project approvals, so something already approved
|
||||
globally shows as approved, not pending. Unlike `dejima-allow`,
|
||||
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
|
||||
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
|
||||
scope either, and refuses the flag rather than ignoring it: a dependency
|
||||
needs an image rebuild, so there is nothing to patch in a live session.
|
||||
|
||||
So a project can adopt the rule, write every plan correctly, and still yield
|
||||
nothing, with no error anywhere - a squash-merged `main` looks perfectly
|
||||
healthy. dejima's own history is intact because it merges with merge commits.
|
||||
Approved domains join the allowlist at the next launch. `dejima-allow` can also
|
||||
patch a running session immediately, but only when run bare or with
|
||||
`--container` - approving here (`--project`/`--global`) never touches one; see
|
||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||
A domain that resolves to a private address has no live patch at all. Approved
|
||||
dependencies are baked into a derived image built `FROM` the shared base -
|
||||
first `dejima:<username>-global` if the global store has any dependency
|
||||
approved, then `dejima:<username>-<slug>` on top of that if the project store
|
||||
does - so they take effect on the **next** launch. Because of that rebuild, the
|
||||
usual moment to approve is *after* leaving the sandbox, and persistence never
|
||||
requires a running container.
|
||||
|
||||
The reason is recoverability rather than tidiness. A step is only legible
|
||||
afterwards if the file's state immediately before it and immediately after it
|
||||
can both be read back from history: before from the commit's parent, after
|
||||
from the commit. One commit carrying several steps that touch the same file
|
||||
destroys that, because no step in it can be isolated any more, and
|
||||
reconstructing one would mean building a file state the repository never held.
|
||||
Nothing recovers that later, so it has to be recorded while the work happens.
|
||||
`dejima-project approve` never attempts to patch a running session: it only
|
||||
ever calls `dejima-allow` without `--container`. If an individual approval
|
||||
can't be recorded - an unwritable store, a slug collision, a corrupt approval
|
||||
list - it reports that entry as not approved and keeps going, so one bad entry
|
||||
can't cost you the rest of the set. It then exits non-zero if anything failed,
|
||||
so `dejima-project approve ~/myproject && dejima` won't launch a session
|
||||
missing the approvals you just confirmed.
|
||||
|
||||
It lives in the project rather than in `~/.config/dejima/config` because it is
|
||||
a fact about the repository. Anyone who clones it and runs dejima gets the same
|
||||
commit discipline, which is what makes the history uniformly usable: a rule
|
||||
half the contributors follow produces a history nothing can be taken from.
|
||||
|
||||
What dejima does not do is say what the history is *for*. Collecting it, and
|
||||
deciding whether to, is the operator's business; the briefing states the rule
|
||||
the project already adopted and stops there.
|
||||
A session whose project declares something it doesn't have is told so in its
|
||||
startup context, with the exact command to hand you. Project approvals are
|
||||
inherited by subdirectories, so a git worktree under the project gets its
|
||||
parent's; global approvals apply everywhere regardless.
|
||||
|
||||
### Tarball dependencies
|
||||
|
||||
@@ -205,88 +223,53 @@ interpolated into a `RUN` line that runs as root at build time, so anything
|
||||
shell-active is refused outright rather than quoted around, and only `https`
|
||||
is accepted.
|
||||
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a session could write these files; they are read only to
|
||||
compare against reality and suggest a command. Approvals live outside the
|
||||
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
|
||||
tree) - global at its root, per project under `projects/<slug>/` - written
|
||||
only by tools you run on the host:
|
||||
### Declaring that the history is evidence
|
||||
|
||||
```sh
|
||||
dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # review and approve the declared set
|
||||
dejima-allow --project ~/myproject crates.io
|
||||
dejima-allow --global crates.io # applies to every project
|
||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
dejima-dep add --global cargo:cargo-nextest
|
||||
A third file, approved the same way as the two above:
|
||||
|
||||
```
|
||||
.dejima/corpus # one rule per line; today, commit-per-step
|
||||
```
|
||||
|
||||
Two of those tools also write the declaration, so an approval you make by
|
||||
hand does not stay invisible to everyone else on the project:
|
||||
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
|
||||
approval first - that is the part that grants - and report separately on the
|
||||
declaration, so a project you can't write to still gets you the approval.
|
||||
`--global` writes no declaration at all: there is no project to declare in.
|
||||
An approved `commit-per-step` adds a paragraph to the session briefing
|
||||
requiring **one commit per plan step that writes a file**, rather than one
|
||||
commit per task, and explaining why. Absent or unapproved, nothing is added
|
||||
and nothing changes, which is the default for every project that has not asked
|
||||
for it.
|
||||
|
||||
The declaration those two write still grants nothing; it is the same
|
||||
untrusted file the section above describes, and writing to it does not
|
||||
promote it. That it is session-writable is exactly why both tools refuse to
|
||||
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
|
||||
The refusal is the `open` itself rather than a check before it, so it holds
|
||||
even against a session that is still running while you type the command. See
|
||||
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
|
||||
for what that defends against and how. The same write
|
||||
reads the file back to skip an already-declared entry, and that read is
|
||||
capped - a session can make its own `.dejima/<kind>` arbitrarily large for
|
||||
free, and an oversized one is refused rather than loaded into your memory.
|
||||
It goes through approval despite granting no capability, and that is
|
||||
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
|
||||
session able to turn this on by writing a file would be writing the *next*
|
||||
session's system prompt. `bin/dejima` reads the approval store and never a
|
||||
project's declarations - a static test holds that it does not so much as name
|
||||
that directory.
|
||||
|
||||
Neither writer has an inverse for the declaration. `dejima-dep remove
|
||||
--project` withdraws the approval and deliberately leaves the declaration
|
||||
alone (it says so, since `dejima-project approve` will otherwise offer the
|
||||
entry again); `dejima-allow --project` has no `remove` at all. Taking
|
||||
something back from the project itself means editing `.dejima/` and
|
||||
committing that.
|
||||
An unknown rule is reported but never offered for approval. One that nothing
|
||||
implements would approve cleanly, sit in the store and do nothing, which reads
|
||||
as being in force when it is not.
|
||||
|
||||
`dejima-project status`/`approve` classify a declared entry against
|
||||
the union of global and project approvals, so something already approved
|
||||
globally shows as approved, not pending. Unlike `dejima-allow`,
|
||||
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
|
||||
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
|
||||
scope either, and refuses the flag rather than ignoring it: a dependency
|
||||
needs an image rebuild, so there is nothing to patch in a live session.
|
||||
The reason is recoverability rather than tidiness. A step is legible afterwards
|
||||
only if the file's state immediately before and immediately after it can both
|
||||
be read back: before from the commit's parent, after from the commit. One
|
||||
commit carrying several steps that touch the same file destroys that, and
|
||||
nothing recovers it later, so it has to be recorded while the work happens.
|
||||
|
||||
Approved domains join the allowlist at the next launch. `dejima-allow` can
|
||||
also patch a running session immediately, but only when it is run bare or
|
||||
with `--container` - approving here (`--project`/`--global`, with no
|
||||
`--container`) never touches one; see
|
||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||
A domain that resolves to a private address has no live patch at all - an
|
||||
internally-routed host can only be approved and picked up at the next launch.
|
||||
Approved dependencies are baked into a derived image built `FROM` the shared
|
||||
base - first `dejima:<username>-global`, if the global store has any
|
||||
dependency approved, then `dejima:<username>-<slug>` on top of that,
|
||||
if the project store does - so they need an image rebuild: they take effect
|
||||
on the **next** launch.
|
||||
**Check the forge's merge policy before relying on this.** The setting that can
|
||||
undo all of it is yours. A squash-merge is survivable, because the step commits
|
||||
stay reachable through the branch ref, but "delete branch on merge" - which
|
||||
several forges offer and some default on - removes the last thing pointing at
|
||||
them. Measured both ways on a throwaway repository: squash-merge with the
|
||||
branch retained left every step commit reachable in a fresh clone; the same
|
||||
merge with the branch deleted first left none. So a project can adopt the rule,
|
||||
write every plan correctly, and still yield nothing, with no error anywhere.
|
||||
dejima's own history is intact because it merges with merge commits.
|
||||
|
||||
Because of that rebuild, the usual moment to approve is *after* leaving the
|
||||
sandbox - so persistence (`--global` or `--project`) never requires a running
|
||||
container.
|
||||
|
||||
`dejima-project approve` never attempts to patch a running session: it only
|
||||
ever calls `dejima-allow` without `--container`, so recording the approval
|
||||
is the only thing it does. If an individual approval can't be recorded - an
|
||||
unwritable store, a slug collision, a corrupt approval list - it reports
|
||||
that entry as not approved and keeps going, rather than stopping at the
|
||||
first failure; that holds for dependencies and domains alike, so one bad
|
||||
entry can't cost you the rest of the set. It then exits non-zero if anything
|
||||
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
|
||||
session missing the approvals you just confirmed.
|
||||
|
||||
A session whose project declares something it doesn't have is told so in its
|
||||
startup context, with the exact command to hand you. Project approvals are
|
||||
inherited by subdirectories, so a git worktree under the project gets its
|
||||
parent's; global approvals apply everywhere regardless.
|
||||
It lives in the project rather than in `~/.config/dejima/config` because it is
|
||||
a fact about the repository: anyone who clones it gets the same commit
|
||||
discipline, and a rule half the contributors follow produces a history nothing
|
||||
can be taken from. What dejima does not do is say what the history is *for*.
|
||||
Collecting it is the operator's business; the briefing states the rule the
|
||||
project adopted and stops there.
|
||||
|
||||
## Config parity with your host agent harness setup
|
||||
|
||||
@@ -427,39 +410,78 @@ Dockerfile directly puts it outside that record.
|
||||
|
||||
## Identities
|
||||
|
||||
One machine, several accounts. A `[identity.<name>]` section groups the things
|
||||
that belong to a **person** rather than to a project - the SSH key, the forge
|
||||
token, the name on the commits - so that selecting an account is one word
|
||||
rather than four edits.
|
||||
**Several accounts on one machine, and none of them yours.** An
|
||||
`[identity.<name>]` section groups what one session acts as - the SSH key, the
|
||||
forge token, the name on the commits - so that pointing a launch at a
|
||||
different account is one word rather than four edits.
|
||||
|
||||
The rest of these documents ask you to give a session an account of its own -
|
||||
the README under [Using this responsibly](../README.md#using-this-responsibly),
|
||||
and security.md's [Give the sandbox its own
|
||||
identity](security.md#give-the-sandbox-its-own-identity). This is how you
|
||||
have more than one of them, which you will: an account is scoped to what it
|
||||
may reach, and one account scoped to everything any session might ever need
|
||||
is the thing separate accounts existed to avoid.
|
||||
|
||||
So the sections are per **agent account**, not per person:
|
||||
|
||||
```ini
|
||||
[identity.work]
|
||||
ssh_key = ~/.ssh/id_work
|
||||
user_name = A Name
|
||||
user_email = name@work.example
|
||||
token_file = ~/.config/gitea/work-token
|
||||
# Pushes to the gitea forge, and can reach nothing on GitHub.
|
||||
[identity.agent-gitea]
|
||||
ssh_key = ~/.ssh/id_agent_gitea
|
||||
user_name = dejima agent (gitea)
|
||||
user_email = agent-gitea@example.invalid
|
||||
token_file = ~/.config/gitea/agent-token
|
||||
|
||||
[identity.oss]
|
||||
ssh_key = ~/.ssh/id_oss
|
||||
user_email = name@personal.example
|
||||
push_token_file = ~/.config/gh/oss-push
|
||||
# A GitHub account, with push rights to the repositories this host works on.
|
||||
[identity.agent-github]
|
||||
ssh_key = ~/.ssh/id_agent_github
|
||||
user_name = dejima agent (github)
|
||||
user_email = agent-github@example.invalid
|
||||
push_token_file = ~/.config/gh/agent-push
|
||||
|
||||
# A second GitHub account, for a repository the one above may not touch.
|
||||
[identity.agent-vendor]
|
||||
ssh_key = ~/.ssh/id_agent_vendor
|
||||
user_name = dejima agent (vendor)
|
||||
user_email = agent-vendor@example.invalid
|
||||
push_token_file = ~/.config/gh/vendor-push
|
||||
```
|
||||
|
||||
Select one with `[core] identity = work`, or per launch with
|
||||
`--identity oss`.
|
||||
Select one with `[core] identity = agent-gitea`, or per launch with
|
||||
`--identity agent-github` - which is how one host runs a session against each
|
||||
of these at the same time, each pushing as itself.
|
||||
|
||||
**The separation is the point.** Three accounts means three things you can
|
||||
scope, revoke and read in a log independently: a session that turns out to
|
||||
have been prompt-injected reached what its own account could reach, and
|
||||
answering "what did it touch" does not start with "everything I can touch".
|
||||
|
||||
**What an identity may set** is deliberately narrow: `ssh_key`, `user_name`,
|
||||
`user_email`, and whichever keys the forges declare as a person's. A forge
|
||||
`user_email`, and whichever keys the forges declare as an account's. A forge
|
||||
says which of its own keys those are (`identity_keys` in its manifest), so
|
||||
`token_file` belongs to an identity on the gitea path and `push_token_file`
|
||||
does on the GitHub one, and neither is a list core keeps its own copy of.
|
||||
|
||||
**A key an identity does not set falls through to the file.** The `oss`
|
||||
identity above names no `token_file`, so `[forge.gitea] token_file` answers
|
||||
for it. An identity is an override for what it names, not a replacement for
|
||||
the section.
|
||||
**`default` is the account a session gets when none is named.** With `[core]
|
||||
identity` unset, an `[identity.default]` section is what a launch acts under -
|
||||
the ordinary account, given a name so it can carry the git name and the forge
|
||||
credential too.
|
||||
|
||||
**A name with no section is refused.** `[core] identity = wrok` does not
|
||||
**`[core] ssh_key` is that key under its old name, and is deprecated.** A
|
||||
config with no `[identity.default]` reads it as before; a config with both is
|
||||
refused rather than ranked, because which key a session pushed with should not
|
||||
depend on precedence rules. Every launch that sets it says so and prints the
|
||||
section to move it into - including launches running as some other identity,
|
||||
which are one `[identity.default]` away from that refusal.
|
||||
|
||||
**A credential does not fall through.** An identity that names some and leaves
|
||||
the rest to the section beneath it is refused, naming each one: an identity is
|
||||
how one session's account stays separate from another's. "Set" means written
|
||||
in the file, so a forge key carrying a schema default - `[forge.gitea]
|
||||
token_file` - still answers for an identity that names none.
|
||||
|
||||
**A name with no section is refused.** `[core] identity = agent-gihub` does not
|
||||
quietly use this machine's own credentials - it stops the launch and says so.
|
||||
Pushing under the wrong key fails loudly; pushing under your default key when
|
||||
you asked for another one does not fail at all, which is the direction worth
|
||||
@@ -469,10 +491,16 @@ being careful about.
|
||||
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
|
||||
touched, which was already true and is what the copy exists for.
|
||||
|
||||
This is the half of per-project configuration that a project cannot carry: a
|
||||
project's `.dejima/` is committed and shared with everyone working on it, and
|
||||
a key path is yours and your machine's. A project can say which identity it
|
||||
wants; only you can say where that identity's key lives.
|
||||
**Selection is the host's, not the project's.** There is no `.dejima/` kind
|
||||
for an identity and a project cannot name one: a project's `.dejima/` is
|
||||
committed and shared with everyone working on it, and both the credential and
|
||||
which account this host holds are that host's own. `[core] identity` and
|
||||
`--identity` are the whole of it today.
|
||||
|
||||
A project-side layer would be a different thing than it sounds - not "this
|
||||
project's credentials", which cannot be shared, but "this project wants
|
||||
whichever account you have called X". That does not exist yet, and this
|
||||
document will say so until it does.
|
||||
|
||||
## Headless git push without agent forwarding
|
||||
|
||||
@@ -502,15 +530,15 @@ the public half as a Gitea deploy key scoped to just the repos that need
|
||||
push access, then set it in the config:
|
||||
|
||||
```
|
||||
[core]
|
||||
[identity.default]
|
||||
ssh_key = ~/.ssh/dejima_ed25519
|
||||
```
|
||||
|
||||
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
||||
deletes the plaintext copy immediately after - the sandbox user itself never
|
||||
has a path to the raw key, only the agent socket (see
|
||||
[security notes](security.md) for the full mount rationale). Setting
|
||||
`[core] ssh_key` means a live forwarded agent is no longer required at all.
|
||||
[security notes](security.md) for the full mount rationale). Setting it means
|
||||
a live forwarded agent is no longer required at all.
|
||||
|
||||
## Using a different editor/application
|
||||
|
||||
@@ -612,13 +640,54 @@ colon is refused outright: both lists are colon-separated, so such a path
|
||||
cannot be represented in one at all and would otherwise split into two
|
||||
mounts that each fail the existence check.
|
||||
|
||||
## Suspending a session
|
||||
|
||||
Ctrl-Z backgrounds all of dejima - the container, the credential proxy, the
|
||||
conversation shipper - and `fg` brings back a working session, with the terminal
|
||||
put back the way the harness had it.
|
||||
|
||||
That takes doing, because a container run holds the terminal in raw mode, where
|
||||
Ctrl-Z is not a signal at all: it is a byte, delivered to whatever is running
|
||||
inside. A harness that implements Ctrl-Z itself then stops itself, and nothing
|
||||
in a container can continue it - there is no job-control shell in there. So
|
||||
dejima takes that one character back for the host while a session runs
|
||||
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
|
||||
still reach the session, which is how a harness expects to be interrupted.
|
||||
|
||||
Suspending is not detaching. A suspended session is stopped, so it makes no
|
||||
progress until you resume it, and it belongs to the terminal you started it
|
||||
from. For a session that keeps working while you are away, or that you want to
|
||||
pick up from somewhere else, run dejima inside tmux and use
|
||||
[`dejima-attach`](remote-access.md).
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
`~/shared` is a read-write bind mount of `[core] share_dir` (default
|
||||
`~/.local/share/dejima/shared`, created automatically) - separate
|
||||
from the project mount, for passing files (screenshots, temp scripts) in and
|
||||
out of a session without touching the actual checkout. Persists across
|
||||
sessions since it's a real directory, not a container volume.
|
||||
`~/shared` is a read-write bind mount of one project's directory under
|
||||
`[core] share_dir` (default `~/.local/share/dejima/shared`, created
|
||||
automatically) - separate from the project mount, for passing files
|
||||
(screenshots, temp scripts) in and out of a session without touching the actual
|
||||
checkout. Persists across sessions since it's a real directory, not a container
|
||||
volume.
|
||||
|
||||
**One directory per project, not one for the host.** A launch mounts
|
||||
`<share_dir>/projects/<slug>` at `~/shared`, where the slug is the project's
|
||||
absolute path with every non-alphanumeric character replaced by a dash - the
|
||||
same key the audit and epitaph stores use. So a session sees its own project's
|
||||
handoff files and no other project's, and nothing an agent has been told
|
||||
changes, because inside the container the path is still `~/shared`.
|
||||
|
||||
`dejima-share` is the host side of that:
|
||||
|
||||
```sh
|
||||
dejima-share path # the host directory for this project, created if new
|
||||
dejima-share path ~/code/x # …for another project
|
||||
dejima-share list # every project's share, with size and last write
|
||||
dejima-share adopt # move files left loose in share_dir into this project's
|
||||
```
|
||||
|
||||
**To hand a file *to* a session,** put it in `dejima-share path` before
|
||||
launching. That is what makes the inbound direction work without a mount that
|
||||
every project can read.
|
||||
|
||||
Treat it as a handoff point, not as general-purpose storage. If your own
|
||||
project instructions tell agents to write somewhere at a fixed host path -
|
||||
@@ -632,10 +701,20 @@ being rewritten to point at `~/shared`.
|
||||
only writable host paths in the container. Everything else a session writes
|
||||
goes away with it.
|
||||
|
||||
**Clear it out periodically.** It persists across sessions by design, which
|
||||
means anything you left in it is readable by every session after the one you
|
||||
left it for. A credential dropped in there to get one job done, and then
|
||||
forgotten, is a credential you have handed to every session since.
|
||||
**Clear it out periodically.** The per-project split bounds which sessions can
|
||||
read what you left, and bounds nothing about how long. A project's share
|
||||
persists by design, so anything in it is readable by every future session
|
||||
against that project: a credential dropped in there to get one job done, and
|
||||
then forgotten, is a credential you have handed to every session against that
|
||||
project since. Nothing prunes `share_dir` - `dejima-share list` prints each
|
||||
project's size and the date of its last write, and deleting is yours.
|
||||
|
||||
**Files from before the split.** `share_dir` used to be mounted whole, so
|
||||
anything at its top level was visible to every session. Those files are still
|
||||
there and are now mounted into nothing; dejima says so at every launch while
|
||||
that is true, rather than moving an operator's files on its own.
|
||||
`dejima-share adopt <project>` moves them into one project's share after
|
||||
showing what it will move and asking, and it never overwrites.
|
||||
|
||||
## Reading what a session left behind
|
||||
|
||||
|
||||
+18
-4
@@ -25,7 +25,7 @@ run them.
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
|
||||
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
|
||||
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
||||
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
||||
@@ -40,6 +40,8 @@ run them.
|
||||
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
||||
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
|
||||
| `bin/dejima-color-lib.sh` | The one terminal palette, and the glyphs that ride the same switch. Indexed colour, not 24-bit: gruvbox is the brand and an ANSI slot **is** gruvbox on a gruvbox terminal, but hardcoding the hexes assumes a background dejima does not own. `web/src/page.css` is the exception, since the page sets its own. Not on PATH |
|
||||
| `bin/dejima-tui-lib.sh` | The palette, heading and numbered prompt `dejima-attach` and `dejima-allow` share. Rows stay with each tool, which lists a different thing from a different source. Not on PATH |
|
||||
| `bin/dejima-update-check` | Host-side: says at startup when this checkout is behind the branch it tracks. Reaches the remote by URL from outside the repository, so the checkout's own git config never names a command it runs |
|
||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
|
||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||
@@ -149,11 +151,23 @@ attached: read what `gitea-pr` printed.
|
||||
### Running the tests
|
||||
|
||||
```sh
|
||||
bats test/ # everything
|
||||
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
|
||||
bash test/run-confined.sh # every confined suite - the pull-request gate
|
||||
bash test/run-confined.sh --changed # only the suites your diff calls for
|
||||
bats test/audit.bats # one suite - the suites are independent
|
||||
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
|
||||
```
|
||||
|
||||
`test/run-confined.sh` runs the files concurrently and, within each file, the
|
||||
tests too, via bats' own `--jobs`. Adding `--jobs $(nproc)
|
||||
--no-parallelize-across-files` to a bare `bats` invocation gets the same effect
|
||||
for a single suite; both halves of that are needed, because bats otherwise
|
||||
requires GNU parallel for `--jobs` and only the across-files half actually does.
|
||||
|
||||
`--changed` is for iterating. It maps the diff to suites through `suites_for()`
|
||||
in that script, prints which lane it ran, and says plainly that it is not the
|
||||
gate - a change is tested when the whole set has run, not when the selected
|
||||
subset has.
|
||||
|
||||
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
|
||||
that builds the image and boots real containers to assert against.
|
||||
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
|
||||
@@ -487,7 +501,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` |
|
||||
|
||||
@@ -25,9 +25,15 @@
|
||||
# 2. Authentication. ttyd is started with no credential of its own (see
|
||||
# bin/dejima-ttyd), because it is bound to 127.0.0.1 where only this
|
||||
# host can reach it - this proxy is the only gate in front of it. A
|
||||
# vhost without auth_basic (or something stronger in its place) is an
|
||||
# unauthenticated host shell on your LAN. Put every listen behind it:
|
||||
# a second server block without auth is the same open door.
|
||||
# vhost that authenticates nothing is an unauthenticated host shell on
|
||||
# your LAN. Put every listen behind it: a second server block without
|
||||
# auth is the same open door.
|
||||
#
|
||||
# This file uses HTTP Basic because it is the shortest thing to show.
|
||||
# One shared credential, no per-operator identity, no second factor,
|
||||
# revocation by editing htpasswd. docs/remote-access.md, under
|
||||
# "Authenticating the front door", is what that costs and what else
|
||||
# meets the requirement.
|
||||
#
|
||||
# Authentication alone does not stop a page in your browser from
|
||||
# opening a WebSocket to this vhost and having the browser attach your
|
||||
|
||||
+29
-7
@@ -76,6 +76,7 @@ value never does:
|
||||
| `export` | `NAME=key` or `NAME=@key` for the `env_export` forge key - see [Forge](#forge) below. |
|
||||
| `template` | Prose that may contain `{{placeholder}}` markers; the placeholder names are checked against the briefing whitelist at composition time, not here. |
|
||||
| `keyspec` | One element of a `keys` list - see [The `keys` field](#the-keys-field-a-providers-own-config-schema) below. |
|
||||
| `repopath` | `<subcommand>:<first path segment>` for the `repo_paths` forge key - where a repository appears in a path rather than behind a flag. |
|
||||
| `list:TYPE` | Space-separated (not colon-separated, unlike a config-file list) elements, each checked against `TYPE`. |
|
||||
|
||||
A leading `?` on any type (`?hostname`, `?list:identifier`, ...) makes the
|
||||
@@ -123,9 +124,10 @@ operator can set, resolved the same way as any other config key
|
||||
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
|
||||
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
|
||||
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
|
||||
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling (`--repo x`, `--repo=x`, and a one-dash flag with its value attached). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
|
||||
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` belong to a **person** rather than to the project - the credential paths. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
|
||||
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
|
||||
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
|
||||
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
|
||||
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
|
||||
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
|
||||
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
|
||||
@@ -172,7 +174,7 @@ as environment variables.
|
||||
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
|
||||
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
|
||||
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
|
||||
| `block_merge` | `bool` | `off` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. |
|
||||
| `block_merge` | `bool` | `on` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. Defaults on so a workflow that omits it refuses a merge rather than permitting one; every shipped workflow sets it anyway. |
|
||||
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
|
||||
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
|
||||
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
|
||||
@@ -509,13 +511,33 @@ knowing about because they are what a second forge costs:
|
||||
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
|
||||
gitea CLIs refuse an origin that is not on the configured host, so they can
|
||||
only act on the repo the sandbox was launched against. `gh` takes `--repo`
|
||||
on nearly every subcommand and reads `GH_REPO`, so this forge declares both
|
||||
(`repo_flags`, `repo_env`) and `docker/forge-guard.sh` refuses a value that
|
||||
is not the launched-against repository. Fast-fail, not a boundary, exactly
|
||||
as `merge_block` is: the session holds the token and could call the API
|
||||
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
|
||||
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
|
||||
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
|
||||
launched-against repository. Fast-fail, not a boundary, exactly as
|
||||
`merge_block` is: the session holds the token and could call the API
|
||||
directly. What actually bounds it is the token's scope, so a per-repository
|
||||
fine-grained PAT is the expected shape of `push_token_file`.
|
||||
|
||||
**What it sees, and what it does not.** Worth stating both, because a guard
|
||||
described only by what it catches reads like a boundary. It compares, case
|
||||
-insensitively: `--repo x` and `--repo=x`; `-R x` and `-Rx`; the same letter
|
||||
inside a short-flag cluster, `-wR x` and `-wRx`; `GH_REPO`; and
|
||||
`gh api repos/<owner>/<name>/...`, with or without a leading slash. A launch
|
||||
that resolved no repository at all refuses every one of those rather than
|
||||
waving them through.
|
||||
|
||||
It does not see a GraphQL `mutation` sent through `gh api graphql`, a
|
||||
repository named in an `--jq` expression or a `-F`/`-f` field, an API path
|
||||
under a segment other than `repos/`, or `gh` invoked through something else
|
||||
that passes the repository along. Nor can it know that the workspace's
|
||||
`origin` is the repository you meant: it compares against whatever origin
|
||||
the checkout has, so a workspace pointing somewhere unexpected shifts what
|
||||
counts as "this repository" rather than being caught by this. That list is
|
||||
not exhaustive either, and it is not meant to be - widening it further would
|
||||
mean parsing each subcommand's flags, which is the coupling this guard
|
||||
exists to avoid. The token's scope is what bounds the ones it misses.
|
||||
|
||||
So: if you have a choice, `gitea` is the better-worn path. Choose `github`
|
||||
because that is where your repositories are, which is a good reason - and
|
||||
expect to be the person who finds the next crack.
|
||||
|
||||
+450
-217
@@ -36,9 +36,9 @@ attached. The two things holding that line are:
|
||||
- **[The bind address](#the-bind-address-is-the-one-thing-to-get-right).**
|
||||
`ttyd` binds `127.0.0.1` and your proxy must bind one specific LAN
|
||||
address - never the wildcard, never a Docker bridge.
|
||||
- **Authentication on the proxy.** `ttyd` carries no credential of its own,
|
||||
so a `server` block without `auth_basic` is an unauthenticated host shell
|
||||
for everyone on that network.
|
||||
- **[Authentication on the proxy](#authenticating-the-front-door).** `ttyd`
|
||||
carries no credential of its own, so a `server` block that authenticates
|
||||
nothing is an unauthenticated host shell for everyone on that network.
|
||||
|
||||
What it does **not** add is a way *out* of a sandbox. Configured as
|
||||
described below, a compromised session cannot reach `ttyd`, cannot reach
|
||||
@@ -50,194 +50,8 @@ front door opening *inward*, and that is a full sandbox escape rather than a
|
||||
degradation. [Verifying it](#verifying-it) is two commands, and worth
|
||||
running.
|
||||
|
||||
## `[remote-access]`
|
||||
|
||||
A section of the single config file, at `~/.config/dejima/config`
|
||||
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
|
||||
approvals):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
|
||||
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
|
||||
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
|
||||
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
|
||||
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
|
||||
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
|
||||
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
|
||||
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
|
||||
|
||||
**If this host sits behind a firewall it does not control**, the push has to
|
||||
be permitted outbound before any of this works. `dejima-notify-watch` runs on
|
||||
the host, not in the container, so the container's own allowlist has nothing
|
||||
to do with whether it can reach your ntfy server - and a self-hosted one on a
|
||||
private address is exactly the case a host-level egress filter drops.
|
||||
|
||||
That failure is the quiet kind. Nothing errors, the config is correct,
|
||||
`systemctl --user status dejima-notify-watch` looks healthy, and the phone
|
||||
simply never buzzes. If you administer that filter, the rule is yours to add;
|
||||
if someone else does, this is the thing to ask them about before re-reading
|
||||
your config a third time.
|
||||
|
||||
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://`
|
||||
scheme; a value without one is refused rather than guessed at, because
|
||||
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
|
||||
from that file instead. Values are restricted to
|
||||
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
|
||||
end up in an HTTP header and in a URL handed to a phone.
|
||||
|
||||
**A malformed config is a hard failure, not a warning.** The whole file is
|
||||
validated on every read, not just the key being asked for: an unknown key, a
|
||||
value outside that character set, or a line that doesn't parse makes
|
||||
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
|
||||
was wrong. A `ttyd_port` that isn't a number in 1-65535 is refused the same
|
||||
way, as is a `ttyd_font_size` outside 8-72 - a size big enough to make one
|
||||
glyph wider than the viewport renders a terminal that shows nothing, which
|
||||
reads as a broken front door rather than as a typo three sections away. A
|
||||
`ttyd_tmux_prefix` that is not a control chord is refused here too, rather
|
||||
than on the phone, hours later, by whoever presses `PREFIX` and gets nothing.
|
||||
|
||||
`dejima-ttyd` and `dejima-notify-watch` are the two scripts that read a
|
||||
*value* out of the config, so a typo anywhere in the file refuses their
|
||||
start. `dejima` reads values too (`audit dir` among them) and refuses the
|
||||
same way. `dejima-attach` alone only asks `remote_enabled` - deliberately
|
||||
bypassing the whole-file validation, so a typo elsewhere in the config can't
|
||||
lock you out of the terminal you'd fix it from.
|
||||
|
||||
```
|
||||
# ~/.config/dejima/config - substitute your own topic and hostname
|
||||
[remote-access]
|
||||
enabled = on
|
||||
ntfy_url = https://ntfy.example.invalid/your-topic-here
|
||||
base_url = https://dejima.example.invalid
|
||||
ttyd_port = 7681
|
||||
# ttyd_font_size = 20 # optional, and applies to the desktop too
|
||||
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
|
||||
```
|
||||
|
||||
**Editing this changes nothing until the services restart.** Every value
|
||||
is read once, at process start - `dejima-ttyd` reads `ttyd_port`,
|
||||
`ttyd_font_size` and `ttyd_tmux_prefix` and then `exec`s `ttyd` with them
|
||||
baked into its argv. So:
|
||||
|
||||
```sh
|
||||
systemctl --user restart dejima-ttyd.service
|
||||
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
|
||||
pgrep -a ttyd # confirm the argv is what you just configured
|
||||
```
|
||||
|
||||
Skipping this is quiet rather than loud: nothing warns, and the front door
|
||||
keeps serving with the old settings, which reads as "the setting had no
|
||||
effect" rather than as "the process is older than the file".
|
||||
|
||||
## How it works
|
||||
|
||||
Almost none of this is in the sandbox. The container writes one empty file
|
||||
and knows nothing else about the feature; everything that turns that into a
|
||||
notification, and everything that puts a terminal in front of you
|
||||
afterwards, runs on the host as your own user.
|
||||
|
||||
### The session asks you something
|
||||
|
||||
```
|
||||
┌─ the sandbox container ────────────────────────────────────────────┐
|
||||
│ claude code, at a Notification or Stop hook │
|
||||
│ │ │
|
||||
│ │ hooks/dejima-notify.sh writes an empty file named │
|
||||
│ ▼ `waiting` or `done` - no content, no stdin, no network │
|
||||
└───────┼────────────────────────────────────────────────────────────┘
|
||||
════════╪═══ container boundary - one bind-mounted directory ═════════
|
||||
┌───────▼─── your machine, as your user ─────────────────────────────┐
|
||||
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/waiting │
|
||||
│ │ │
|
||||
│ │ inotify, or a ~6s poll where inotify-tools is absent │
|
||||
│ ▼ │
|
||||
│ dejima-notify-watch (systemd --user) │
|
||||
│ │ consumes the marker by NAME; never reads its content │
|
||||
│ │ every byte of the push is composed from host-side data │
|
||||
│ ▼ │
|
||||
└───────┼────────────────────────────────────────────────────────────┘
|
||||
════════╪═══════════════ off your machine ════════════════════════════
|
||||
│ curl
|
||||
▼
|
||||
your ntfy topic ──── push ────► your phone
|
||||
"dejima: <project> needs input"
|
||||
link: <base_url>/?arg=<run-id>
|
||||
```
|
||||
|
||||
The marker directory is the sandbox's entire surface here, and it is
|
||||
one-way: a name, out. Nothing the session writes reaches the push, which is
|
||||
why the notification says a project needs input and never what it is asking.
|
||||
[Notifications](#notifications) has the detail, including where that
|
||||
guarantee actually lives.
|
||||
|
||||
### You come back
|
||||
|
||||
```
|
||||
browser, on the link from the push any plain SSH client
|
||||
│ │
|
||||
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
|
||||
│ HTTP Basic over TLS │ key with a
|
||||
▼ ▼ forced command
|
||||
════════╪══════════════════ your LAN ══════════════════╪══════════════
|
||||
┌─ your machine, as your user ───────────────────────────────────────┐
|
||||
│ nginx, listening on ONE LAN address sshd │
|
||||
│ TLS + auth_basic, plus a default_server authorized_keys with │
|
||||
│ that refuses every other name a forced command │
|
||||
│ │ │ │
|
||||
│ │ proxy_pass http://127.0.0.1:7681 │ │
|
||||
│ ▼ │ │
|
||||
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────┬───────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ dejima-attach [<run-id>] │
|
||||
│ │ a grouped tmux session │
|
||||
│ ▼ │
|
||||
│ YOUR TMUX SERVER │
|
||||
│ the sandbox's window - and every other │
|
||||
│ window on it, one prefix key away │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
═════════════════════ container boundary ═════════════════════════════
|
||||
┌─ the sandbox container ────────────────────────────────────────────┐
|
||||
│ routes to the Docker bridge gateway - your host - and to nothing │
|
||||
│ else of yours. Neither front door is on that address, which is │
|
||||
│ the whole reason `ttyd` is on loopback and nginx is on one │
|
||||
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
|
||||
│ host shell in here. │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The web door is the one the notification links into: the browser hits your
|
||||
reverse proxy, which authenticates and forwards to a `ttyd` that only this
|
||||
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
|
||||
from `?arg=`. The SSH door skips all of that. It is a key in your own
|
||||
`authorized_keys` pinned to a forced command, so a plain SSH app on the
|
||||
phone runs `dejima-attach` and nothing else no matter what it asks to run,
|
||||
and lands on a numbered picker of live sessions. It needs no proxy, no
|
||||
`ttyd`, and no notification to have been sent.
|
||||
|
||||
Both converge on `dejima-attach`, and both end at *your* tmux server. That
|
||||
convergence is the point: the controls worth spending effort on are the two
|
||||
front doors, because behind them there is no further boundary to cross - a
|
||||
grouped tmux session shares every window on the server, and the prefix key
|
||||
opens new ones regardless.
|
||||
|
||||
### What `off` means
|
||||
|
||||
With `[remote-access] enabled` off (the default), `dejima` builds a
|
||||
`docker compose` invocation with nothing remote-access in it: no
|
||||
session state is written, no marker directory is created or mounted, and no
|
||||
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
|
||||
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
|
||||
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
|
||||
and starts nothing.
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
This is the step that carries the whole feature's security, and the only one
|
||||
where a plausible-looking choice is a full sandbox escape.
|
||||
|
||||
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
|
||||
container's default-route address - the Docker bridge gateway, which is your
|
||||
host. That is the one host address a sandbox can reach at all. Combine it
|
||||
@@ -320,6 +134,72 @@ asserts this same containment property against a live container (with a
|
||||
positive control on the same path, so a passing result can't come from the
|
||||
container reaching nothing), and it passes on a real Docker host.
|
||||
|
||||
## `[remote-access]`
|
||||
|
||||
A section of the single config file, at `~/.config/dejima/config`
|
||||
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
|
||||
approvals):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
|
||||
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
|
||||
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
|
||||
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
|
||||
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
|
||||
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
|
||||
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
|
||||
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
|
||||
|
||||
```
|
||||
# ~/.config/dejima/config - substitute your own topic and hostname
|
||||
[remote-access]
|
||||
enabled = on
|
||||
ntfy_url = https://ntfy.example.invalid/your-topic-here
|
||||
base_url = https://dejima.example.invalid
|
||||
ttyd_port = 7681
|
||||
# ttyd_font_size = 20 # optional, and applies to the desktop too
|
||||
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
|
||||
```
|
||||
|
||||
**Editing this changes nothing until the services restart.** Every value is
|
||||
read once, at process start, and baked into `ttyd`'s argv. Skipping the restart
|
||||
is quiet rather than loud: the front door keeps serving with the old settings,
|
||||
which reads as "the setting had no effect".
|
||||
|
||||
```sh
|
||||
systemctl --user restart dejima-ttyd.service
|
||||
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
|
||||
pgrep -a ttyd # confirm the argv is what you just configured
|
||||
```
|
||||
|
||||
**If this host sits behind a firewall it does not control**, the push has to be
|
||||
permitted outbound before any of this works. `dejima-notify-watch` runs on the
|
||||
host, not in the container, so the container's own allowlist has nothing to do
|
||||
with whether it can reach your ntfy server - and a self-hosted one on a private
|
||||
address is exactly the case a host-level egress filter drops. That failure is
|
||||
the quiet kind: nothing errors, `systemctl --user status dejima-notify-watch`
|
||||
looks healthy, and the phone simply never buzzes.
|
||||
|
||||
**A malformed config is a hard failure, not a warning.** The whole file is
|
||||
validated on every read, not just the key being asked for, so an unknown key or
|
||||
an unparseable line makes `dejima-ttyd` and `dejima-notify-watch` refuse to
|
||||
start and say which line was wrong. So does an out-of-range `ttyd_port`
|
||||
(1-65535) or `ttyd_font_size` (8-72), or a `ttyd_tmux_prefix` that is not a
|
||||
control chord - refused here rather than on the phone, hours later, by whoever
|
||||
presses `PREFIX` and gets nothing.
|
||||
|
||||
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://` scheme
|
||||
and are restricted to `A-Za-z0-9:/._~?=%@+-`. A value without a scheme is
|
||||
refused rather than guessed at, because `curl` reads a leading `-K/path` as an
|
||||
*option* that takes the request's URL from that file instead; the character set
|
||||
is because these end up in an HTTP header and in a URL handed to a phone.
|
||||
|
||||
`dejima` itself refuses the same way, since it reads values too.
|
||||
`dejima-attach` alone reads only `remote_enabled`, deliberately bypassing the
|
||||
whole-file validation, so a typo elsewhere in the config can't lock you out of
|
||||
the terminal you'd fix it from.
|
||||
|
||||
## SSH front door
|
||||
|
||||
Generate a key that exists only for this, and give it a forced command:
|
||||
@@ -441,8 +321,10 @@ and forgotten. What matters in it:
|
||||
|
||||
- **Authentication on every server block.** `ttyd` is started with no
|
||||
credential of its own, because the loopback bind means only this host can
|
||||
reach it - the proxy is the entire gate. A block without `auth_basic` is
|
||||
an unauthenticated host shell on your network.
|
||||
reach it - the proxy is the entire gate. A block that authenticates nothing
|
||||
is an unauthenticated host shell on your network. The example uses HTTP
|
||||
Basic; [Authenticating the front door](#authenticating-the-front-door) is
|
||||
what that costs and what else meets the requirement.
|
||||
- **A `default_server` that refuses.** nginx picks a server block by
|
||||
SNI/`Host`, and when nothing matches it serves the **default** for that
|
||||
listen address - which, if the front door is the only block on it, is the
|
||||
@@ -465,6 +347,163 @@ and forgotten. What matters in it:
|
||||
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
|
||||
nginx's default is 60 seconds.
|
||||
|
||||
**The page carries its own colours.** dejima's terminal output uses indexed
|
||||
colour so your terminal's theme fills the slots - but a browser has no theme
|
||||
to inherit, so the front door is handed gruvbox explicitly: all sixteen ANSI
|
||||
slots plus background, foreground and cursor. Without it xterm.js draws with
|
||||
its own defaults, and the same session looks one way over SSH and another in
|
||||
the browser. `bin/dejima-ttyd` sets it, and `web/src/page.css` carries the
|
||||
same values for the chrome around it.
|
||||
|
||||
## Authenticating the front door
|
||||
|
||||
`ttyd` binds loopback and carries no credential, so whatever sits in front of
|
||||
it is the entire gate. The requirement is that it authenticates **every
|
||||
request to every server block, the default one included** - not that it is any
|
||||
particular software. These are the shapes that meet it, roughly in order of
|
||||
how much they change.
|
||||
|
||||
**A network the public internet cannot reach.** Tailscale, WireGuard, or any
|
||||
VPN that puts the listener on an interface nothing outside can route to.
|
||||
First because it removes the exposure rather than authenticating it, because
|
||||
it composes with everything below, and because for a lot of readers it is both
|
||||
the simplest answer and the strongest. It does not replace [the bind
|
||||
address](#the-bind-address-is-the-one-thing-to-get-right): a VPN interface is
|
||||
still one specific address to bind, and the wildcard is still wrong.
|
||||
|
||||
**HTTP Basic**, which is what the example config shows. One shared credential,
|
||||
no per-operator identity, no second factor, revocation that means editing
|
||||
`htpasswd`, and an access log that cannot tell two people apart. For a solo
|
||||
operator that is a reasonable trade, and worth saying that it is one; for
|
||||
anyone else it is the first thing to replace. It also sends the password on
|
||||
every request, which is why TLS is not optional here.
|
||||
|
||||
**Client certificates.** `ssl_client_certificate` and `ssl_verify_client on`
|
||||
in the same block. Per-device identity, revocable through a CRL, and nothing
|
||||
worth stealing sent on every request. The cost is distribution and renewal,
|
||||
and a phone that has to have the certificate installed - the part that will
|
||||
bite, and the part that differs per platform.
|
||||
|
||||
**A forward-auth proxy.** oauth2-proxy or Authelia via nginx `auth_request`:
|
||||
OIDC, per-user identity, and a session cookie rather than a credential on
|
||||
every request. Also where passkeys realistically arrive, since the proxy
|
||||
handles WebAuthn and `ttyd` never learns about it. The cost is another moving
|
||||
piece in the path to a host shell, which is worth naming given what is on the
|
||||
other side of it.
|
||||
|
||||
**Something you already run.** An identity-aware gateway, or an
|
||||
authenticating proxy already in the path. The requirement above is the whole
|
||||
of what this document asks of it.
|
||||
|
||||
**None of these stops a browser that has already authenticated.** Whatever is
|
||||
in front, a page loaded in a session that has passed it can act - which is the
|
||||
same property that makes the front door usable from a phone you unlocked an
|
||||
hour ago. Authentication decides who gets a shell, not what an already-open
|
||||
tab does with one.
|
||||
|
||||
## How it works
|
||||
|
||||
Almost none of this is in the sandbox. The container writes one empty file
|
||||
and knows nothing else about the feature; everything that turns that into a
|
||||
notification, and everything that puts a terminal in front of you
|
||||
afterwards, runs on the host as your own user.
|
||||
|
||||
### The session asks you something
|
||||
|
||||
```
|
||||
┌─ the sandbox container ────────────────────────────────────────────┐
|
||||
│ claude code, at a Stop hook │
|
||||
│ │ │
|
||||
│ │ hooks/dejima-notify.sh writes an empty file named │
|
||||
│ ▼ `done` - no content, no stdin, no network │
|
||||
└───────┼────────────────────────────────────────────────────────────┘
|
||||
════════╪═══ container boundary - one bind-mounted directory ═════════
|
||||
┌───────▼─── your machine, as your user ─────────────────────────────┐
|
||||
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/done │
|
||||
│ │ │
|
||||
│ │ inotify, or a ~6s poll where inotify-tools is absent │
|
||||
│ ▼ │
|
||||
│ dejima-notify-watch (systemd --user) │
|
||||
│ │ consumes the marker by NAME; never reads its content │
|
||||
│ │ every byte of the push is composed from host-side data │
|
||||
│ ▼ │
|
||||
└───────┼────────────────────────────────────────────────────────────┘
|
||||
════════╪═══════════════ off your machine ════════════════════════════
|
||||
│ curl
|
||||
▼
|
||||
your ntfy topic ──── push ────► your phone
|
||||
"dejima: <project> finished"
|
||||
link: <base_url>/?arg=<run-id>
|
||||
```
|
||||
|
||||
The marker directory is the sandbox's entire surface here, and it is
|
||||
one-way: a name, out. Nothing the session writes reaches the push, which is
|
||||
why the notification says a project has stopped and never what it is asking.
|
||||
[Notifications](#notifications) has the detail, including where that
|
||||
guarantee actually lives.
|
||||
|
||||
### You come back
|
||||
|
||||
```
|
||||
browser, on the link from the push any plain SSH client
|
||||
│ │
|
||||
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
|
||||
│ HTTP Basic over TLS │ key with a
|
||||
▼ ▼ forced command
|
||||
════════╪══════════════════ your LAN ══════════════════╪══════════════
|
||||
┌─ your machine, as your user ───────────────────────────────────────┐
|
||||
│ nginx, listening on ONE LAN address sshd │
|
||||
│ TLS + auth, plus a default_server authorized_keys with │
|
||||
│ that refuses every other name a forced command │
|
||||
│ │ │ │
|
||||
│ │ proxy_pass http://127.0.0.1:7681 │ │
|
||||
│ ▼ │ │
|
||||
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────┬───────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ dejima-attach [<run-id>] │
|
||||
│ │ a grouped tmux session │
|
||||
│ ▼ │
|
||||
│ YOUR TMUX SERVER │
|
||||
│ the sandbox's window - and every other │
|
||||
│ window on it, one prefix key away │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
═════════════════════ container boundary ═════════════════════════════
|
||||
┌─ the sandbox container ────────────────────────────────────────────┐
|
||||
│ routes to the Docker bridge gateway - your host - and to nothing │
|
||||
│ else of yours. Neither front door is on that address, which is │
|
||||
│ the whole reason `ttyd` is on loopback and nginx is on one │
|
||||
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
|
||||
│ host shell in here. │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The web door is the one the notification links into: the browser hits your
|
||||
reverse proxy, which authenticates and forwards to a `ttyd` that only this
|
||||
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
|
||||
from `?arg=`. The SSH door skips all of that. It is a key in your own
|
||||
`authorized_keys` pinned to a forced command, so a plain SSH app on the
|
||||
phone runs `dejima-attach` and nothing else no matter what it asks to run,
|
||||
and lands on a numbered picker of live sessions. It needs no proxy, no
|
||||
`ttyd`, and no notification to have been sent.
|
||||
|
||||
Both converge on `dejima-attach`, and both end at *your* tmux server. That
|
||||
convergence is the point: the controls worth spending effort on are the two
|
||||
front doors, because behind them there is no further boundary to cross - a
|
||||
grouped tmux session shares every window on the server, and the prefix key
|
||||
opens new ones regardless.
|
||||
|
||||
### What `off` means
|
||||
|
||||
With `[remote-access] enabled` off (the default), `dejima` builds a
|
||||
`docker compose` invocation with nothing remote-access in it: no
|
||||
session state is written, no marker directory is created or mounted, and no
|
||||
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
|
||||
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
|
||||
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
|
||||
and starts nothing.
|
||||
|
||||
## Reading it on a phone
|
||||
|
||||
`dejima-ttyd` launches `ttyd` with `--index web/index.html`, which replaces
|
||||
@@ -474,15 +513,13 @@ there is nothing to turn on.
|
||||
`ttyd`'s own page is close to unusable on a phone for anything beyond typing,
|
||||
and not because of a setting anyone missed. xterm.js turns a tap into a mouse
|
||||
click, but a touch *drag* is consumed by the browser as viewport scrolling and
|
||||
never becomes a mouse drag - so nothing downstream ever sees one. tmux binds
|
||||
`MouseDrag1Border` to `resize-pane -M` by default and nvim resizes splits from
|
||||
separator drags whenever `mouse` is on; both have always worked, and simply
|
||||
never received a drag event from a touch client. The same missing drag is why
|
||||
selecting text is impossible, and scrolling an application's output with it.
|
||||
Then there is no Ctrl key, the tmux status bar's window names are a few pixels
|
||||
wide, and `ttyd` serves no viewport meta tag at all - so a phone lays the page
|
||||
out at a virtual desktop width and scales the result down, which no font size
|
||||
can rescue.
|
||||
never becomes a mouse drag, so nothing downstream ever sees one: tmux's
|
||||
`resize-pane -M` and nvim's split-separator drags have always worked and simply
|
||||
never received the event. The same missing drag is why selecting text and
|
||||
scrolling an application's output are impossible. Then there is no Ctrl key,
|
||||
the tmux status bar's window names are a few pixels wide, and `ttyd` serves no
|
||||
viewport meta tag at all, so a phone lays the page out at a virtual desktop
|
||||
width and scales the result down, which no font size can rescue.
|
||||
|
||||
### Gestures
|
||||
|
||||
@@ -525,7 +562,10 @@ PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
|
||||
without closing the tab. It reloads the page without the run-id, which is
|
||||
what makes `dejima-attach` show the list again. The session you leave is
|
||||
unaffected: each client gets its own tmux session grouped onto the real one,
|
||||
and only that view is reaped when you detach.
|
||||
and only that view is reaped when you detach. The picker arrives on a
|
||||
cleared screen, scrollback included - it is a page load rather than a
|
||||
scroll, so otherwise it draws over the tail of the session you just left and
|
||||
a swipe up lands in the middle of it.
|
||||
- `ZOOM` is prefix + `z`, toggling tmux's pane zoom - the fastest way to make
|
||||
one pane usable on a small screen.
|
||||
- `PASTE` reads the phone's clipboard and sends it, wrapped in bracketed-paste
|
||||
@@ -705,8 +745,8 @@ cost of losing the correction everywhere else.
|
||||
Terminating TLS somewhere else and forwarding here is a supported shape, but
|
||||
read this first: it moves the front door out of the LAN threat model
|
||||
everything above is written against. If the outer proxy is internet-facing,
|
||||
`auth_basic` over TLS is now the only thing between the public internet and
|
||||
a host shell. Put something stronger in front of it, and take the
|
||||
whatever authenticates it is now the only thing between the public internet
|
||||
and a host shell, and HTTP Basic is a thin thing for that to be. Put something stronger in front of it, and take the
|
||||
[security notes](security.md) as the floor rather than the ceiling.
|
||||
|
||||
The mechanical part has four requirements at the outer hop, and each one
|
||||
@@ -758,14 +798,24 @@ what the outer proxy sends.
|
||||
|
||||
## Notifications
|
||||
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
|
||||
hook for a remote-enabled session, into the throwaway settings override layer
|
||||
it already generates - never into your real `~/.claude/settings.json`. Your
|
||||
own hooks keep running alongside it, since Claude Code merges hooks across
|
||||
layers rather than replacing. The layer is mounted read-only, so a session
|
||||
cannot unregister its own hook. The hook writes one empty file named
|
||||
`waiting` or `done` into this run's marker directory: no content, no stdin,
|
||||
no network.
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Stop` hook for a
|
||||
remote-enabled session, into the throwaway settings override layer it already
|
||||
generates - never into your real `~/.claude/settings.json`. Your own hooks
|
||||
keep running alongside it, since Claude Code merges hooks across layers rather
|
||||
than replacing. The layer is mounted read-only, so a session cannot
|
||||
unregister its own hook. The hook writes one empty file named `done` into
|
||||
this run's marker directory: no content, no stdin, no network.
|
||||
|
||||
**One push per stop, and not a second one a minute later.** The hook and the
|
||||
watcher both understand a `waiting` event as well, and Claude Code's
|
||||
`Notification` hook is where it would come from - but that hook fires when
|
||||
Claude needs permission to run something and when the prompt has sat idle,
|
||||
and a dejima session runs with permission prompts off. Only the timer is
|
||||
left, and it expires shortly after the Stop it follows, so registering both
|
||||
sent two pushes for one event with the second saying nothing the first had
|
||||
not. What is given up is being told about a session that has gone idle
|
||||
without having stopped first, which Claude Code cannot currently distinguish;
|
||||
a harness that can may register `waiting` and get its own notification.
|
||||
|
||||
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
|
||||
Same installation shape as above:
|
||||
@@ -811,8 +861,9 @@ than a new exposure, but a RAM-backed filesystem fails harder and sooner.
|
||||
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
logging out, which discards the tmpfs) is the recovery.
|
||||
|
||||
**What a notification contains.** A title of `dejima: <project> needs input`
|
||||
(or `dejima: <project> finished`), a fixed one-line body, and - when
|
||||
**What a notification contains.** A title of `dejima: <project> finished`
|
||||
(or `dejima: <project> needs input`, for a harness that registers `waiting`),
|
||||
a fixed one-line body, and - when
|
||||
`base_url` is set - a click link of `<base_url>/?arg=<run-id>` that opens the
|
||||
web front door already attached to that session. `<project>` is the
|
||||
workspace directory's name, taken from the host-side registry and reduced to
|
||||
@@ -860,9 +911,9 @@ the next one is worth checking.
|
||||
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
|
||||
container with remote access off - `[remote-access] enabled` was off at
|
||||
launch, and no later change affects a session already running.
|
||||
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
|
||||
the throwaway override layer, not in your real `settings.json`:
|
||||
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
|
||||
2. **Is the hook registered?** The `Stop` entry lives in the throwaway
|
||||
override layer, not in your real `settings.json`:
|
||||
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 1.
|
||||
That file is passed to the harness by `harness-wrapper.sh`, as whatever
|
||||
its manifest's `settings_args` names (`--settings` for Claude Code);
|
||||
if it is missing entirely, this container was not started by `dejima`.
|
||||
@@ -883,7 +934,12 @@ the next one is worth checking.
|
||||
with a 404 usually means `ntfy_url` names the server but no topic; a 401
|
||||
or 403 means the instance wants credentials, which the URL can carry as
|
||||
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
|
||||
header of its own.
|
||||
header of its own. That URL is handed to `curl` on stdin rather than as an
|
||||
argument, because every argument of every process on a host is readable by
|
||||
any local account through `/proc/<pid>/cmdline`, and a token in a URL is a
|
||||
token in `ps` output for as long as the request runs. Worth knowing if you
|
||||
reach for `curl -sv "$ntfy_url"` yourself in the next step: that one does
|
||||
put it there, briefly, on a machine you are already logged into.
|
||||
5. **Can this host reach the ntfy server at all?** `curl -sv "$ntfy_url"`
|
||||
from the host, not from a session. A host-level egress filter this machine
|
||||
does not control drops the push with nothing in any log here, and every
|
||||
@@ -954,3 +1010,180 @@ from shrinking the desktop's view, and the option's only scopes are the
|
||||
window - which a grouped session *shares* with yours, so setting it there
|
||||
would permanently overwrite your choice with nothing to restore afterwards -
|
||||
and global. An operator who deliberately set `smallest` keeps it.
|
||||
|
||||
## Mounting the box's files
|
||||
|
||||
The front doors above get you a terminal. The other half of working from
|
||||
another machine is the files - the screenshots, specs and scratch output a
|
||||
session leaves behind - and `sshfs` puts those directories in your own file
|
||||
manager and editor over the SSH access you already have.
|
||||
|
||||
Read the next section before setting it up. What it costs is not obvious from
|
||||
the fact that it is only a filesystem mount, and it is not a cost dejima can
|
||||
carry for you: nothing dejima runs is in the path.
|
||||
|
||||
### What mounting it costs you
|
||||
|
||||
**Every byte and every name in those directories was written by a sandboxed
|
||||
session.** That is what they are for - a handoff directory a session cannot
|
||||
write to is not a handoff directory - so this is not a defect to fix but the
|
||||
surface to know about. dejima sanitises session-authored bytes before
|
||||
printing them to your terminal, in `dejima-audit` and `dejima-epitaph`,
|
||||
because that is the one surface it controls. A file manager on another
|
||||
machine is not that surface, and nothing carries the sanitising there.
|
||||
|
||||
**Mounting removes the deciding.** Copying a file off the box when you want
|
||||
it is a decision per file. A mount, and especially one a login unit brings up
|
||||
for you, means the directory is simply present whenever you are logged in -
|
||||
and a desktop does not wait to be asked. A file indexer walks new directories and
|
||||
opens what it finds to extract text from PDFs, archives and office documents.
|
||||
A thumbnailer decodes images and video frames. A previewer parses on
|
||||
selection. Those are image and document parsers, running as you, unsandboxed,
|
||||
reached with no click. dejima's whole premise is that the session may be
|
||||
hostile; none of that software was written with that assumption about a
|
||||
directory on your own disk. This is the [threat
|
||||
model](threat-model.md#ranked-by-what-is-actually-likely)'s fifth-ranked item
|
||||
with its one bound removed - it is still the operator's side of the handoff,
|
||||
but no person decides per file any more.
|
||||
|
||||
**Names are session-chosen too, and a desktop renders names.** Control
|
||||
characters, bidi overrides, and a long name whose visible tail is not its real
|
||||
extension all survive into a file listing. The rule for a terminal applies
|
||||
unchanged to a file manager: what is displayed is not evidence of what it is.
|
||||
|
||||
**Opening the directory as a project is code execution.** Editors act on
|
||||
configuration they find beside the files - `.envrc` for direnv, a `.vscode`
|
||||
directory, a project-local `exrc`, a `.git/config` that any later `git`
|
||||
command run in that directory honours. A session that can write the directory
|
||||
chooses those files as freely as it chooses the screenshots. Pull files out of
|
||||
the mount; do not point a project-opening tool at it.
|
||||
|
||||
**The mount is read-write, and your desktop uses that.** Thumbnail caches,
|
||||
trash directories, editor swap and undo files land in the share, where every
|
||||
later session reads them. An editor swap file can hold buffer content from
|
||||
the file you had open, and deleting something in a file manager usually moves
|
||||
it to a trash directory inside the same mount rather than removing it.
|
||||
|
||||
**Symlinks resolve on your machine.** `sshfs` passes a symlink through as a
|
||||
symlink, so one a session planted resolves against *your* filesystem: a
|
||||
backup job, an indexer, `cp -r` or `rsync -L` pointed at the mount can be led
|
||||
out of it and into your own files. `-o follow_symlinks` inverts that by
|
||||
resolving them on the box instead, which hands you the box's files rather
|
||||
than yours. Neither is what you want, so do not add that option and do not
|
||||
point recursive tools at the mount.
|
||||
|
||||
**What it does not add is a route inward.** A session cannot see the mount,
|
||||
cannot cause one, and gains nothing from the fact that one exists. The ssh
|
||||
credential involved already gets you a shell on that box, so it is not a new
|
||||
grant either. Everything above is about where session-authored bytes end up,
|
||||
which is a machine that appears nowhere in dejima's blast-radius table.
|
||||
|
||||
**Keeping it small** is most of the answer, and unmounting is the biggest
|
||||
lever: none of the above happens to a directory that is not currently there.
|
||||
Then mount the handoff directories rather than the box's home, add `-o ro` for
|
||||
one you only ever pull from, exclude the mount point from your indexer's and
|
||||
your backup tool's scope, and [clear `share_dir` out
|
||||
periodically](configuration.md#shared-scratch-space) - which now means stale
|
||||
files on two machines rather than one.
|
||||
|
||||
### Setting it up
|
||||
|
||||
**Use your ordinary key, not the front-door one.** A `command=` in
|
||||
`authorized_keys` replaces whatever the client asked to run, and a
|
||||
`subsystem sftp` request is not an exception, so a client offering the phone
|
||||
key gets `dejima-attach` where it expected the SFTP protocol and fails
|
||||
without explaining itself. Do not relax that key to make this work: the
|
||||
forced command is the whole of what makes it safe to leave on a phone. The
|
||||
key that already gets you a normal shell on the box is the right one, and it
|
||||
grants nothing new here - you had the shell.
|
||||
|
||||
**Find the host path first.** `~/shared` is where the *container* sees it; on
|
||||
the host it is one project's directory under `[core] share_dir`, which defaults
|
||||
to `~/.local/share/dejima/shared` and is not named `shared` at all.
|
||||
`dejima --print-config | grep share_dir` on the box prints the resolved root,
|
||||
and `dejima-share list` prints what is under it. Mount `<share_dir>/projects`
|
||||
to get one directory per project with no loose files mixed in, or a single
|
||||
project's directory - `dejima-share path <project>` - to keep the mount to the
|
||||
one you actually pull from.
|
||||
Anything in `[mounts] read_write` needs no lookup, because those mount at the
|
||||
host path they name: `~/specs` in the container is `~/specs` on the box.
|
||||
|
||||
```sh
|
||||
sshfs box:.local/share/dejima/shared/projects ~/mnt/dejima-shared \
|
||||
-o IdentityFile=/home/<you>/.ssh/id_ed25519,IdentitiesOnly=yes \
|
||||
-o PasswordAuthentication=no \
|
||||
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 \
|
||||
-o Compression=no
|
||||
```
|
||||
|
||||
`IdentitiesOnly=yes` stops ssh offering every key your agent holds ahead of
|
||||
the one you named, which can exhaust the server's `MaxAuthTries` and fail as
|
||||
though the key were wrong. `PasswordAuthentication=no` makes an unattended
|
||||
mount fail immediately rather than block on a prompt nobody is there to
|
||||
answer. The keepalives are what make `reconnect` worth having: they give up
|
||||
after about 45 seconds, where a laptop that slept or changed network
|
||||
otherwise leaves every `ls` hanging on the kernel's TCP timeout.
|
||||
`Compression=no` because screenshots and PDFs are already compressed.
|
||||
|
||||
Two additions worth knowing rather than copying. `-o
|
||||
Ciphers=aes128-gcm@openssh.com` is faster when both ends have AES-NI, and
|
||||
slower when they do not - ssh's default puts `chacha20-poly1305` first
|
||||
precisely because it wins on hardware without it. And `-o idmap=user` if your
|
||||
account has a different UID on the two machines, or every file arrives owned
|
||||
by whoever holds that number locally.
|
||||
|
||||
A `Host box` block in `~/.ssh/config` carrying the identity and keepalive
|
||||
options keeps all of this to `sshfs box:... ~/mnt/...`, which is worth doing
|
||||
once rather than pasting the long form wherever you need it.
|
||||
|
||||
**Mount it when you want it, and `fusermount3 -u` when you are done.** Two
|
||||
commands is not much friction, and it buys back the thing the section above
|
||||
says a mount costs: while it is unmounted there is no directory for an
|
||||
indexer to walk or a previewer to open, so the window in which your machine
|
||||
acts on session output is one you opened on purpose. Every automatic form
|
||||
below trades that away. There is deliberately no `/etc/fstab` line here -
|
||||
those run as root, which means a key root can read, root's `~/.ssh/config`
|
||||
instead of yours, and `allow_other` before your own account can see inside
|
||||
the mount, at which point every account on the machine can too. That is a
|
||||
worse deal in every direction than typing `sshfs`.
|
||||
|
||||
### On login
|
||||
|
||||
**Prefer mounting on demand.** This is here because a mount you have to
|
||||
remember is a mount you stop using, not because it is the better answer: an
|
||||
always-present directory is exactly the case the cost section is about. Worth
|
||||
it for a directory you genuinely work out of, like `~/specs`; not worth it
|
||||
for one you look at twice a week.
|
||||
|
||||
A **user** service runs as you, so the key in your home directory works
|
||||
without being made readable by anyone else. In
|
||||
`~/.config/systemd/user/dejima-shared.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=dejima shared directory
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/sshfs -f box:.local/share/dejima/shared %h/mnt/dejima-shared \
|
||||
-o IdentityFile=%h/.ssh/id_ed25519,IdentitiesOnly=yes \
|
||||
-o PasswordAuthentication=no \
|
||||
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3
|
||||
ExecStop=/bin/fusermount3 -u %h/mnt/dejima-shared
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
`-f` is the load-bearing flag: without it `sshfs` daemonises, systemd watches
|
||||
the parent exit and calls the mount a failure. `fusermount3` is the libfuse3
|
||||
name, `fusermount` the libfuse2 one. Then
|
||||
`systemctl --user enable --now dejima-shared`, and one unit per directory -
|
||||
`%h` expands to your home, so the same file with two paths changed covers
|
||||
`~/specs`.
|
||||
|
||||
`systemctl --user` needs `loginctl enable-linger <you>` if you want the mount
|
||||
to survive you logging out, the same requirement `dejima-notify-watch` has.
|
||||
|
||||
+83
-117
@@ -2,9 +2,8 @@
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
The README states each of these ten rules in a sentence or two. This is the
|
||||
part that didn't fit: why each one matters, what goes wrong without it, and
|
||||
the detail behind the three the README could only gesture at.
|
||||
The README states each of these rules in a sentence or two. This is the part
|
||||
that didn't fit: why each one matters, and what goes wrong without it.
|
||||
|
||||
## Check whether they want it first.
|
||||
|
||||
@@ -33,45 +32,37 @@ The concrete workflow, step by step:
|
||||
6. Open the upstream pull request by hand from there, once you would have
|
||||
been glad to have written it yourself.
|
||||
|
||||
The reason for the extra hop is what happens without it. An auto-push
|
||||
workflow aimed straight at someone else's repository puts a session's raw
|
||||
output directly into that project's pull request queue, unreviewed by
|
||||
An auto-push workflow aimed straight at someone else's repository puts a
|
||||
session's raw output into that project's pull request queue, unreviewed by
|
||||
anyone, the moment it opens. Maintainers already spend real time triaging a
|
||||
flood of low-effort AI-generated pull requests; a Godot maintainer's account
|
||||
of what that actually costs is worth reading in full:
|
||||
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
|
||||
reviewing first doesn't change what the session is capable of writing. It
|
||||
changes who reads the output before anyone else has to: you, not a stranger
|
||||
who owns the project.
|
||||
flood of low-effort AI-generated pull requests; a Godot maintainer's account of
|
||||
what that costs is worth reading in full:
|
||||
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking first
|
||||
doesn't change what the session is capable of writing. It changes who reads the
|
||||
output before anyone else has to: you, not a stranger who owns the project.
|
||||
|
||||
## Don't point it at proprietary code.
|
||||
|
||||
dejima narrows the paths code has to leave through; it doesn't close them,
|
||||
and "point it only at code you could survive losing" is the practical test
|
||||
for that gap. Ask it plainly before you start a session, not after: if this
|
||||
checkout's contents ended up somewhere they shouldn't, would that be a bad
|
||||
week or the end of the business? Code you could survive losing may be worth
|
||||
the convenience of unattended agentic work. Code you couldn't shouldn't go in
|
||||
at all.
|
||||
dejima narrows the paths code has to leave through; it doesn't close them.
|
||||
Ask it plainly before you start a session, not after: if this checkout's
|
||||
contents ended up somewhere they shouldn't, would that be a bad week or the end
|
||||
of the business? Code you could survive losing may be worth the convenience of
|
||||
unattended agentic work. Code you couldn't shouldn't go in at all.
|
||||
|
||||
That second one is not a judgement about how good the containment is, and it
|
||||
does not get better with a version bump. There is no containment that fully
|
||||
prevents exfiltration: a determined attacker with code execution and any
|
||||
outbound path will find a way. So the question is not whether you trust
|
||||
dejima enough, but whether you can accept the risk of the contents leaving.
|
||||
If you cannot, do not hand them to an agent that could send them.
|
||||
That is not a judgement about how good the containment is, and it does not get
|
||||
better with a version bump. No containment fully prevents exfiltration: a
|
||||
determined attacker with code execution and any outbound path will find a way.
|
||||
The question is not whether you trust dejima enough, but whether you can accept
|
||||
the risk of the contents leaving.
|
||||
|
||||
## Look at what else is in the directory.
|
||||
|
||||
The bind mount doesn't know which files you meant to hand over. It exposes
|
||||
the whole checkout, and everything a working tree accumulates over its life
|
||||
sits there with the same reachability as the source files you actually care
|
||||
about: a `.env` a teammate left uncommitted, a database dump pulled down for
|
||||
local debugging and never deleted, a credential baked into a fixture file,
|
||||
an old key still sitting in the history of a branch that got checked out.
|
||||
None of that is code in any sense that matters to the agent; all of it is
|
||||
just bytes in a directory the session can read and any tool call it makes
|
||||
can touch.
|
||||
The bind mount doesn't know which files you meant to hand over. Everything a
|
||||
working tree accumulates sits there with the same reachability as the source
|
||||
you actually care about: a `.env` a teammate left uncommitted, a database dump
|
||||
pulled down for local debugging and never deleted, a credential baked into a
|
||||
fixture, an old key on a branch that got checked out. All of it is bytes in a
|
||||
directory the session can read and any tool call it makes can touch.
|
||||
|
||||
Auditing a working tree by eye is not a minute's work, and it is the kind of
|
||||
check that succeeds every time until the once it matters. **Point dejima at a
|
||||
@@ -82,25 +73,22 @@ directory not to hand over.
|
||||
|
||||
## An allowlist entry is a grant, not a convenience.
|
||||
|
||||
For every allowlist entry you give a dejima agent, consider what an attacker
|
||||
could do with it. The answer varies enormously between entries that look
|
||||
similar, and what it turns on is what happens if the call is wrong, whether
|
||||
wrong means a prompt-injected instruction or an ordinary agent mistake.
|
||||
For every allowlist entry, consider what an attacker could do with it. The
|
||||
answer varies enormously between entries that look similar, and what it turns
|
||||
on is what happens if the call is wrong, whether wrong means a prompt-injected
|
||||
instruction or an ordinary agent mistake.
|
||||
|
||||
A package registry or a docs site can be wrong without being expensive. A
|
||||
session that fetches the wrong package version, or scrapes a stale or
|
||||
misleading page, produces bad input to more code, code that still has to
|
||||
pass through your own review before any of it ships. The mistake is
|
||||
contained by the same diff you were always going to read.
|
||||
session that fetches the wrong package version, or scrapes a misleading page,
|
||||
produces bad input to more code that still has to pass your own review. The
|
||||
mistake is contained by the same diff you were always going to read.
|
||||
|
||||
A deploy webhook, a production database API, and a payment provider are a
|
||||
different shape entirely. A single call against any of them is the action,
|
||||
not a step toward one you'll review later: the deployment already ran, the
|
||||
row is already written or gone, the charge already happened. There is no
|
||||
diff downstream that catches it, because nothing about it passes through
|
||||
your repository at all. Allowlisting one of these gives an unattended process
|
||||
standing authority to act on the world, the same authority you would think
|
||||
twice about handing a person.
|
||||
A deploy webhook, a production database API and a payment provider are a
|
||||
different shape entirely. A single call against any of them *is* the action,
|
||||
not a step toward one you'll review later: the deployment already ran, the row
|
||||
is already gone, the charge already happened. Nothing about it passes through
|
||||
your repository, so no diff downstream catches it. Allowlisting one gives an
|
||||
unattended process standing authority to act on the world.
|
||||
|
||||
Ask, before adding an entry: if this call happened by mistake, is the result
|
||||
something I fix in the next commit, or something I have to explain to
|
||||
@@ -114,21 +102,14 @@ contained perfectly and still produce something that should not be merged,
|
||||
and the sandbox has no opinion either way.
|
||||
|
||||
This point is about the merge, not about code quality in general. What makes
|
||||
`main` (or `master`, or whatever your default branch is called) different
|
||||
from any other branch is that other things read it and believe it. It is
|
||||
worth being precise about why, rather than saying "the released version" in
|
||||
some vaguer sense. In this repository, dejima-ci-hardener resolves the
|
||||
CI-side egress
|
||||
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
|
||||
from a branch or a local checkout, and treats whatever it finds there as
|
||||
already reviewed. That trust is load-bearing: the only way content lands on
|
||||
`main` is through a pull request you merged, so reading from `main` is how
|
||||
the tool avoids asking a branch what it's allowed to do. So merging onto
|
||||
`main` ships it to users and feeds a trust anchor that other tooling, this
|
||||
one and potentially future ones, is entitled to rely on without re-checking.
|
||||
Keeping insecure or broken code off `main` is what makes that reliance
|
||||
justified, and that job is yours; nothing about running the agent in a
|
||||
sandbox does it for you.
|
||||
your default branch different from any other is that other things read it and
|
||||
believe it. Concretely: dejima-ci-hardener resolves the CI-side egress
|
||||
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never from a
|
||||
branch or a local checkout, and treats whatever it finds there as already
|
||||
reviewed. That trust is load-bearing, and it is only justified because the one
|
||||
way content lands on `main` is through a pull request you merged. Keeping
|
||||
insecure or broken code off it is your job; nothing about running the agent in
|
||||
a sandbox does it for you.
|
||||
|
||||
## Give it its own identity, then own what you merge.
|
||||
|
||||
@@ -152,32 +133,27 @@ it accordingly.
|
||||
|
||||
## Understand what you ship.
|
||||
|
||||
The risk here is habituation, not a single lapse. dejima makes iterating
|
||||
cheap enough that reading every diff starts to feel like the slow part of
|
||||
the loop, and skipping it works fine, repeatedly, until the one time it
|
||||
doesn't. A reviewer who reads every change catches most of what's wrong with
|
||||
it. A reviewer who only reads the ones that felt risky catches none of the
|
||||
ones that didn't feel risky, which is the shape a subtle bug, or a
|
||||
prompt-injected change dressed up as something ordinary, takes. Nothing
|
||||
about a faster loop changes what engineering judgment is for.
|
||||
The risk here is habituation, not a single lapse. dejima makes iterating cheap
|
||||
enough that reading every diff starts to feel like the slow part of the loop,
|
||||
and skipping it works fine, repeatedly, until the one time it doesn't. A
|
||||
reviewer who only reads the changes that felt risky catches none of the ones
|
||||
that didn't feel risky, which is the shape a subtle bug, or a prompt-injected
|
||||
change dressed up as something ordinary, takes. Nothing about a faster loop
|
||||
changes what engineering judgement is for.
|
||||
|
||||
## This is not a blastproof box.
|
||||
|
||||
Read this rule as a statement about what kind of failure to plan for.
|
||||
dejima's containment is Linux namespaces and a small capability set around
|
||||
the session, the same kind of isolation a container gives any process, not a
|
||||
virtual machine boundary; it shares the host kernel with everything else on
|
||||
the machine.
|
||||
That buys real things (see [docs/security.md](security.md) for the specific
|
||||
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
|
||||
the surfaces that matter most), and every one of them assumes the container
|
||||
itself stays intact. A determined attacker, or a single container-escape
|
||||
vulnerability, doesn't erode that protection gradually; it removes every
|
||||
layer described in that document at once, because they all sit on the same
|
||||
kernel. So the assumption to hold is not that the walls will probably hold.
|
||||
It is that if this container is ever compromised at the kernel level,
|
||||
everything reachable from this host account should be treated as compromised
|
||||
with it, which is a very different thing to plan around than an ordinary bug.
|
||||
This is a statement about what kind of failure to plan for. dejima's
|
||||
containment is Linux namespaces and a small capability set around the session,
|
||||
not a virtual machine boundary; it shares the host kernel with everything else
|
||||
on the machine. That buys real things (see [docs/security.md](security.md): no
|
||||
working `sudo`, a deny-by-default firewall, read-only mounts over the surfaces
|
||||
that matter most), and every one of them assumes the container stays intact. A
|
||||
container-escape vulnerability doesn't erode that protection gradually, it
|
||||
removes every layer at once, because they all sit on the same kernel. So the
|
||||
assumption to hold is not that the walls will probably hold: it is that if this
|
||||
container is ever compromised at the kernel level, everything reachable from
|
||||
this host account should be treated as compromised with it.
|
||||
|
||||
What "everything reachable from this host account" amounts to is not fixed,
|
||||
and it is the one part of this that can be narrowed from outside. Bounding
|
||||
@@ -190,34 +166,24 @@ would have to be bounded.
|
||||
## dejima's security says nothing about your software's.
|
||||
|
||||
These are different axes, not different degrees of the same thing. dejima's
|
||||
threat model is about the session: what a process running inside the
|
||||
sandbox can reach, exfiltrate, or execute while it runs. None of that has
|
||||
anything to say about the software that session writes once it's on disk.
|
||||
dejima can contain a session perfectly and that session can still write a
|
||||
SQL injection, hand-roll a broken authentication check, hardcode a secret,
|
||||
or pull in a compromised dependency, because those are properties of the
|
||||
code itself, decided at the moment it's written, not something a sandbox
|
||||
sitting around the process that wrote it can retrofit afterward. If what got
|
||||
built inside dejima turns out to be insecure, containment was never the
|
||||
control that was supposed to catch it.
|
||||
threat model is about the session: what a process running inside the sandbox
|
||||
can reach, exfiltrate, or execute while it runs. It can contain a session
|
||||
perfectly and that session can still write a SQL injection, hand-roll a broken
|
||||
authentication check, hardcode a secret, or pull in a compromised dependency.
|
||||
Those are properties of the code, decided when it is written.
|
||||
|
||||
**It is not something a future agent can retrofit either.** "We will do a
|
||||
security pass later" was already a weak plan before agents made it cheap to
|
||||
say. Early architectural decisions
|
||||
close off the fixes for whole classes of problem: where trust boundaries sit,
|
||||
what is authenticated and where, what data crosses which process, what a
|
||||
single compromised component reaches. A later pass can find a missing bounds
|
||||
check. It cannot move a boundary you built a system on top of. And insecure
|
||||
implementation decisions sit in software for years without announcing
|
||||
themselves, which is exactly why they are not found by a pass that goes
|
||||
looking once.
|
||||
**It is not something a future agent can retrofit either.** Early
|
||||
architectural decisions close off the fixes for whole classes of problem: where
|
||||
trust boundaries sit, what is authenticated and where, what data crosses which
|
||||
process, what a single compromised component reaches. A later pass can find a
|
||||
missing bounds check. It cannot move a boundary you built a system on top of.
|
||||
And insecure implementation decisions sit in software for years without
|
||||
announcing themselves, which is exactly why a pass that goes looking once does
|
||||
not find them.
|
||||
|
||||
So security has to be considered at every point in the work, not scheduled
|
||||
as a phase, and dejima will not help you do that. It makes writing a lot of
|
||||
code fast, which makes writing a lot of insecure code fast if nobody is
|
||||
holding that concern. That responsibility is yours at every step, and it is
|
||||
the one thing here no tooling in this repository is aiming at.
|
||||
|
||||
The same practices you'd apply to code from any other source, review,
|
||||
linting, dependency scanning, tests aimed at the failure modes you care
|
||||
about, still have to happen here. dejima does not stand in for any of them.
|
||||
So security has to be considered at every point in the work rather than
|
||||
scheduled as a phase, and dejima will not help you do that. It makes writing a
|
||||
lot of code fast, which makes writing a lot of insecure code fast if nobody is
|
||||
holding that concern. Review, linting, dependency scanning and tests aimed at
|
||||
the failure modes you care about all still have to happen; dejima stands in for
|
||||
none of them.
|
||||
|
||||
+227
-176
@@ -44,6 +44,14 @@ than the thing inferred from silence.
|
||||
session that loops until someone notices is not prevented, and this is a
|
||||
deliberate omission rather than an oversight - see
|
||||
[Why there are no timeouts](#why-there-are-no-timeouts).
|
||||
- **A suspended job. Ctrl-Z stops the observer, not the session.** It
|
||||
suspends `bin/dejima` on the host; the container keeps running, with its
|
||||
mounts, its firewall grants and its forge token, and keeps making requests
|
||||
while the terminal looks stopped. That is deliberate - SIGSTOP mid-tool-call
|
||||
leaves a session wherever it was, and the feature exists to get the terminal
|
||||
back rather than to pause the work - but a stopped job reads as a paused
|
||||
agent, and the difference matters most to whoever suspends one because they
|
||||
are uneasy about what it is doing.
|
||||
- **The first layer's controls against an agent that has stopped
|
||||
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
|
||||
cooperating agent does and stop nothing that has decided otherwise. Only the
|
||||
@@ -250,13 +258,27 @@ checkout itself and needs no symlink to edit, so a session with write access
|
||||
to this repo's own checkout can change what the next launch composes and
|
||||
hands to whichever session starts against it.
|
||||
|
||||
The provider directories are in the exit-time audit's TCB list for that
|
||||
reason, alongside `bin/`, `hooks/`, `docker/` and `test/`. They hold no code
|
||||
and nothing executes them, but what they hold decides what the next launch
|
||||
permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`, which
|
||||
`entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so a
|
||||
line added to a manifest widens the egress boundary on the next run. An
|
||||
ordinary edit is a diff you are about to read and is not reported; one git
|
||||
**Which requires dejima's own checkout to be the workspace, and almost never
|
||||
is.** Nothing mounts `forges/`, `workflows/`, `harnesses/`, `tools/` or the
|
||||
rest of the checkout into a container - the resolved values go in, the files
|
||||
do not - so for every launch pointed at some other project, a session cannot
|
||||
read these paths, let alone write them. The rest of this section is about the
|
||||
one configuration where it can: dejima sandboxing the repository it is itself
|
||||
installed from, which is how dejima is developed. `bin/dejima` says so at
|
||||
launch when it notices.
|
||||
|
||||
In that configuration the provider directories are in the exit-time audit's
|
||||
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
|
||||
hold no code and nothing executes them, but what they hold decides what the
|
||||
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
|
||||
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
|
||||
a line added to a manifest widens the egress boundary on the next run.
|
||||
`tools/*/tool.conf` is the same class, and is read by the host rather than the
|
||||
container: it names a tarball URL, the sha256 checked against it and the
|
||||
layout it is unpacked with, all consumed by the image build - so a session
|
||||
that edits one chooses what the next launch downloads and installs as root.
|
||||
|
||||
An ordinary edit is a diff you are about to read and is not reported; one git
|
||||
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
|
||||
or `skip-worktree` - is a CRITICAL finding. It is still a change a session
|
||||
can make; it is one the audit reports. See
|
||||
@@ -297,178 +319,180 @@ can make; it is one the audit reports. See
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
## The exit-time audit
|
||||
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
`config.worktree`) keys that run a command on the next `git` -
|
||||
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
||||
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
|
||||
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
|
||||
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
||||
`include.path`/`includeif.*.path` (config indirection - these can point at
|
||||
a planted file that itself sets an exec key, so the exec key never
|
||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content). When the mounted workspace is
|
||||
this repo itself, it also hashes the sandbox's own trusted computing base -
|
||||
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
|
||||
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
|
||||
diffs them after the container exits. It is **advisory**: it fires after the
|
||||
session ended, so a planted `core.hooksPath` is something to clean up before
|
||||
the next `git` or `claude` in that repo, not something that was blocked. It is
|
||||
silent when nothing changed and never affects the session's exit code.
|
||||
|
||||
Worth knowing about that first surface, because the sandbox's own workflow
|
||||
makes it easy to get wrong: **a git worktree isolates the working tree, not
|
||||
`.git/config`.** Config is shared with the parent checkout, so a bare
|
||||
`git config <key> <value>` inside a worktree writes the real repository's
|
||||
config, including any exec key above. The briefing now says so, and gives
|
||||
`git -C <path> config`. A guard against an unaware agent, not a boundary:
|
||||
`.git/config` is in the read-write project mount and cannot be read-only
|
||||
without breaking `git push -u`, which writes `branch.<name>.merge` on every
|
||||
push. Detection covers the rest, and did - this was found by the audit
|
||||
reporting a `.git/config` change.
|
||||
**Its diff is time-windowed, not session-attributed:** a finding means a path
|
||||
changed while the session ran, not that the session changed it. A concurrent
|
||||
session, another host process, or your own editing all land in the same diff,
|
||||
reported identically.
|
||||
|
||||
**Advisory only.** It fires after the session ended, so a planted
|
||||
`core.hooksPath` is something to clean up before the next `git` or
|
||||
`claude` in that repo, not something that was blocked. Silent when nothing
|
||||
changed, and never affects the session's exit code. It does not cover
|
||||
`[core] share_dir` (see
|
||||
[Shared scratch space](configuration.md#shared-scratch-space)), and its
|
||||
coverage is a fixed list rather than a general integrity check, so a config
|
||||
surface it has not been taught about goes unreported.
|
||||
### What it watches
|
||||
|
||||
**Its diff is time-windowed, not session-attributed:** a finding means a
|
||||
path changed while the session ran, not that the session changed it. A
|
||||
concurrent session, another host process, or your own editing all land in
|
||||
the same diff, reported identically.
|
||||
- **Approving and ignoring are different decisions, and the difference is
|
||||
what you are promising about next time.** Approving records the exact
|
||||
content: `safe <id> <sha>`, so this file with these bytes is fine and any
|
||||
other content re-fires. Ignoring records the surface: `spam <id>`, so this
|
||||
path or key is fine whatever it later contains. Approve a hook you have
|
||||
read; ignore a key some tool rewrites on every run and which you have
|
||||
decided you will never care about. The review offers both, and says which
|
||||
is which rather than leaving you to infer it:
|
||||
`.git/config` (and worktree `config.worktree`) keys that run a command on the
|
||||
next `git` - `core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
||||
`credential.helper`, `core.pager`, `pager.*`, `core.editor`, `sequence.editor`,
|
||||
`gpg.program`, `diff.external`, `merge.*.driver`, `diff.*.command`,
|
||||
`filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
||||
`include.path`/`includeif.*.path` (config indirection - these can point at a
|
||||
planted file that itself sets an exec key, so the exec key never appears in
|
||||
`.git/config` directly), `!`-shell aliases - plus content changes under the
|
||||
workspace's `.claude/`, `.mcp.json`, `.envrc`, `.vscode/`, and `.git/hooks/`
|
||||
(symlinks included - a repointed symlink is hashed by its target, not just its
|
||||
content). When the mounted workspace is this repo itself, it also hashes the
|
||||
sandbox's own trusted computing base: `bin/`, `hooks/`, `docker/`,
|
||||
`install.sh`, `test/`.
|
||||
|
||||
```
|
||||
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
||||
```
|
||||
Coverage is a fixed list rather than a general integrity check, so a config
|
||||
surface it has not been taught about goes unreported. It does not cover
|
||||
`[core] share_dir` (see
|
||||
[Shared scratch space](configuration.md#shared-scratch-space)).
|
||||
|
||||
A finding whose file has since been deleted is **cleared** rather than
|
||||
approved: there is no content left to pin an approval to, and a surface
|
||||
that does not exist does not run. Nothing is recorded, so if it comes back
|
||||
it comes back as a new finding. Refusing would leave the report
|
||||
unresolvable: `ack` opens CRITICAL reports first, so every other report
|
||||
would hide behind one no decision could clear.
|
||||
**A git worktree isolates the working tree, not `.git/config`.** Config is
|
||||
shared with the parent checkout, so a bare `git config <key> <value>` inside a
|
||||
worktree writes the real repository's config, including any exec key above. The
|
||||
briefing says so, and gives `git -C <path> config`. That is a guard against an
|
||||
unaware agent, not a boundary: `.git/config` is in the read-write project mount
|
||||
and cannot be read-only without breaking `git push -u`, which writes
|
||||
`branch.<name>.merge` on every push. Detection covers the rest, and did - this
|
||||
was found by the audit reporting a `.git/config` change.
|
||||
|
||||
Skipping asks again next session, the right default when you are unsure.
|
||||
It matters for keys some tools rewrite on every run: with no decision
|
||||
recorded, one re-fires every session and pushes real findings out of the
|
||||
capped list.
|
||||
### Approving and ignoring are different decisions
|
||||
|
||||
Severity is not negotiable by either verb. An exec-capable key or shell
|
||||
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
|
||||
refused against a CRITICAL surface when the baseline is read, not only when
|
||||
written - so neither decision can quietly downgrade something that matters.
|
||||
The difference is what you are promising about next time. Approving records the
|
||||
exact content (`safe <id> <sha>`), so this file with these bytes is fine and any
|
||||
other content re-fires. Ignoring records the surface (`spam <id>`), so this path
|
||||
or key is fine whatever it later contains. Approve a hook you have read; ignore
|
||||
a key some tool rewrites on every run and which you have decided you will never
|
||||
care about. The review offers both, and says which is which:
|
||||
|
||||
- `dejima-audit ack` shows what it is asking you to approve. For a surface
|
||||
that changed during the session it prints a unified diff of the
|
||||
pre-session content against the current content; for one created during
|
||||
the session it prints the whole current content. To make that possible,
|
||||
`snapshot` stages a copy of each monitored surface's bytes for the
|
||||
duration of the session, and `compare` turns those into **bounded
|
||||
diffs** stored in a `<report>.d/` directory beside the report. What
|
||||
persists after the session is only the diff of a *modified* surface -
|
||||
never a whole file. A created surface stores a marker (its content is
|
||||
live on disk); a removed one stores nothing at all, because a deletion
|
||||
diff *is* the whole former file.
|
||||
```
|
||||
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
||||
```
|
||||
|
||||
What it prints is chosen by what changed, which is not always what you
|
||||
need in order to judge it - three changed lines in a hook script are only
|
||||
judgeable by reading the script. `[o]` at the prompt opens the file in
|
||||
`$VISUAL`/`$EDITOR`, read-only where the editor understands how, and
|
||||
returns to the same decision. It opens the file **as it is now**, not the
|
||||
text the report captured, for the same reason an approval pins the content
|
||||
that is on disk at the moment it is recorded: approving something nobody
|
||||
read would be the one outcome worse than not asking.
|
||||
Skipping asks again next session, the right default when you are unsure. It
|
||||
matters for keys some tools rewrite on every run: with no decision recorded, one
|
||||
re-fires every session and pushes real findings out of the capped list.
|
||||
|
||||
Staging and retained diffs live under `$AUDIT_DIR`
|
||||
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
|
||||
scheme rests on: nothing in `docker-compose.yml` covers it, so the
|
||||
container cannot read earlier runs' retained content, and `static.bats`
|
||||
pins that. A store configured *inside* the workspace is readable and
|
||||
deletable from the sandbox, so nothing is retained in that case - only a
|
||||
note saying why.
|
||||
A report walked to the end stops being counted as waiting, but stays on disk as
|
||||
the record of what that session did. `[audit] keep` decides how many are
|
||||
retained; rotation evicts one, `ack` never does.
|
||||
|
||||
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
|
||||
the next run against that workspace reaps it, identifying dead owners by
|
||||
pid and process start time from an `.owner` file so a concurrent session is
|
||||
never mistaken for dead. A missing or unreadable owner record falls back to
|
||||
a 24-hour age check rather than guessing.
|
||||
A finding whose file has since been deleted is **cleared** rather than approved:
|
||||
there is no content left to pin an approval to, and a surface that does not
|
||||
exist does not run. Nothing is recorded, so if it comes back it comes back as a
|
||||
new finding. Refusing would leave the report unresolvable, since `ack` opens
|
||||
CRITICAL reports first.
|
||||
|
||||
Content is withheld from the store in several further cases, and in
|
||||
every one `ack` prints the reason in place of the diff - so an absent
|
||||
diff is never mistaken for an absent change - and still shows the live
|
||||
current content on your terminal, because withholding is about the
|
||||
persistent store, not about what you are shown while deciding. Surfaces
|
||||
that hold credentials by convention are never staged in the first
|
||||
place, so their bytes are never even copied transiently: `.envrc`,
|
||||
`.mcp.json` and `.claude/settings.local.json` by exact path, and
|
||||
`.env`/`.env.*` by *basename*, at any depth - so a nested `.claude/.env`
|
||||
is caught too, not just one at the workspace root. A surface that
|
||||
existed before the session but was too large to have been staged (over
|
||||
1 MiB) withholds with "no pre-session copy", not a false claim that it
|
||||
is new. The *current* side is bounded by that same 1 MiB cap, and - like
|
||||
the staged side - checked *before* any copy is taken: a file the session
|
||||
grew past it withholds with "change too large to retain" rather than
|
||||
being copied through `$TMPDIR` first. The ordering is the point, not the
|
||||
cap. `compare` runs from the exit trap on every exit and the size is the
|
||||
session's own choice, so copying first would turn "I filled my own
|
||||
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
|
||||
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
|
||||
A generated diff is withheld if it is binary, over 200 lines
|
||||
or 64 KiB (and is bounded to that as it is generated, for the same
|
||||
reason), or matches a best-effort credential screen - vendor prefixes
|
||||
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
|
||||
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
|
||||
`Authorization: Bearer`, and PEM private-key headers, matched
|
||||
case-insensitively, since `.claude/settings.json`'s `env` block - the one
|
||||
high-value surface this is the only net for - conventionally holds its keys
|
||||
upper-case. The screen is best-effort with real false negatives: the
|
||||
denylist above is the reliable half.
|
||||
Severity is not negotiable by either verb. An exec-capable key or shell alias is
|
||||
CRITICAL whatever the baseline says, and an `ignore` entry is refused against a
|
||||
CRITICAL surface when the baseline is read, not only when written - so neither
|
||||
decision can quietly downgrade something that matters.
|
||||
|
||||
A git config key with more than one value on either side is not compared.
|
||||
Git resolves such a key to its last value in FILE order; the audit parses
|
||||
and sorts, so lexicographically-last is not what git runs, and the
|
||||
pre-session side is every value the key had. Comparing anyway produced a
|
||||
diff over values git does not use - two pre-session values read as a
|
||||
deletion that never happened, while the appended value git now executes
|
||||
appeared nowhere. It withholds instead, and must be compared by hand.
|
||||
`include.path` is legitimately multi-valued, so this is an ordinary shape.
|
||||
### What `ack` shows you
|
||||
|
||||
A diff can also come out empty when the config file's TEXT changed but the
|
||||
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
|
||||
rather than showing nothing - an empty diff for a reported change reads as
|
||||
a bug.
|
||||
For a surface that changed during the session it prints a unified diff of the
|
||||
pre-session content against the current content; for one created during the
|
||||
session it prints the whole current content. To make that possible, `snapshot`
|
||||
stages a copy of each monitored surface's bytes for the duration of the session,
|
||||
and `compare` turns those into **bounded diffs** in a `<report>.d/` directory
|
||||
beside the report. What persists afterwards is only the diff of a *modified*
|
||||
surface, never a whole file. A created surface stores a marker (its content is
|
||||
live on disk); a removed one stores nothing, because a deletion diff *is* the
|
||||
whole former file.
|
||||
|
||||
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
|
||||
diff bytes one report keeps. Once spent, the rest withhold and both the
|
||||
report and the stderr summary add an alarm line: that many changed
|
||||
host-persistence surfaces in one session is a signal in itself, not a limit
|
||||
worth raising. `diffs = off` in `[audit]` disables retention and staging.
|
||||
What it prints is chosen by what changed, which is not always what you need in
|
||||
order to judge it: three changed lines in a hook script are only judgeable by
|
||||
reading the script. `[o]` at the prompt opens the file in `$VISUAL`/`$EDITOR`,
|
||||
read-only where the editor understands how, and returns to the same decision. It
|
||||
opens the file **as it is now**, for the same reason an approval pins the content
|
||||
on disk at the moment it is recorded: approving something nobody read would be
|
||||
the one outcome worse than not asking.
|
||||
|
||||
Staging and retained diffs live under `$AUDIT_DIR`
|
||||
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the scheme
|
||||
rests on: nothing in `docker-compose.yml` covers it, so the container cannot read
|
||||
earlier runs' retained content, and `static.bats` pins that. A store configured
|
||||
*inside* the workspace is readable and deletable from the sandbox, so nothing is
|
||||
retained in that case, only a note saying why.
|
||||
|
||||
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory; the
|
||||
next run against that workspace reaps it, identifying dead owners by pid and
|
||||
process start time from an `.owner` file so a concurrent session is never
|
||||
mistaken for dead. A missing or unreadable owner record falls back to a 24-hour
|
||||
age check rather than guessing.
|
||||
|
||||
### When content is withheld from the store
|
||||
|
||||
In every case below `ack` prints the reason in place of the diff - so an absent
|
||||
diff is never mistaken for an absent change - and still shows the live current
|
||||
content on your terminal, because withholding is about the persistent store, not
|
||||
about what you are shown while deciding.
|
||||
|
||||
Surfaces that hold credentials by convention are never staged in the first
|
||||
place, so their bytes are never even copied transiently: `.envrc`, `.mcp.json`
|
||||
and `.claude/settings.local.json` by exact path, and `.env`/`.env.*` by
|
||||
*basename*, at any depth - so a nested `.claude/.env` is caught too.
|
||||
|
||||
A surface that existed before the session but was too large to have been staged
|
||||
(over 1 MiB) withholds with "no pre-session copy", not a false claim that it is
|
||||
new. The *current* side is bounded by that same cap, and checked *before* any
|
||||
copy is taken: a file the session grew past it withholds with "change too large
|
||||
to retain" rather than being copied through `$TMPDIR` first. The ordering is the
|
||||
point, not the cap. `compare` runs from the exit trap on every exit and the size
|
||||
is the session's own choice, so copying first would turn "I filled my own
|
||||
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so host
|
||||
RAM - and into a whole copy left there if the exit was a `SIGKILL`.
|
||||
|
||||
A generated diff is withheld if it is binary, over 200 lines or 64 KiB (bounded
|
||||
as it is generated, for the same reason), or matches a best-effort credential
|
||||
screen: vendor prefixes including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`,
|
||||
`github_pat_`, `glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
|
||||
`Authorization: Bearer`, and PEM private-key headers, matched case-insensitively
|
||||
since `.claude/settings.json`'s `env` block - the one high-value surface this is
|
||||
the only net for - conventionally holds its keys upper-case. The screen has real
|
||||
false negatives; the denylist is the reliable half.
|
||||
|
||||
A git config key with more than one value on either side is not compared. Git
|
||||
resolves such a key to its last value in FILE order; the audit parses and sorts,
|
||||
so lexicographically-last is not what git runs, and the pre-session side is every
|
||||
value the key had. Comparing anyway produced a diff over values git does not use:
|
||||
two pre-session values read as a deletion that never happened, while the appended
|
||||
value git now executes appeared nowhere. It withholds instead, and must be
|
||||
compared by hand. `include.path` is legitimately multi-valued, so this is an
|
||||
ordinary shape.
|
||||
|
||||
A diff can also come out empty when the config file's TEXT changed but the parsed
|
||||
value did not, e.g. a re-quoted value. That withholds too, saying so rather than
|
||||
showing nothing, since an empty diff for a reported change reads as a bug.
|
||||
|
||||
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total diff
|
||||
bytes one report keeps. Once spent, the rest withhold and both the report and the
|
||||
stderr summary add an alarm line: that many changed host-persistence surfaces in
|
||||
one session is a signal in itself, not a limit worth raising. `diffs = off` in
|
||||
`[audit]` disables retention and staging.
|
||||
|
||||
Two residual limits on top of the screen's false negatives. The prompt truncates
|
||||
at 40 lines, naming the file so you can read the rest, so a large planted change
|
||||
is cut exactly where you would want it whole. And the `SIGKILL` case is an
|
||||
exposure window, not just a cleanup mechanic: until the next run reaps it, the
|
||||
staging directory holds whole pre-session file copies of every monitored surface,
|
||||
for as long as the workspace goes unused.
|
||||
|
||||
### Findings reach your terminal as untrusted text
|
||||
|
||||
They name a path or config key the session may have chosen. One containing an
|
||||
ASCII or C1 control or a Unicode bidi override - which can scroll, blank or
|
||||
reorder what you read, and split a name across the audit's own records - is
|
||||
written hex-encoded behind a `{hex}` marker and decodes with
|
||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is, so
|
||||
the marker appearing is itself worth a look. The encoded form is what `ack`
|
||||
records, so what you acknowledge is the file on disk rather than a readable
|
||||
approximation of its name.
|
||||
|
||||
Two residual limits on top of the screen's false negatives. The prompt
|
||||
truncates at 40 lines, naming the file so you can read the rest - a large
|
||||
planted change is cut exactly where you would want it whole. And the
|
||||
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
|
||||
the next run reaps it, the staging directory holds whole pre-session file
|
||||
copies of every monitored surface, for as long as the workspace goes
|
||||
unused.
|
||||
- Findings name a path or config key the session may have chosen, so they
|
||||
reach your terminal as untrusted text. One containing an ASCII or C1
|
||||
control or a Unicode bidi override - which can scroll, blank or reorder
|
||||
what you read, and split a name across the audit's own records - is written
|
||||
hex-encoded behind a `{hex}` marker and decodes with
|
||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
|
||||
so the marker appearing is itself worth a look. The encoded form is what
|
||||
`ack` records, so what you acknowledge is the file on disk rather than a
|
||||
readable approximation of its name.
|
||||
## One project's session cannot reach another's
|
||||
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
@@ -531,6 +555,23 @@ can make; it is one the audit reports. See
|
||||
they are extracted from *is* session-writable, so a summary records what was
|
||||
there rather than vouching for it - and nothing loads one back into a later
|
||||
session, deliberately: that would be the channel #104 closed, rebuilt.
|
||||
- `[core] share_dir` is **not** mounted whole. A launch mounts
|
||||
`<share_dir>/projects/<slug>` at `~/shared`, so a session sees its own
|
||||
project's handoff files and no other project's - the same slicing as
|
||||
`projects/` above, applied to the directory that was the remaining way
|
||||
around it. A handoff file is session-authored by design, and one project's
|
||||
session writing a plausible-looking note that a session in another project
|
||||
then picks up is #104's channel by a different route.
|
||||
|
||||
The per-project split bounds *which* sessions, not *how long*: a project's
|
||||
share persists, so everything in it is readable by every later session
|
||||
against that project. Nothing prunes it, which is why
|
||||
[docs/configuration.md](configuration.md#shared-scratch-space) says to clear
|
||||
it out and `dejima-share list` prints what each project is holding.
|
||||
|
||||
`<share_dir>/projects` is in the guarded list, so an operator cannot hand
|
||||
the whole of it back through `[mounts] readonly` or `[mounts] read_write`.
|
||||
The session's own share arrives at `~/shared` without going through either.
|
||||
## The model API credential never enters the container
|
||||
|
||||
**This assumes the host has one.** dejima authenticates nothing: the proxy
|
||||
@@ -547,7 +588,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
|
||||
@@ -555,7 +596,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
|
||||
@@ -564,7 +605,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
|
||||
@@ -773,10 +820,11 @@ session, where the credential deliberately never goes.
|
||||
reach the daemon - that is daemon access rather than any process at all,
|
||||
and it is not something the launcher can change.
|
||||
- The container runs with `no-new-privileges` and an explicit capability
|
||||
floor - `cap_drop: ALL` plus the seven it actually uses. Six are the
|
||||
entrypoint's own; the seventh is `KILL`, which nothing in the entrypoint
|
||||
needs and `dejima-allow --container` does, because adding a domain to a
|
||||
running session signals dnsmasq and dnsmasq runs as its own user. The first means a setuid binary or a file capability cannot raise
|
||||
floor - `cap_drop: ALL` plus the ones it actually uses: what the
|
||||
entrypoint's root phase needs, and `KILL`. `KILL` is there for
|
||||
`dejima-allow --container`, not for the entrypoint - adding a domain to a
|
||||
running session signals dnsmasq, and dnsmasq runs as its own user. The list
|
||||
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
|
||||
privileges on exec, which is the shape any escalation from the session user
|
||||
would have to take; the second means the capabilities nothing here uses were
|
||||
never in the bounding set to be regained. Both are asserted against a running
|
||||
@@ -981,12 +1029,15 @@ is precisely when nothing is running to probe in the first place. Pass
|
||||
`dejima-allow --global --container <id> crates.io` records the approval and
|
||||
patches that session immediately, in that order, so the approval is never
|
||||
lost to a live-session failure. If more than one dejima container is
|
||||
running, the bare form above refuses and asks you to disambiguate with
|
||||
`--container <name>` - `docker ps` finds it, or use the
|
||||
container's own ID, which `sandbox-context.sh` already hands the agent in
|
||||
its startup context (and which is always available inside the sandbox as
|
||||
`cat /etc/hostname`), so there's no need for it to guess one and risk
|
||||
"successfully" updating someone else's session instead.
|
||||
running, the bare form above refuses rather than guessing. On a terminal it
|
||||
offers the running sessions to pick from, by project; anywhere else - a
|
||||
script, a pipe, a session asking its operator to run this - it prints the same
|
||||
rows and stops, because a prompt nobody can answer is worse than a refusal.
|
||||
Either way the answer is in front of you instead of in a separate `docker ps`.
|
||||
A session has its own ID regardless: `sandbox-context.sh` hands it to the
|
||||
agent in its startup context, and `cat /etc/hostname` has it inside the
|
||||
sandbox, so there is no need for it to guess one and risk "successfully"
|
||||
updating someone else's session instead.
|
||||
|
||||
A domain allowed this way stays allowed for the rest of the session even
|
||||
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
|
||||
|
||||
+85
-11
@@ -2,16 +2,16 @@
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
What dejima defends, what it does not, and where its boundary passes out of
|
||||
its hands. [security.md](security.md) is the control-by-control account; this
|
||||
is the shape of the thing those controls are arranged against.
|
||||
|
||||
**And none of it has been reviewed by anyone else.** This is early
|
||||
development: the reasoning here is one person's, the tests assert what that
|
||||
person thought to check, and no outside audit or adversarial testing has
|
||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||
|
||||
What dejima defends, what it does not, and where its boundary passes out of
|
||||
its hands. [security.md](security.md) is the control-by-control account; this
|
||||
is the shape of the thing those controls are arranged against.
|
||||
|
||||
## What this tool assumes
|
||||
|
||||
**The agent is hostile, or will be.** Not badly behaved: captured, mid-session,
|
||||
@@ -79,8 +79,29 @@ The ordering is the point. Nothing above the last item requires an exploit.
|
||||
Item 5 is not an argument for making the handoff harder; a session that cannot
|
||||
ask is a session that works around you instead. It is an argument for being
|
||||
able to answer, afterwards, what that actually did to the machine - which is
|
||||
the layer below this repository's, and the reason the sister project is
|
||||
looking at detection rather than at more enforcement.
|
||||
the layer below this repository's, and the reason `dejima-guard` exists and
|
||||
looks at detection rather than at more enforcement.
|
||||
|
||||
**The handoff directories are item 5 with the person taken out.** `[core]
|
||||
share_dir` and everything in `[mounts] read_write` exist so that session
|
||||
output survives the container, so every byte and every filename in them is
|
||||
session-authored by design. On the box that is bounded by how rarely anyone
|
||||
looks. Mount one on a workstation - over sshfs, or by any sync that runs on
|
||||
its own - and the directory is simply present, at which point a desktop opens
|
||||
what appears in it without being asked: an indexer parsing documents and
|
||||
archives to extract text, a thumbnailer decoding images, a previewer, an
|
||||
editor honouring project configuration it finds beside the files. Each is a
|
||||
parser running as the operator, outside the container, reached with no
|
||||
decision per file. Item 5's last sentence is that a person decides; here
|
||||
nobody does.
|
||||
|
||||
It stays item 5 rather than becoming a row of its own, because the shape is
|
||||
the same: the session initiates none of it. It cannot see the mount, cannot
|
||||
cause one, and the ssh credential involved already gave that operator a shell
|
||||
on the box. What changes is where session-authored bytes get opened, and the
|
||||
machine they get opened on is not a row in the table below.
|
||||
[remote-access.md](remote-access.md#what-mounting-it-costs-you) has the
|
||||
specifics and what shrinks them.
|
||||
|
||||
## Blast radius by layer
|
||||
|
||||
@@ -122,8 +143,31 @@ can see.
|
||||
## Where dejima's boundary ends
|
||||
|
||||
dejima runs inside the machine it is protecting the rest of, which is the
|
||||
whole reason there are two other repositories. It cannot bound its own host's
|
||||
network position, and it cannot bound a runner it never touches.
|
||||
whole reason there are three other repositories. It cannot bound its own host's
|
||||
network position, it cannot bound a runner it never touches, and it cannot tell
|
||||
anyone that something changed after the session ended.
|
||||
|
||||
### The stack, and what each piece buys
|
||||
|
||||
**Only the first row is required.** Everything under it is what the layer
|
||||
below the container costs, and every one of them can be substituted or left
|
||||
out - the last two are categories rather than products.
|
||||
|
||||
| Piece | Runs on | What it buys | Without it |
|
||||
| --- | --- | --- | --- |
|
||||
| **dejima** | the devbox | The container boundary: what a session reaches on disk, and deny-by-default egress. | An agent with your home directory and the open internet. |
|
||||
| **dejima-devbox-hardener** | the same devbox, enforced from outside the guest | Bounds where an escape *spreads*: LAN reach cut to what is declared, and root in the VM cannot flush the rule set. | An escape reaches the LAN, and the row below the container is unbounded. |
|
||||
| **dejima-guard** | a different machine | Notices the devbox changed: file hashes, enabled units and listening ports, diffed against a baseline held off the box. Detection, not enforcement. | Anything a session leaves behind survives unremarked, because nobody is looking at the devbox between sessions. |
|
||||
| **self-hosted CI + dejima-ci-hardener** | a runner VM | A runner that executes a session's pull request without that being LAN access, and a rollback that expires what an escape gained. | CI runs attacker-controlled code with the network position of whatever host it sits on. |
|
||||
| **or third-party hosted CI** | someone else's machine | The same isolation, bought rather than run - and on their network rather than yours. | - |
|
||||
| **ntfy, or any push service** | anywhere both can reach | The channel dejima's "session needs input" and the guard's alerts both arrive on. | A session that stopped to ask, and an alert that fired, both wait for someone to think to look. |
|
||||
| **Uptime Kuma, or any uptime monitor** | off the devbox | The dead man's switch: it is what notices the *guard* has stopped reporting. | Silence is indistinguishable from health, which is the failure mode every watcher has. |
|
||||
|
||||
The last row is the one most easily skipped and the one that decides whether
|
||||
the rest is real. A watcher that has died looks exactly like a watcher with
|
||||
nothing to report.
|
||||
|
||||
### Each project's own boundary
|
||||
|
||||
**[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)**
|
||||
covers the two VM rows: what an escape spreads to, rather than whether it can
|
||||
@@ -138,14 +182,40 @@ Nothing there tries to stop the code running. Its
|
||||
[threat model](https://gitbox.branderson.io/dejima/dejima-ci-hardener/src/branch/main/docs/threat-model.md#blast-radius-by-layer)
|
||||
has the two tiers and where each one's boundary sits.
|
||||
|
||||
Neither is required to run dejima, and the container row holds without them.
|
||||
What they change is what the row below it costs.
|
||||
**[dejima-guard](https://gitbox.branderson.io/dejima/dejima-guard)** watches a
|
||||
devbox from a machine that is not the devbox and alerts on what changed. It
|
||||
enforces nothing: everything it reports has already happened. It collects over
|
||||
SSH, diffs against a baseline held on the watching machine, and alerts from
|
||||
there. That placement is the point, since a baseline stored on the box being
|
||||
watched is a baseline the thing you are watching for can edit, and it
|
||||
complements devbox-hardener rather than overlapping it - one bounds where a
|
||||
compromise spreads, the other notices that one happened.
|
||||
|
||||
**It is the only piece here that adds a machine holding credentials rather than
|
||||
taking capability away.** Every other row removes something: what a session
|
||||
reaches, where an escape spreads, what a runner can talk to. This one
|
||||
introduces a host holding an SSH key to the devbox and a token for the alert
|
||||
channel, and its exposure is bounded by the hardening of a machine this stack
|
||||
does not describe or require anything of. That is the thing to weigh before
|
||||
running it.
|
||||
|
||||
**Its detection ceiling is root on the watched box.** The collector runs on the
|
||||
devbox, so root there can return whatever it likes, including the baseline
|
||||
unchanged. What the guard catches is accidents, non-root compromise, and
|
||||
payloads that did not think to look, which is most of them. The heartbeat
|
||||
proves the guard ran, not that it saw the truth.
|
||||
[Its own `SECURITY.md`](https://gitbox.branderson.io/dejima/dejima-guard/src/branch/main/SECURITY.md)
|
||||
has the rest, including the ways it is deliberately narrower than dejima.
|
||||
|
||||
None of the three is required to run dejima, and the container row holds
|
||||
without them. What they change is what the row below it costs, and whether
|
||||
anyone finds out.
|
||||
|
||||
**A survey of comparable projects found none with rows below the container at
|
||||
all** - their boundary is between the agent and the host, and what is under
|
||||
the host is outside their threat models. The same survey found nothing
|
||||
treating a forge or a CI runner as part of an agent's blast radius, which is
|
||||
what the second sister project exists for: a session that can open a pull
|
||||
what dejima-ci-hardener exists for: a session that can open a pull
|
||||
request can execute code on a machine dejima never touches.
|
||||
|
||||
Recorded as an observation from one day of secondary sources rather than as a
|
||||
@@ -212,6 +282,10 @@ this section. Nothing in dejima can bound its own host.
|
||||
- **That a finding from the exit-time audit was caused by the session.** It
|
||||
reports that a surface changed while the session ran. A concurrent session
|
||||
or the operator's own editing lands in the same diff.
|
||||
- **That what a session leaves in a handoff directory is safe for another
|
||||
machine to open.** `share_dir` and `[mounts] read_write` carry
|
||||
session-authored bytes and session-chosen filenames out of the container on
|
||||
purpose. Sanitising happens where dejima does the printing, and stops there.
|
||||
- **That the allowlist bounds exfiltration once a session has push access.**
|
||||
A commit is an outbound channel to a host that is on the list by definition.
|
||||
See [ci.md](ci.md).
|
||||
|
||||
@@ -38,6 +38,11 @@ merge_block = gh pr merge
|
||||
# repository-scoped fine-grained PAT is the expected shape.
|
||||
repo_flags = --repo -R
|
||||
repo_env = GH_REPO
|
||||
# `gh api repos/<owner>/<name>/...` names a repository with no --repo anywhere
|
||||
# in it, and is the ordinary way to reach an endpoint the subcommands do not
|
||||
# cover. Scoped to `api` so the guard is not comparing every positional
|
||||
# argument that contains two slashes (#243).
|
||||
repo_paths = api:repos
|
||||
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
|
||||
# See the gitea manifest's note: this is the key an identity supplies and a
|
||||
# project may not. The read-only [github] token_file is core's rather than
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Notification/Stop hook. bin/dejima registers it, per session, into the
|
||||
# throwaway settings.json copy it generates for a session that has remote
|
||||
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
|
||||
# from the operator's real settings.json; registering it in that real file
|
||||
# Notification hook. The selected harness's plugin registers it, per session,
|
||||
# into the throwaway settings.json copy bin/dejima generates for a session
|
||||
# that has remote access enabled, and nowhere else. Every sandbox gets such a
|
||||
# copy, derived from the operator's real settings.json; registering it there
|
||||
# instead would put this hook into all of them - and into the host's own
|
||||
# Claude Code, which reads that file directly - where there is no marker
|
||||
# directory to write into. The copy is mounted read-only (see
|
||||
|
||||
+7
-3
@@ -2,9 +2,9 @@
|
||||
# Idempotent installer for dejima. Registers the selected harness's guard
|
||||
# hooks wherever that harness reads them from, and puts
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project,
|
||||
# bin/dejima-audit and the opt-in remote-access scripts on PATH. Safe to
|
||||
# re-run - every step is a no-op if already done, and every change is
|
||||
# printed as it happens.
|
||||
# bin/dejima-audit, bin/dejima-share and the opt-in remote-access scripts on
|
||||
# PATH. Safe to re-run - every step is a no-op if already done, and every
|
||||
# change is printed as it happens.
|
||||
#
|
||||
# It never enables anything: the remote-access scripts do nothing without
|
||||
# [remote-access] enabled = on in ~/.config/dejima/config, and the user units
|
||||
@@ -76,6 +76,10 @@ link_bin dejima-project
|
||||
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
|
||||
link_bin dejima-audit
|
||||
link_bin dejima-epitaph
|
||||
# ~/shared is one directory per project now, so "where do I put a file for the
|
||||
# next session" has an answer only this can give - and the launch notice about
|
||||
# files left over from the old layout tells the operator to run it.
|
||||
link_bin dejima-share
|
||||
link_bin dejima-update-check
|
||||
|
||||
# Remote access (opt-in, see docs/remote-access.md). All three are inert
|
||||
|
||||
+157
-21
@@ -502,7 +502,8 @@ _identity_fixture() {
|
||||
"token_file = $ID_DIR/default-token" \
|
||||
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
|
||||
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
|
||||
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
|
||||
'[identity.oss]' "ssh_key = $ID_DIR/oss-key" \
|
||||
'[identity.keyless]' "token_file = $ID_DIR/work-token"
|
||||
}
|
||||
|
||||
@test "an identity supplies the ssh key and the forge credential" {
|
||||
@@ -515,15 +516,59 @@ _identity_fixture() {
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "a key the identity does not name falls through to the file" {
|
||||
# An identity overrides what it names. Treating it as a replacement for
|
||||
# the whole section would make a half-filled one silently unset the rest.
|
||||
@test "an identity that leaves a credential to the section beneath it is refused" {
|
||||
# This used to fall through: oss names no token_file, so [forge.gitea]
|
||||
# answered for it, and a session launched as oss talked to the forge as
|
||||
# whichever account owns that token. An identity is how one session's
|
||||
# account is kept separate from another's, so a credential crossing that
|
||||
# line is the feature not working rather than a convenience it offers.
|
||||
_identity_fixture
|
||||
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
|
||||
[ "$status" -ne 0 ] || { echo "the borrowed token was accepted: $output"; return 1; }
|
||||
# Which credential, by name - "something is wrong with your identity" is
|
||||
# not something an operator can act on.
|
||||
[[ "$output" == *"[forge.gitea] token_file"* ]] \
|
||||
|| { echo "the refusal does not name the credential: $output"; return 1; }
|
||||
# And nothing resolved is printed, for the same reason the missing-section
|
||||
# refusal prints nothing: it would show this machine's token under oss.
|
||||
[[ "$output" != *"forge.gitea.token_file=$ID_DIR/default-token"* ]] \
|
||||
|| { echo "printed the borrowed credential anyway: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "an identity that leaves the ssh key to [core] is refused" {
|
||||
# The other half, and the one the ambiguity was about: [core] ssh_key is
|
||||
# the key a session gets when no identity is named, so an identity that
|
||||
# names none is asking to push as itself with the default account's key.
|
||||
_identity_fixture
|
||||
run "$REPO_ROOT/bin/dejima" --identity keyless --print-config
|
||||
[ "$status" -ne 0 ] || { echo "the borrowed key was accepted: $output"; return 1; }
|
||||
[[ "$output" == *"[core] ssh_key"* ]] \
|
||||
|| { echo "the refusal does not name the key: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "a credential belonging to a forge this launch does not use is not borrowed" {
|
||||
# Only the selected forge's keys. Refusing over a value nothing on this
|
||||
# launch reads would make an unrelated section able to block a launch.
|
||||
_identity_fixture
|
||||
printf '[forge.github]\npush_token_file = %s\n' "$ID_DIR/default-token" \
|
||||
>> "$CONFIG_DIR/config"
|
||||
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "an unused forge's credential blocked the launch: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "with no identity named, the config's own credentials are the ones used" {
|
||||
# The unnamed default, which is what [core] ssh_key means. Nothing about
|
||||
# the refusals above touches a host that never names an identity.
|
||||
_identity_fixture
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qx "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|
||||
|| { echo "oss has no token_file and did not fall through: $output"; return 1; }
|
||||
|| { echo "$output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@@ -1377,13 +1422,11 @@ EOF
|
||||
docker_log="$(mktemp)"
|
||||
# A launch runs dejima-update-check, which would ls-remote this checkout's
|
||||
# own origin. Off here rather than in each fixture's config, because the
|
||||
# launch is what triggers it. XDG_STATE_HOME is stated for the mirror
|
||||
# reason: left alone the cache lands under the test's HOME, so every test
|
||||
# misses and calls out again; exported by whoever ran the suite, it lands
|
||||
# in their real state directory.
|
||||
# launch is what triggers it. Its cache needs nothing further: it lands
|
||||
# under $HOME, which every launch fixture already points somewhere of its
|
||||
# own.
|
||||
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
|
||||
XDG_STATE_HOME="$RW_HOME/.local/state" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$1"
|
||||
}
|
||||
@@ -1506,7 +1549,6 @@ EOF
|
||||
# Same as _rw_launch: the launch would otherwise reach the network.
|
||||
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
XDG_STATE_HOME="$RW_HOME/.local/state" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
|
||||
}
|
||||
@@ -2476,12 +2518,12 @@ STUB
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"harness-claude-code"* ]] \
|
||||
|| { echo "no harness layer was built:"; echo "$output"; return 1; }
|
||||
grep -qxF "dejima:$(id -un)-harness-claude-code" "$docker_log" \
|
||||
grep -qxF "localhost/dejima:$(id -un)-harness-claude-code" "$docker_log" \
|
||||
|| { echo "the harness layer was not tagged as expected:"; cat "$docker_log"; return 1; }
|
||||
# The LAST compose invocation is the run; the first builds the base.
|
||||
local ran
|
||||
ran="$(tail -1 "$image_log")"
|
||||
[ "$ran" = "dejima:$(id -un)-harness-claude-code" ] \
|
||||
[ "$ran" = "localhost/dejima:$(id -un)-harness-claude-code" ] \
|
||||
|| { echo "the session ran on '$ran', not the harness layer"; cat "$image_log"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log" "$image_log"
|
||||
}
|
||||
@@ -2623,9 +2665,9 @@ _upd_fixture() {
|
||||
UPD_DIR="$(mktemp -d)"
|
||||
UPD_ORIGIN="$UPD_DIR/origin"
|
||||
UPD_CHECKOUT="$UPD_DIR/checkout"
|
||||
# Stated, not inherited: the cache lands under XDG_STATE_HOME, and a test
|
||||
# that left it unset would write into whoever ran it.
|
||||
export XDG_STATE_HOME="$UPD_DIR/state"
|
||||
# Stated, not inherited: the cache lands under $HOME, and a test that left
|
||||
# that alone would write into whoever ran it.
|
||||
export HOME="$UPD_DIR/home"
|
||||
# -b trunk so the fixture does not depend on the host's
|
||||
# init.defaultBranch, and so no guard has an opinion about the name.
|
||||
git init -q -b trunk "$UPD_ORIGIN"
|
||||
@@ -2638,6 +2680,9 @@ _upd_fixture() {
|
||||
|
||||
_upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
|
||||
|
||||
# Where _cache_file puts it, named once rather than at each assertion.
|
||||
_upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
|
||||
|
||||
@test "a checkout level with its remote says nothing" {
|
||||
_upd_fixture
|
||||
run _upd_check
|
||||
@@ -2670,7 +2715,7 @@ _upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
|
||||
|| { echo "$output"; return 1; }
|
||||
|
||||
git -C "$UPD_CHECKOUT" fetch -q origin
|
||||
rm -rf "$XDG_STATE_HOME"
|
||||
rm -f "$(_upd_cache)"
|
||||
run _upd_check
|
||||
[[ "$output" == *"has 1 commit this checkout does not"* ]] \
|
||||
|| { echo "no count once the commit is present: $output"; return 1; }
|
||||
@@ -2701,7 +2746,7 @@ _upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
|
||||
# Silence alone would also be produced by asking and saying nothing. The
|
||||
# cache is written on every lookup, including a failed one, so its absence
|
||||
# is what shows no lookup happened.
|
||||
[ ! -e "$XDG_STATE_HOME/dejima/update-check" ] \
|
||||
[ ! -e "$(_upd_cache)" ] \
|
||||
|| { echo "it asked anyway"; return 1; }
|
||||
}
|
||||
|
||||
@@ -2769,13 +2814,13 @@ _upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
|
||||
# Written into the file directly: `git remote set-url` rejects a value
|
||||
# starting with a dash itself, which would leave that case untested.
|
||||
sed -i "s#\turl = .*#\turl = $url#" "$UPD_CHECKOUT/.git/config"
|
||||
rm -rf "$XDG_STATE_HOME"
|
||||
rm -f "$(_upd_cache)"
|
||||
run _upd_check
|
||||
[ "$status" -eq 0 ] || { echo "$url exited $status"; return 1; }
|
||||
[ ! -e "$marker" ] || { echo "$url ran"; return 1; }
|
||||
# Nothing recorded either, so a refused URL does not also poison the
|
||||
# cache for the hour after someone fixes it.
|
||||
[ ! -e "$XDG_STATE_HOME/dejima/update-check" ] \
|
||||
[ ! -e "$(_upd_cache)" ] \
|
||||
|| { echo "$url was attempted"; return 1; }
|
||||
done
|
||||
}
|
||||
@@ -2896,3 +2941,94 @@ EOF
|
||||
[ "$output" -eq 0 ] || { echo "the launch called out:"; grep ls-remote "$git_log"; return 1; }
|
||||
rm -rf "$workspace" "$GIT_WRAP"
|
||||
}
|
||||
|
||||
@test "[identity.default] is what a launch acts under when none is named" {
|
||||
# The unnamed default gets a name, which is what lets it carry a git name
|
||||
# and a forge credential beside the key rather than those coming from
|
||||
# three different places.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
|
||||
write_config '[core]' 'forge = gitea' \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||
"token_file = $ID_DIR/tok" 'user_name = dejima agent'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" \
|
||||
|| { echo "the default identity did not supply the key: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
|
||||
# Two spellings of one thing. Which key a session pushed with is not
|
||||
# something to work out from precedence rules.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/new-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -ne 0 ] || { echo "both spellings were accepted: $output"; return 1; }
|
||||
[[ "$output" == *"[core] ssh_key"* ]] && [[ "$output" == *"[identity.default]"* ]] \
|
||||
|| { echo "the refusal does not name both: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "[core] ssh_key on its own still works, and says what replaced it" {
|
||||
# Deprecated, not removed: it is how every config written before
|
||||
# identities existed says this.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/old-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"[identity.default]"* ]] \
|
||||
|| { echo "nothing said what replaced it: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "a named identity still beats [identity.default]" {
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
|
||||
write_config '[core]' 'forge = none' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||
run "$REPO_ROOT/bin/dejima" --identity bot --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/bot-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "the [core] ssh_key deprecation is warned even when a launch does not use it" {
|
||||
# A config with [core] ssh_key and a named identity is one migration away
|
||||
# from the refusal - adding [identity.default] would stop it starting.
|
||||
# Finding that out on the launch that breaks is worse than being told now,
|
||||
# so the warning is about the config rather than about this launch.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"[core] ssh_key is deprecated"* ]] \
|
||||
|| { echo "no migration warning: $output"; return 1; }
|
||||
# And says it is not the key in play, so the reader is not left thinking
|
||||
# this launch pushed with it.
|
||||
[[ "$output" == *"runs as bot"* ]] \
|
||||
|| { echo "the warning does not say which account is in force: $output"; return 1; }
|
||||
# The path, because "move it" is not actionable without knowing what.
|
||||
[[ "$output" == *"$ID_DIR/old-key"* ]] \
|
||||
|| { echo "the warning does not name the key: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "a migrated config is warned about nothing" {
|
||||
# The other half of a deprecation warning: it has to stop. One that fires
|
||||
# after the migration is one an operator learns to ignore, which is how
|
||||
# the next warning gets missed too.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
|
||||
write_config '[core]' 'forge = none' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"deprecated"* ]] \
|
||||
|| { echo "still warning after the migration: $output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/key" <<< "$output" \
|
||||
|| { echo "the migrated key is not the one in force: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
+26
-7
@@ -106,6 +106,23 @@ await_shipped() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Blocks until the shipper has finished a poll and is asleep between polls.
|
||||
# The two SIGTERM tests below need exactly that state - the trap installed, one
|
||||
# scan done - and both used to state it by sleeping for a second. That is a
|
||||
# statement about the machine rather than about the shipper: under load the
|
||||
# startup that precedes the trap takes longer than the sleep, the TERM lands
|
||||
# before there is a trap to catch it, and the process dies flushing nothing.
|
||||
# The nap is a child `sleep` of the shipper, which makes the state observable.
|
||||
await_napping() {
|
||||
local i
|
||||
for i in $(seq 1 200); do
|
||||
pgrep -P "$SHIP_PID" -x sleep >/dev/null 2>&1 && return 0
|
||||
sleep 0.05
|
||||
done
|
||||
echo "the shipper never reached its first nap" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
@test "a line is shipped to Loki while the session runs" {
|
||||
start_loki
|
||||
write_config
|
||||
@@ -156,10 +173,11 @@ await_shipped() {
|
||||
write_config
|
||||
mkdir -p "$(slug_dir)"
|
||||
start_ship
|
||||
# Long enough for one poll to read the line into the batch, short enough
|
||||
# that the 2s batch interval has not flushed it on its own.
|
||||
# Written before the first poll, so the poll reads it into the batch; the
|
||||
# kill then lands while the shipper is asleep with it still in there,
|
||||
# because the batch interval has not elapsed since it started.
|
||||
say "already batched" >> "$(slug_dir)/s1.jsonl"
|
||||
sleep 1
|
||||
await_napping
|
||||
kill -TERM "$SHIP_PID"
|
||||
wait "$SHIP_PID" 2>/dev/null || true
|
||||
SHIP_PID=""
|
||||
@@ -172,10 +190,11 @@ await_shipped() {
|
||||
write_config
|
||||
mkdir -p "$(slug_dir)"
|
||||
start_ship
|
||||
# Written and killed inside one poll interval, so nothing has read it yet:
|
||||
# only the trap's own rescan can find it. A final_flush that flushed the
|
||||
# batch without rescanning would pass the test above and fail this one.
|
||||
sleep 1
|
||||
# Written after a poll has already run and killed before the next one, so
|
||||
# nothing has read it yet: only the trap's own rescan can find it. A
|
||||
# final_flush that flushed the batch without rescanning would pass the test
|
||||
# above and fail this one.
|
||||
await_napping
|
||||
say "the final answer" >> "$(slug_dir)/s1.jsonl"
|
||||
kill -TERM "$SHIP_PID"
|
||||
wait "$SHIP_PID" 2>/dev/null || true
|
||||
|
||||
Vendored
+87
-8
@@ -27,7 +27,15 @@ import time
|
||||
|
||||
HELPER = os.path.abspath(sys.argv[1])
|
||||
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
|
||||
WORK = os.environ.get("TTY_SUSPEND_WORK") or "/tmp"
|
||||
# Required, not defaulted. The fallback was "/tmp", and the three paths below
|
||||
# are then fixed names in a world-writable directory that this fixture goes on
|
||||
# to WRITE and EXECUTE. Every bats caller passes a directory, so nothing
|
||||
# shipped ever took that branch - but this runs on CI hosts, and a predictable
|
||||
# executed path is not a thing to leave lying around for the one caller that
|
||||
# forgets (#249).
|
||||
WORK = os.environ.get("TTY_SUSPEND_WORK")
|
||||
if not WORK:
|
||||
sys.exit("tty-suspend-harness: set TTY_SUSPEND_WORK to a directory of its own")
|
||||
RECEIVED = os.path.join(WORK, "received")
|
||||
LAUNCHER = os.path.join(WORK, "launcher.sh")
|
||||
CONTAINER = os.path.join(WORK, "container.py")
|
||||
@@ -110,8 +118,8 @@ finally:
|
||||
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
|
||||
# existed. It is the control: a harness that cannot see the bug cannot be
|
||||
# trusted to see the fix either.
|
||||
start_helper = "" if os.environ.get("TTY_SUSPEND_NO_HELPER") else \
|
||||
'"%s" &\nhelper=$!' % HELPER
|
||||
HELPER_EXPECTED = not os.environ.get("TTY_SUSPEND_NO_HELPER")
|
||||
start_helper = '"%s" &\nhelper=$!' % HELPER if HELPER_EXPECTED else ""
|
||||
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
|
||||
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
|
||||
# before the launcher stops itself. Without the conversion the client is the
|
||||
@@ -159,7 +167,11 @@ if pid == 0:
|
||||
if slave > 2:
|
||||
os.close(slave)
|
||||
os.close(master)
|
||||
env = dict(os.environ, PS1="PROMPT> ")
|
||||
# LC_ALL=C so the shell's job-control notice is the "Stopped" this expects.
|
||||
# Left to the environment it is whatever locale the runner happens to have,
|
||||
# and the assertion then fails on a translated message with nothing about
|
||||
# the terminal handling having changed (#249).
|
||||
env = dict(os.environ, PS1="PROMPT> ", LC_ALL="C", LANG="C")
|
||||
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
|
||||
os.execvpe(SHELL, args, env)
|
||||
os.close(slave)
|
||||
@@ -209,6 +221,53 @@ def lflag():
|
||||
return termios.tcgetattr(master)[3]
|
||||
|
||||
|
||||
def reclaimed():
|
||||
"""True once dejima-tty-suspend has taken ^Z back from the container's raw mode.
|
||||
|
||||
Read off the pty rather than inferred from output, and specific enough to
|
||||
mean only the helper. Raw mode has ISIG off; a plain shell has ISIG on with
|
||||
intr at ^C. Only the helper leaves ISIG on, susp at ^Z, and intr undefined
|
||||
- which is the whole of what it does, so this is the feature's precondition
|
||||
stated exactly.
|
||||
"""
|
||||
attrs = termios.tcgetattr(master)
|
||||
cc = attrs[6]
|
||||
return bool(attrs[3] & termios.ISIG) \
|
||||
and cc[termios.VSUSP] == b"\x1a" \
|
||||
and cc[termios.VINTR] == b"\x00"
|
||||
|
||||
|
||||
def await_reclaim(timeout=10.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if reclaimed():
|
||||
return True
|
||||
pump(0.05)
|
||||
return reclaimed()
|
||||
|
||||
|
||||
def raw_restored():
|
||||
"""True once the resumed client has put the terminal back into raw mode."""
|
||||
flags = lflag()
|
||||
return not (flags & termios.ICANON) and not (flags & termios.ECHO)
|
||||
|
||||
|
||||
def await_raw(timeout=15.0):
|
||||
"""Wait for that, rather than reading the flags at a fixed moment after fg.
|
||||
|
||||
`fg` returns as soon as the job is continued; the client sets raw mode some
|
||||
scheduling later. Sampling the flags right after fg was timing the machine,
|
||||
and on a loaded one it read the shell's own canonical mode and reported it
|
||||
as a failure to restore - the same shape as the reclaim wait above.
|
||||
"""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if raw_restored():
|
||||
return True
|
||||
pump(0.05)
|
||||
return raw_restored()
|
||||
|
||||
|
||||
def received():
|
||||
try:
|
||||
with open(RECEIVED, "rb") as fh:
|
||||
@@ -230,6 +289,27 @@ try:
|
||||
check("the container run attached", expect("CONTAINER-ATTACHED"))
|
||||
|
||||
for cycle in (1, 2):
|
||||
# Wait for the helper to hold the terminal before typing at it (#262).
|
||||
#
|
||||
# CONTAINER-ATTACHED is printed by the stand-in immediately after it
|
||||
# sets raw mode, so it marks the START of the window this feature
|
||||
# exists to close, not the end. The helper only acts once it has POLLED
|
||||
# the terminal and seen -isig, so between those two moments ^Z is still
|
||||
# the byte 0x1a. Sending into that window put the byte in the container
|
||||
# and failed `nothing-reached-the-container` - the security assertion -
|
||||
# on a machine where nothing was wrong but the scheduling.
|
||||
#
|
||||
# Waiting for the precondition rather than lengthening the deadline
|
||||
# after it: with the terminal provably converting ^Z to a signal before
|
||||
# anything is typed, that check can no longer fail for timing reasons
|
||||
# at all, which is the point. A longer timeout would only have narrowed
|
||||
# the window.
|
||||
#
|
||||
# Skipped without the helper, where the byte reaching the container is
|
||||
# the expected result and this would never come true.
|
||||
if HELPER_EXPECTED:
|
||||
check("cycle%d-suspend-char-reclaimed" % cycle, await_reclaim(),
|
||||
"dejima-tty-suspend never took ^Z back from raw mode")
|
||||
before = received()
|
||||
forget()
|
||||
send("\x1a")
|
||||
@@ -243,12 +323,11 @@ try:
|
||||
expect("SHELL-ALIVE-%d" % cycle, 5.0))
|
||||
forget()
|
||||
send("fg\n")
|
||||
expect("CONTAINER-REDRAW", 5.0)
|
||||
restored = await_raw()
|
||||
flags = lflag()
|
||||
check("cycle%d-raw-mode-restored" % cycle,
|
||||
not (flags & termios.ICANON) and not (flags & termios.ECHO),
|
||||
check("cycle%d-raw-mode-restored" % cycle, restored,
|
||||
"ICANON=%s ECHO=%s" % (bool(flags & termios.ICANON), bool(flags & termios.ECHO)))
|
||||
check("cycle%d-redraw-poked" % cycle, "CONTAINER-REDRAW" in "".join(seen))
|
||||
check("cycle%d-redraw-poked" % cycle, expect("CONTAINER-REDRAW", 15.0))
|
||||
check("cycle%d-session-alive" % cycle, "LAUNCHER-DONE" not in "".join(seen))
|
||||
|
||||
before = received()
|
||||
|
||||
+106
-25
@@ -95,14 +95,12 @@ launch() {
|
||||
# No default, so a config that launches must select one.
|
||||
# A launch runs dejima-update-check, which would ls-remote this checkout's
|
||||
# own origin. Off here rather than in each fixture's config, because the
|
||||
# launch is what triggers it. XDG_STATE_HOME is stated for the mirror
|
||||
# reason: left alone the cache lands under the test's HOME, so every test
|
||||
# misses and calls out again; exported by whoever ran the suite, it lands
|
||||
# in their real state directory.
|
||||
# launch is what triggers it. Its cache needs nothing further: it lands
|
||||
# under $HOME, which every launch fixture already points somewhere of its
|
||||
# own.
|
||||
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
XDG_STATE_HOME="$LAUNCH_DIR/state" \
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
|
||||
@@ -583,8 +581,8 @@ EOF
|
||||
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Multiple running dejima containers"* ]] || { echo "$output"; return 1; }
|
||||
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE 'dejima-run-bbbb +beta' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE 'beta +dejima-run-bbbb' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@@ -597,8 +595,8 @@ EOF
|
||||
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||
# beta is first in the attach listing, so beta is 1 - not alpha, which
|
||||
# docker happens to report first.
|
||||
grep -qE 'beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE 'alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE '^ *beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE '^ *alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@@ -615,7 +613,7 @@ EOF
|
||||
chmod +x "$STUB_DIR/dejima-attach"
|
||||
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||
[ "$status" -ne 0 ]
|
||||
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"dejima-attach 1"* ]] || { echo "invented a number: $output"; return 1; }
|
||||
[[ "$output" != *"Remote access is not configured"* ]] \
|
||||
|| { echo "leaked the attach refusal: $output"; return 1; }
|
||||
@@ -1068,13 +1066,13 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
|
||||
|
||||
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
|
||||
}
|
||||
|
||||
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
|
||||
@@ -1118,8 +1116,8 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q -- "-t dejima:$(id -un)-global -f" <(docker_log)
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-global" ]
|
||||
grep -q -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log)
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-global" ]
|
||||
}
|
||||
|
||||
@test "the launcher builds the global layer then the project layer on top of it" {
|
||||
@@ -1129,12 +1127,12 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
global_line=$(grep -n -- "-t dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
|
||||
project_line=$(grep -n -- "-t dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
|
||||
global_line=$(grep -n -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
|
||||
project_line=$(grep -n -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
|
||||
[ -n "$global_line" ]
|
||||
[ -n "$project_line" ]
|
||||
[ "$global_line" -lt "$project_line" ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
|
||||
}
|
||||
|
||||
@test "the project layer's Dockerfile is generated FROM the global layer, not the base image" {
|
||||
@@ -1150,11 +1148,11 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
[ "$status" -eq 0 ]
|
||||
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
# base -> harness -> global -> project, each FROM the one before it.
|
||||
dockerfile_for "dejima:$(id -un)-harness-claude-code" \
|
||||
| grep -qxF "FROM dejima:$(id -un)"
|
||||
dockerfile_for "dejima:$(id -un)-global" \
|
||||
| grep -qxF "FROM dejima:$(id -un)-harness-claude-code"
|
||||
dockerfile_for "dejima:$(id -un)-$slug" | grep -qxF "FROM dejima:$(id -un)-global"
|
||||
dockerfile_for "localhost/dejima:$(id -un)-harness-claude-code" \
|
||||
| grep -qxF "FROM localhost/dejima:$(id -un)"
|
||||
dockerfile_for "localhost/dejima:$(id -un)-global" \
|
||||
| grep -qxF "FROM localhost/dejima:$(id -un)-harness-claude-code"
|
||||
dockerfile_for "localhost/dejima:$(id -un)-$slug" | grep -qxF "FROM localhost/dejima:$(id -un)-global"
|
||||
}
|
||||
|
||||
@test "the launcher builds no dependency layer when neither global nor project deps are approved" {
|
||||
@@ -1165,10 +1163,10 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
# harness in the image - so this is about the DEPENDENCY layers, which is
|
||||
# what "no approved dependencies" is a statement about.
|
||||
local tagged
|
||||
tagged="$(grep -oE -- '-t dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
|
||||
[ "$tagged" = "dejima:$(id -un)-harness-claude-code" ] \
|
||||
tagged="$(grep -oE -- '-t localhost/dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
|
||||
[ "$tagged" = "localhost/dejima:$(id -un)-harness-claude-code" ] \
|
||||
|| { echo "expected only the harness layer to be built, got:"; echo "$tagged"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
|
||||
}
|
||||
|
||||
@test "the launcher warns when the approval store sits inside the mounted workspace" {
|
||||
@@ -1935,3 +1933,86 @@ declare_repeatedly() {
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Removed apt:ripgrep"* ]]
|
||||
}
|
||||
|
||||
@test "with no terminal the multi-container case stays a list and an exit" {
|
||||
# The picker must never be what a script meets. `run` gives no tty, which
|
||||
# is the same shape as cron, a pipe, or an agent running this: a prompt
|
||||
# there is a hang nobody can answer.
|
||||
stub_two_containers
|
||||
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||
[ "$status" -ne 0 ] || { echo "a script got no error: $output"; return 1; }
|
||||
[[ "$output" == *"specify one with --container"* ]] \
|
||||
|| { echo "the flag is not offered: $output"; return 1; }
|
||||
[[ "$output" != *"q to quit"* ]] || { echo "prompted with no tty: $output"; return 1; }
|
||||
# And the line an agent is pointed at stays, since this is the path an
|
||||
# agent's operator reads.
|
||||
[[ "$output" == *"don't let it guess one"* ]] \
|
||||
|| { echo "the guess-one warning is gone: $output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "the picker acts on the row that was picked" {
|
||||
# The number typed indexes the rows as printed. Worth its own test because
|
||||
# the failure is silent: the wrong session is patched, the domain is
|
||||
# allowed, and everything reports success.
|
||||
stub_two_containers
|
||||
local out
|
||||
# beta is docker's second row, so 2 is beta - and 1 must not be.
|
||||
# || true: the stub's reachability probe decides the exit status, and this
|
||||
# is a test about which container was acted on, not about that.
|
||||
out="$(printf '2\n' | TERM=xterm-256color script -qec \
|
||||
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
|
||||
[[ "$out" == *dejima-run-bbbb* ]] \
|
||||
|| { echo "picking 2 did not act on the second row: $out"; return 1; }
|
||||
[[ "$out" != *"inside dejima-run-aaaa"* ]] \
|
||||
|| { echo "picking 2 acted on the first row: $out"; return 1; }
|
||||
out="$(printf '1\n' | TERM=xterm-256color script -qec \
|
||||
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
|
||||
[[ "$out" == *dejima-run-aaaa* ]] \
|
||||
|| { echo "picking 1 did not act on the first row: $out"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "quitting the picker allows nothing" {
|
||||
stub_two_containers
|
||||
local out
|
||||
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
|
||||
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null)"
|
||||
[[ "$out" != *Resolving* ]] || { echo "q went ahead anyway: $out"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "the picker's columns are padded to what is in them" {
|
||||
# "dejima-sandbox-run-" plus twelve hex digits is 31 characters, and the
|
||||
# column was a fixed 28 - so every real name overran it and the project
|
||||
# beside it started one space later than the row above. That is the whole
|
||||
# complaint: there is no telling where the name stops.
|
||||
stub_two_containers
|
||||
local out proj_cols
|
||||
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
|
||||
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null \
|
||||
| sed 's/\x1b\[[0-9;]*m//g; s/\r$//')"
|
||||
# The container name starts at the same column on every row, header
|
||||
# included, which is what padding to the widest value buys.
|
||||
proj_cols="$(grep -oE '^ +[0-9]+ +[a-z]+ +dejima-run' <<< "$out" \
|
||||
| awk '{ print index($0, "dejima-run") }' | sort -u | wc -l)"
|
||||
[ "$proj_cols" -eq 1 ] \
|
||||
|| { echo "rows start the name at different columns:"; echo "$out"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow's picker does not wipe the terminal it was run in" {
|
||||
# It shares the banner with dejima-attach and deliberately not the clear.
|
||||
# dejima-attach is a front door, navigated to; this is a prompt inside a
|
||||
# shell the operator is already using, and wiping their scrollback to ask
|
||||
# one question is worse than the interleaving the clear exists to fix.
|
||||
stub_two_containers
|
||||
local out
|
||||
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
|
||||
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
|
||||
[[ "$out" != *$'\033[2J'* ]] \
|
||||
|| { echo "dejima-allow cleared the operator's screen"; return 1; }
|
||||
# It still drew the picker, so this is not passing because nothing ran.
|
||||
[[ "$out" == *"q to quit"* ]] || { echo "no picker at all: $out"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@@ -1045,11 +1045,34 @@ assert_remedy_forges_exist() {
|
||||
# with (#187). The guard is generic; the manifest is what makes it fire.
|
||||
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
|
||||
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
|
||||
# And in a path rather than a flag: `gh api repos/<owner>/<name>/...`
|
||||
# names a repository with no --repo in it at all (#243).
|
||||
[ "$(lib manifest_get forge github repo_paths)" = "api:repos" ]
|
||||
# The gitea CLIs resolve the repo from origin and refuse another host, so
|
||||
# there is nothing to declare and nothing for the guard to compare. A
|
||||
# forge is not required to be aimable.
|
||||
[ -z "$(lib manifest_get forge gitea repo_flags)" ]
|
||||
[ -z "$(lib manifest_get forge gitea repo_env)" ]
|
||||
[ -z "$(lib manifest_get forge gitea repo_paths)" ]
|
||||
}
|
||||
|
||||
@test "a repo path has to name a subcommand and a segment" {
|
||||
# `<subcommand>:<first segment>`. The subcommand half is what keeps the
|
||||
# guard from comparing every positional argument that contains two
|
||||
# slashes, so a spelling that omits it is not a narrower declaration - it
|
||||
# is one that would refuse an issue title.
|
||||
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/pathtype.XXXXXX")"
|
||||
mkdir -p "$d/forges/probe"
|
||||
for bad in "repos" "api:" ":repos" "api:repos/more" "api:/repos" "-api:repos"; do
|
||||
printf 'repo_paths = %s\n' "$bad" > "$d/forges/probe/forge.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
|
||||
[ "$status" -ne 0 ] || { echo "accepted repo_paths = $bad"; return 1; }
|
||||
done
|
||||
printf 'repo_paths = api:repos\n' > "$d/forges/probe/forge.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a repo flag has to look like a flag" {
|
||||
@@ -1263,3 +1286,62 @@ assert_remedy_forges_exist() {
|
||||
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "a workflow that omits block_merge blocks merges anyway" {
|
||||
# The default decides what happens to a workflow that forgets the key -
|
||||
# a third-party one, or a fourth shipped one added in a hurry. `off` means
|
||||
# a missing key silently permits merging from inside a session, which is
|
||||
# the direction a guard should never fail in. Every shipped workflow sets
|
||||
# it explicitly, so the invariant test covering those would pass either
|
||||
# way; this is about the ones nobody has written yet.
|
||||
FAKE_ROOT="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_ROOT/workflows/forgetful"
|
||||
printf 'requires =\npush = off\n' > "$FAKE_ROOT/workflows/forgetful/workflow.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" DEJIMA_CONFIG_DIR="$CONFIG_DIR" \
|
||||
bash -c 'source "$1"; manifest_get workflow forgetful block_merge' _ "$LIB"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$output" = "on" ] \
|
||||
|| { echo "a workflow that names no block_merge got '$output'"; return 1; }
|
||||
rm -rf "$FAKE_ROOT"
|
||||
}
|
||||
|
||||
@test "a forge that aims its CLI also gets that CLI shadowed" {
|
||||
# entrypoint.sh installs the PATH shadow from merge_block and disclose
|
||||
# only (_shadow_clis). repo_flags and repo_env are exported to the
|
||||
# container regardless - so a forge declaring repo_flags without either of
|
||||
# the other two hands the guard its configuration with no guard on PATH to
|
||||
# read it, and the aiming check silently does not exist.
|
||||
#
|
||||
# Both shipped forges declare disclose, so nothing is broken today. This
|
||||
# is the manifest contract stated as an invariant, for the third forge
|
||||
# someone writes (#244).
|
||||
local forge named shadowed cli entry
|
||||
for dir in "$REPO_ROOT"/forges/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
forge="$(basename "$dir")"
|
||||
[ -n "$(lib manifest_get forge "$forge" repo_flags)$(lib manifest_get forge "$forge" repo_env)" ] \
|
||||
|| continue
|
||||
|
||||
# The CLI is the first word of each command the forge names.
|
||||
named=""
|
||||
for entry in pr_command issue_command; do
|
||||
cli="$(lib manifest_get forge "$forge" "$entry")"
|
||||
[ -n "$cli" ] && named="$named ${cli%% *}"
|
||||
done
|
||||
|
||||
# What entrypoint.sh would actually shadow: merge_block's first word,
|
||||
# and the CLI in each disclose record ("<kind>:<cli> <subcommand>").
|
||||
shadowed=" $(lib manifest_get forge "$forge" merge_block | cut -d' ' -f1) "
|
||||
while IFS= read -r entry; do
|
||||
entry="${entry#"${entry%%[![:space:]]*}"}"
|
||||
[ -n "$entry" ] || continue
|
||||
cli="${entry#*:}"
|
||||
shadowed="$shadowed ${cli%% *} "
|
||||
done < <(lib manifest_get forge "$forge" disclose | tr ',' '\n')
|
||||
|
||||
for cli in $named; do
|
||||
[[ "$shadowed" == *" $cli "* ]] \
|
||||
|| { echo "forge $forge aims '$cli' but nothing shadows it: declare it in merge_block or disclose"; return 1; }
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
+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" ]
|
||||
}
|
||||
|
||||
+173
-30
@@ -180,14 +180,12 @@ launch() {
|
||||
# No default, so a config that launches must select one.
|
||||
# A launch runs dejima-update-check, which would ls-remote this checkout's
|
||||
# own origin. Off here rather than in each fixture's config, because the
|
||||
# launch is what triggers it. XDG_STATE_HOME is stated for the mirror
|
||||
# reason: left alone the cache lands under the test's HOME, so every test
|
||||
# misses and calls out again; exported by whoever ran the suite, it lands
|
||||
# in their real state directory.
|
||||
# launch is what triggers it. Its cache needs nothing further: it lands
|
||||
# under $HOME, which every launch fixture already points somewhere of its
|
||||
# own.
|
||||
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
XDG_STATE_HOME="$LAUNCH_DIR/state" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
@@ -765,26 +763,28 @@ start_tmux() {
|
||||
}
|
||||
|
||||
# Provenance, pinned offline. Upstream's color.table is the authority, but a
|
||||
# test cannot go and fetch it, so the name->hex half is transcribed from it
|
||||
# here. The half that catches a typo is checked rather than transcribed: every
|
||||
# escape is decoded back to a hex and compared against the one written beside
|
||||
# it, so a value edited in one place and not the other fails here instead of
|
||||
# shipping under the name "gruvbox".
|
||||
@test "the picker's palette is gruvbox, by upstream's own values" {
|
||||
local -A gruvbox=(
|
||||
[bright_orange]=fe8019 [light1]=ebdbb2 [gray_245]=928374
|
||||
[dark4]=7c6f64 [dark2]=504945 [bright_green]=b8bb26
|
||||
[bright_yellow]=fabd2f [bright_red]=fb4934 [bright_blue]=83a598
|
||||
)
|
||||
local seen=0 r g b name hex
|
||||
while read -r r g b name hex; do
|
||||
seen=$((seen + 1))
|
||||
[ "${gruvbox[$name]:-unknown}" = "$hex" ]
|
||||
[ "$(printf '%02x%02x%02x' "$r" "$g" "$b")" = "$hex" ]
|
||||
done < <(sed -n \
|
||||
"s/.*38;2;\([0-9]*\);\([0-9]*\);\([0-9]*\)m'.*# \([a-z_0-9]*\) *#\([0-9a-f]*\).*/\1 \2 \3 \4 \5/p" \
|
||||
"$REPO_ROOT/bin/dejima-attach")
|
||||
[ "$seen" -eq "${#gruvbox[@]}" ]
|
||||
# The terminal palette carries no hexes at all any more, and that is the
|
||||
# property worth pinning. It used to hardcode gruvbox in 24-bit, which is the
|
||||
# brand but assumes a background dejima does not own: measured against a light
|
||||
# one, light1 came out at 1.21:1 and bright yellow at 1.49:1. Indexed colours
|
||||
# cannot have that problem, because the theme's author picked them against
|
||||
# their own background - and on a gruvbox terminal they ARE those hexes.
|
||||
@test "the terminal palette is the terminal's own, not hardcoded" {
|
||||
local lib="$REPO_ROOT/bin/dejima-color-lib.sh"
|
||||
run grep -n '38;2;' "$lib" "$REPO_ROOT/bin/dejima-tui-lib.sh"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "24-bit colour is back in a terminal palette:"; echo "$output"; return 1; }
|
||||
# And it is one palette: a second definition is how C_OK and C_WARN came
|
||||
# to mean two different things depending on which file a caller sourced.
|
||||
run grep -c '^\s*C_OK=' "$REPO_ROOT/bin/dejima-tui-lib.sh"
|
||||
[ "$output" -eq 0 ] \
|
||||
|| { echo "dejima-tui-lib.sh defines its own palette again"; return 1; }
|
||||
# The colours it does use are the semantic slots, so a gruvbox terminal
|
||||
# fills them with gruvbox.
|
||||
local slot
|
||||
for slot in '92m' '93m' '91m' '94m' '90m'; do
|
||||
grep -q "$slot" "$lib" || { echo "the palette lost slot $slot"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
|
||||
@@ -1322,7 +1322,6 @@ EOF
|
||||
stub_launch
|
||||
write_remote_config
|
||||
launch "$PROJ"
|
||||
grep -q 'dejima-notify.sh waiting' "$LAUNCH_DIR/settings-copy.json"
|
||||
grep -q 'dejima-notify.sh done' "$LAUNCH_DIR/settings-copy.json"
|
||||
# The layer is a throwaway, so the operator's real settings.json - which
|
||||
# nothing registers this hook into - is left byte-for-byte alone.
|
||||
@@ -1337,8 +1336,8 @@ EOF
|
||||
# Copying them would run each of them twice. Verified against Claude Code
|
||||
# 2.1.220: a SessionStart hook in a --settings file and one in
|
||||
# ~/.claude/settings.json both fire.
|
||||
[ "$(jq '.hooks.Notification | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
|
||||
[ "$(jq '.hooks.Stop | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
|
||||
[ "$(jq '.hooks.Notification // [] | length' "$LAUNCH_DIR/settings-copy.json")" = "0" ]
|
||||
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
||||
[[ "$output" != *operator-desktop-notify.sh* ]]
|
||||
[[ "$output" != *operator-tts.sh* ]]
|
||||
@@ -1354,15 +1353,29 @@ EOF
|
||||
# the whitelist below is what makes the log sufficient.
|
||||
stub_curl() {
|
||||
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
|
||||
# stdin as well as argv, because the request URL now arrives that way and
|
||||
# a stub that only saw argv would report the push as carrying no
|
||||
# destination at all - which reads like the value being dropped.
|
||||
#
|
||||
# Drained only when `--config -` asked for it, exactly as the real curl
|
||||
# does. A stub that reads stdin unconditionally HANGS when the caller
|
||||
# sends none, so a change that stopped redirecting would fail this suite
|
||||
# by timing out rather than by failing an assertion.
|
||||
cat > "$STUB_DIR/curl" <<EOF
|
||||
#!/bin/bash
|
||||
printf '%s\n' "\$@" >> "$STUB_DIR/curl-argv"
|
||||
_want_stdin=
|
||||
for _a in "\$@"; do
|
||||
[ "\$_a" = --config ] && _want_stdin=1
|
||||
done
|
||||
[ -z "\$_want_stdin" ] || cat >> "$STUB_DIR/curl-stdin"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
}
|
||||
curl_log() { cat "$STUB_DIR/curl-argv" 2>/dev/null; }
|
||||
curl_stdin() { cat "$STUB_DIR/curl-stdin" 2>/dev/null; }
|
||||
|
||||
# The EXACT argument vector of one push, one argument per line - a whitelist,
|
||||
# not a search. Absence assertions cannot do this job: an argument that leaks
|
||||
@@ -1376,7 +1389,7 @@ expect_argv() {
|
||||
-H "Title: dejima: $1" \
|
||||
-H "Priority: default"
|
||||
[ -z "${3:-}" ] || printf '%s\n' -H "Click: $3"
|
||||
printf '%s\n' -d "$2" -- https://ntfy.example.invalid/dejima
|
||||
printf '%s\n' -d "$2" --config -
|
||||
}
|
||||
# Compared as a whole file, so a SECOND push would fail this too. The
|
||||
# sentinel is what makes "whole" true: both sides are command substitutions,
|
||||
@@ -1570,6 +1583,49 @@ EOF
|
||||
[ -z "$(curl_log)" ]
|
||||
}
|
||||
|
||||
@test "the ntfy URL never reaches curl's argv, because it can carry a token" {
|
||||
# An ntfy server that wants credentials takes them in the URL, and
|
||||
# docs/remote-access.md documents that form. Every argument of every
|
||||
# process on this host is readable by any local account through
|
||||
# /proc/<pid>/cmdline, so a URL in argv is a token published to the
|
||||
# machine for as long as the request runs - `ps` is enough.
|
||||
#
|
||||
# Both halves are asserted. Absent from argv is the fix; present on stdin
|
||||
# is what stops the fix from being "the push lost its destination", which
|
||||
# would pass an absence check just as well.
|
||||
stub_curl
|
||||
conf_with_ntfy
|
||||
write_entry 0123456789abcdef '%1' 'work:2'
|
||||
marker 0123456789abcdef waiting
|
||||
run "$REPO_ROOT/bin/dejima-notify-watch" --once
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
[[ "$(curl_log)" != *ntfy.example.invalid* ]] \
|
||||
|| { echo "the URL is in argv:"; curl_log; return 1; }
|
||||
[[ "$(curl_stdin)" == *'url = "https://ntfy.example.invalid/dejima"'* ]] \
|
||||
|| { echo "the URL did not reach curl at all:"; curl_stdin; return 1; }
|
||||
|
||||
# The click URL is not secret and stays where it is: it is composed from
|
||||
# base_url, which is the address of the operator's own front door.
|
||||
[[ "$(curl_log)" == *"Click: https://dejima.example.invalid/?arg=0123456789abcdef"* ]] \
|
||||
|| { echo "the click target moved:"; curl_log; return 1; }
|
||||
}
|
||||
|
||||
@test "a misconfigured ntfy URL is not echoed back with its credentials" {
|
||||
# The refusal path prints the offending value, and the journal is where
|
||||
# that lands under a systemd user unit. A token that survives a
|
||||
# misconfiguration by being written to the journal is a token that
|
||||
# outlives the mistake.
|
||||
stub_curl
|
||||
write_remote_config 'ntfy_url = https://someone:hunter2@ntfy.example.invalid/t{}'
|
||||
run "$REPO_ROOT/bin/dejima-notify-watch" --once
|
||||
[ "$status" -ne 0 ] || { echo "a bad URL was accepted: $output"; return 1; }
|
||||
[[ "$output" != *hunter2* ]] \
|
||||
|| { echo "the credential was echoed back: $output"; return 1; }
|
||||
[[ "$output" == *"ntfy_url"* ]] || { echo "$output"; return 1; }
|
||||
[ -z "$(curl_log)" ]
|
||||
}
|
||||
|
||||
@test "a config value curl would read as an option is refused, not passed on" {
|
||||
# The url schema type requires an http(s) scheme, which "-K/path" is not -
|
||||
# verified against the real curl, which would otherwise read "-K/path" as
|
||||
@@ -1952,6 +2008,12 @@ expect_ttyd_argv() {
|
||||
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
|
||||
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
|
||||
[ -z "${5:-}" ] || printf '%s\n' -t "buttonColors=$5"
|
||||
# Always sent, unlike the four above: a browser has no theme to inherit,
|
||||
# so this is not an option an operator turns on. Read out of the script
|
||||
# rather than transcribed - this file asserts that ttyd is given a theme
|
||||
# and that its values match the page (static.bats), not what they are.
|
||||
printf '%s\n' -t "$(sed -n "/^theme_opt=(-t '/,/^}')$/p" \
|
||||
"$REPO_ROOT/bin/dejima-ttyd" | sed "1s/^theme_opt=(-t '//; \$s/')$//")"
|
||||
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
|
||||
}
|
||||
# Sentinel on both sides: both are command substitutions, which strip trailing
|
||||
@@ -2177,7 +2239,10 @@ PY
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345
|
||||
run grep -cx -- '-t' "$STUB_DIR/ttyd-argv"
|
||||
# No fontSize reaches ttyd, rather than no -t at all: the theme is sent on
|
||||
# every launch (a browser has no palette to inherit), so counting -t stopped
|
||||
# measuring what this is about, which is that an unset option sends nothing.
|
||||
run grep -c 'fontSize' "$STUB_DIR/ttyd-argv"
|
||||
[ "$output" = "0" ]
|
||||
|
||||
# Set. Checked through the exact vector, not a substring: "fontSize=20
|
||||
@@ -2706,7 +2771,7 @@ nginx_blocks() {
|
||||
|
||||
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
|
||||
# and ~/.claude/hooks is what gets mounted read-only into the container.
|
||||
# Without this link every Notification and Stop is an exit-127.
|
||||
# Without this link every notification hook is an exit-127.
|
||||
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
|
||||
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
|
||||
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
|
||||
@@ -3054,3 +3119,81 @@ remote_doc() {
|
||||
[ "$status" -ne 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "one session-finished push per stop, not a second one a minute later" {
|
||||
# Claude Code raises Notification when it needs permission and when the
|
||||
# prompt has sat idle. A dejima session runs with permission prompts off,
|
||||
# so only the timer is left, and it expires shortly after the Stop it
|
||||
# follows - two pushes on a phone for one event, the second carrying
|
||||
# nothing the first had not. Debouncing cannot merge them: the watcher
|
||||
# keys on run id AND event, deliberately, so waiting and done are separate
|
||||
# signals whenever a harness can actually tell them apart.
|
||||
stub_launch
|
||||
write_remote_config
|
||||
launch "$PROJ"
|
||||
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
||||
[[ "$output" == *'dejima-notify.sh done'* ]] \
|
||||
|| { echo "the stop notification is gone entirely: $output"; return 1; }
|
||||
[[ "$output" != *'dejima-notify.sh waiting'* ]] \
|
||||
|| { echo "the idle timer is registered again: $output"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "the watcher still consumes a waiting marker no hook registers" {
|
||||
# Nothing writes one by default now, and that changes nothing here: the
|
||||
# marker directory is bind-mounted read-write, so a session creates
|
||||
# whatever names it likes. The enum is the boundary, not the registration,
|
||||
# and dropping `waiting` from it would leave a marker that accumulates
|
||||
# rather than one that is refused.
|
||||
conf_with_ntfy
|
||||
stub_curl
|
||||
write_entry 0123456789abcdef '%1' 'work:2'
|
||||
marker 0123456789abcdef waiting
|
||||
run "$REPO_ROOT/bin/dejima-notify-watch" --once
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ] \
|
||||
|| { echo "the marker was left to accumulate"; return 1; }
|
||||
assert_argv 'myproject needs input' 'Session is waiting for you.' \
|
||||
'https://dejima.example.invalid/?arg=0123456789abcdef'
|
||||
}
|
||||
|
||||
@test "the session picker starts on a clean screen, scrollback included" {
|
||||
# Reopening it from the web toolbar is a full page load, and it drew on
|
||||
# top of whatever the previous session had left on screen - rows
|
||||
# interleaved with the tail of the session just left, which reads as a
|
||||
# rendering fault rather than a new screen (#213). Invisible over SSH,
|
||||
# where a fresh connection to the forced command starts on a blank one.
|
||||
write_remote_config
|
||||
stub_docker_run_id 0123456789abcdef
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *$'\033[2J'* ]] || { echo "the screen is not cleared"; return 1; }
|
||||
# And the scrollback with it: without 3J a swipe up on a phone lands in
|
||||
# the middle of the previous session's output.
|
||||
[[ "$output" == *$'\033[3J'* ]] || { echo "the scrollback is left behind"; return 1; }
|
||||
|
||||
# Not on the machine interface. ttyd feeds run ids back in from --list,
|
||||
# and these bytes in a log are the failure this is gated to avoid.
|
||||
run script -qec "'$REPO_ROOT/bin/dejima-attach' --list" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *$'\033[2J'* ]] || { echo "--list cleared the screen"; return 1; }
|
||||
}
|
||||
|
||||
@test "no terminal, no clear" {
|
||||
# A pipe or a dumb terminal gets none of it. NO_COLOR does not suppress it
|
||||
# though: that asks for no decoration, not for a front door drawn over the
|
||||
# last session.
|
||||
local out
|
||||
out="$(bash -c 'source "$1"; tui_clear; echo AFTER' _ "$REPO_ROOT/bin/dejima-tui-lib.sh")"
|
||||
[ "$out" = AFTER ] || { echo "a pipe got escape bytes: $(printf '%q' "$out")"; return 1; }
|
||||
run script -qec "TERM=dumb bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
|
||||
[[ "$output" != *$'\033['* ]] || { echo "a dumb terminal got escape bytes"; return 1; }
|
||||
# NO_COLOR too, on a real terminal. It rides the same switch as the colour
|
||||
# and the glyphs, which is the reading this file already committed to -
|
||||
# the alternative is a picker answering to two ideas of "plain".
|
||||
run script -qec "NO_COLOR=1 TERM=xterm bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
|
||||
[[ "$output" != *$'\033['* ]] || { echo "NO_COLOR still got a screen clear"; return 1; }
|
||||
}
|
||||
|
||||
+196
-19
@@ -1,13 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Every confined suite at once, for the case where the whole set is genuinely
|
||||
# wanted - before opening a pull request, or after touching bin/dejima, which
|
||||
# nearly every suite reads.
|
||||
# The confined suites, in one of two lanes.
|
||||
#
|
||||
# The suites are separate files that share no state, which is what makes this
|
||||
# safe: each writes into its own BATS_*_TMPDIR, and the two that bind a socket
|
||||
# ask the kernel for a free port rather than naming one. Run sequentially they
|
||||
# take about twenty-five minutes on ten cores; run together, about seven, which
|
||||
# is the difference between checking the whole set and not bothering.
|
||||
# test/run-confined.sh every confined suite - the pull-request gate
|
||||
# test/run-confined.sh --changed only the suites the working diff calls for
|
||||
#
|
||||
# The suites are separate files that share no state, which is what makes running
|
||||
# them together safe: each writes into its own BATS_*_TMPDIR, and the two that
|
||||
# bind a socket ask the kernel for a free port rather than naming one. Within a
|
||||
# file, bats' own --jobs parallelism is used too; it needs flock rather than GNU
|
||||
# parallel, which is why --no-parallelize-across-files is passed alongside it
|
||||
# (bats refuses --jobs without GNU parallel unless across-files is off, and this
|
||||
# script is already doing the across-files half itself).
|
||||
#
|
||||
# CI does not use this. It runs the same suites as a matrix, one job each, so
|
||||
# that a failure names the suite it came from without anyone reading a log -
|
||||
@@ -26,22 +29,196 @@ cd "$root" || exit 1
|
||||
# this repo is usually worked on.
|
||||
PRIVILEGED=" runtime ci-runner "
|
||||
|
||||
suites=()
|
||||
# Which suites a changed path calls for. This is the table .dejima.md used to
|
||||
# carry as prose; it is here so that it runs, and so that an agent iterating
|
||||
# cannot map a change to the wrong suite by hand.
|
||||
#
|
||||
# Anything that matches no rule selects every suite. A path nobody has mapped
|
||||
# is a path nobody has decided about, and guessing "none" there is how a change
|
||||
# ships having been checked by nothing.
|
||||
suites_for() {
|
||||
case "$1" in
|
||||
# Read by nearly every other suite, so a change to one is the whole set.
|
||||
bin/dejima|bin/dejima-config-lib.sh) echo ALL ;;
|
||||
|
||||
bin/dejima-audit) echo audit ;;
|
||||
bin/dejima-update-check) echo config ;;
|
||||
bin/dejima-epitaph) echo config ;;
|
||||
bin/dejima-tty-suspend) echo static ;;
|
||||
bin/dejima-color-lib.sh) echo 'remote project' ;;
|
||||
bin/dejima-tui-lib.sh) echo 'remote project' ;;
|
||||
bin/dejima-conversation-ship) echo conversation ;;
|
||||
bin/dejima-share) echo share ;;
|
||||
# The slug scheme keys the transcript mounts, the epitaph and audit
|
||||
# stores, and the share layout, so a change to it is not one area's.
|
||||
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
|
||||
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo proxy ;;
|
||||
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep|bin/dejima-allow) echo project ;;
|
||||
bin/dejima-attach|bin/dejima-ttyd|bin/dejima-notify-watch) echo remote ;;
|
||||
web/*|systemd/*) echo remote ;;
|
||||
bin/dejima-provider-lib.sh|bin/dejima-harness-lib.sh) echo providers ;;
|
||||
bin/harness/*) echo providers ;;
|
||||
forges/*|workflows/*|harnesses/*|tools/*) echo providers ;;
|
||||
install.sh) echo config ;;
|
||||
|
||||
# static.bats asserts on files it does not own, so a shipped doc or a
|
||||
# docker/ file needs it as well as whatever else the change touched -
|
||||
# which for docker/ is most things, since the entrypoint and compose
|
||||
# file are read by nearly every suite that builds a launch.
|
||||
docker/*) echo ALL ;;
|
||||
hooks/*) echo 'static config providers project' ;;
|
||||
docs/*|README.md|*.md) echo static ;;
|
||||
.gitea/*|.shellcheckrc) echo static ;;
|
||||
.dejima/*) echo 'static config project providers' ;;
|
||||
|
||||
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
|
||||
test/*) echo static ;;
|
||||
|
||||
*) echo ALL ;;
|
||||
esac
|
||||
}
|
||||
|
||||
lane=all
|
||||
jobs=""
|
||||
base=""
|
||||
list_only=0
|
||||
given_paths=()
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--changed) lane=changed ;;
|
||||
--base) shift; base="${1:-}" ;;
|
||||
--list) list_only=1 ;;
|
||||
-j|--jobs) shift; jobs="${1:-}" ;;
|
||||
-h|--help)
|
||||
sed -n '2,5p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
printf ' --list print the selection and stop\n'
|
||||
printf ' --jobs N tests to run at once within each suite\n'
|
||||
printf ' --base REF what --changed diffs against\n'
|
||||
exit 0 ;;
|
||||
-*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
# A path given here stands in for one git would have reported, so the
|
||||
# map can be asked what it does with a path without the answer
|
||||
# depending on what happens to be uncommitted.
|
||||
*) given_paths+=("$1") ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ "$lane" != changed ] && [ "${#given_paths[@]}" -gt 0 ]; then
|
||||
echo "paths only mean something with --changed: ${given_paths[*]}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
all_suites=()
|
||||
for f in test/*.bats; do
|
||||
[ -f "$f" ] || continue
|
||||
name="$(basename "$f" .bats)"
|
||||
case "$PRIVILEGED" in *" $name "*) continue ;; esac
|
||||
suites+=("$name")
|
||||
all_suites+=("$name")
|
||||
done
|
||||
[ "${#suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
|
||||
[ "${#all_suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
|
||||
|
||||
# Within-file parallelism is bats' own, and it needs flock. Without it bats
|
||||
# exits before running anything, so the fallback is stated rather than left to
|
||||
# look like a broken suite.
|
||||
if [ -z "$jobs" ]; then
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
jobs="$(nproc 2>/dev/null || echo 4)"
|
||||
else
|
||||
jobs=1
|
||||
fi
|
||||
fi
|
||||
bats_flags=()
|
||||
# Deliberately more concurrent tests than cores when the whole set runs: these
|
||||
# tests spend their time forking rather than computing, so oversubscribing
|
||||
# measured faster and levelled off rather than degrading.
|
||||
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
|
||||
|
||||
selected=()
|
||||
changed_paths=()
|
||||
if [ "$lane" = changed ]; then
|
||||
if [ "${#given_paths[@]}" -gt 0 ]; then
|
||||
changed_paths=("${given_paths[@]}")
|
||||
else
|
||||
if [ -z "$base" ]; then
|
||||
for candidate in origin/development development origin/main main; do
|
||||
if git rev-parse --verify --quiet "$candidate" >/dev/null; then
|
||||
base="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# Committed work plus what is still in the working tree, including
|
||||
# files git has never seen: iteration happens before a commit, and a
|
||||
# lane that only saw committed changes would report on the wrong thing.
|
||||
mapfile -t changed_paths < <(
|
||||
{
|
||||
[ -z "$base" ] || git diff --name-only \
|
||||
"$(git merge-base "$base" HEAD 2>/dev/null || echo "$base")" 2>/dev/null
|
||||
git diff --name-only HEAD 2>/dev/null
|
||||
git ls-files --others --exclude-standard 2>/dev/null
|
||||
} | sort -u
|
||||
)
|
||||
fi
|
||||
|
||||
wanted=" "
|
||||
for p in "${changed_paths[@]}"; do
|
||||
[ -n "$p" ] || continue
|
||||
for s in $(suites_for "$p"); do
|
||||
[ "$s" != ALL ] || { wanted=ALL; break 2; }
|
||||
case "$wanted" in *" $s "*) ;; *) wanted="$wanted$s " ;; esac
|
||||
done
|
||||
done
|
||||
if [ "$wanted" = ALL ]; then
|
||||
selected=("${all_suites[@]}")
|
||||
else
|
||||
for s in "${all_suites[@]}"; do
|
||||
case "$wanted" in *" $s "*) selected+=("$s") ;; esac
|
||||
done
|
||||
fi
|
||||
else
|
||||
selected=("${all_suites[@]}")
|
||||
fi
|
||||
|
||||
skipped=()
|
||||
for s in "${all_suites[@]}"; do
|
||||
for t in ${selected[@]+"${selected[@]}"}; do [ "$t" = "$s" ] && continue 2; done
|
||||
skipped+=("$s")
|
||||
done
|
||||
|
||||
# Which lane ran, before anything else, because the whole point of having two
|
||||
# is that a run can be read back later without guessing which one it was. To
|
||||
# stderr, so --list's stdout is the suite names and nothing else.
|
||||
{
|
||||
if [ "$lane" = changed ]; then
|
||||
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
|
||||
"${#selected[@]}" "${#all_suites[@]}" "${#changed_paths[@]}"
|
||||
[ "${#skipped[@]}" -eq 0 ] || printf ' not run: %s\n' "${skipped[*]}"
|
||||
printf ' An iteration check. NOT the pull-request gate - for that run\n'
|
||||
printf ' test/run-confined.sh with no arguments.\n'
|
||||
else
|
||||
printf 'lane: ALL - every confined suite (the pull-request gate)\n'
|
||||
printf ' not run: runtime, ci-runner - they need Docker and a real runner.\n'
|
||||
fi
|
||||
[ "$list_only" = 1 ] || printf ' %d test(s) at a time within each suite\n' "$jobs"
|
||||
printf '\n'
|
||||
} >&2
|
||||
|
||||
if [ "$list_only" = 1 ]; then
|
||||
[ "${#selected[@]}" -eq 0 ] || printf '%s\n' "${selected[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${#selected[@]}" -eq 0 ]; then
|
||||
echo "nothing to run: no changed path maps to a confined suite"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# One process per suite. There are fewer suites than cores on anything this
|
||||
# repo is developed on, and they are shell rather than CPU-bound, so a job
|
||||
# limit would cost wall clock for no benefit.
|
||||
out="$(mktemp -d)"
|
||||
trap 'rm -rf "$out"' EXIT
|
||||
for s in "${suites[@]}"; do
|
||||
( bats "test/$s.bats" > "$out/$s" 2>&1; printf '%s' "$?" > "$out/$s.status" ) &
|
||||
for s in "${selected[@]}"; do
|
||||
( bats ${bats_flags[@]+"${bats_flags[@]}"} "test/$s.bats" > "$out/$s" 2>&1
|
||||
printf '%s' "$?" > "$out/$s.status" ) &
|
||||
done
|
||||
wait
|
||||
|
||||
@@ -51,7 +228,7 @@ wait
|
||||
# that as "0 failures" is how a green board comes to mean nothing.
|
||||
failed=0
|
||||
printf '%-16s %6s %8s\n' suite ran failed
|
||||
for s in "${suites[@]}"; do
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
|
||||
@@ -69,7 +246,7 @@ done
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo
|
||||
for s in "${suites[@]}"; do
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
|
||||
@@ -77,7 +254,7 @@ if [ "$failed" -ne 0 ]; then
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
head -20 "$out/$s"
|
||||
else
|
||||
grep -E '^not ok' -A 6 "$out/$s"
|
||||
grep -E '^not ok' -A 20 "$out/$s"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
+73
-18
@@ -49,7 +49,10 @@ compose_cap_add() {
|
||||
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
|
||||
}
|
||||
|
||||
IMAGE=dejima:latest
|
||||
# localhost/ for the same reason bin/dejima uses it: a bare name is a Docker
|
||||
# Hub name to anything that pushes, and nothing built here is meant to leave
|
||||
# the host (#53).
|
||||
IMAGE=localhost/dejima:latest
|
||||
|
||||
# What a session actually runs: the base image plus the selected harness's own
|
||||
# layer, built in setup_file. Exported there because bats re-runs this file's
|
||||
@@ -75,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
|
||||
@@ -169,7 +195,7 @@ harness_manifest_value() {
|
||||
# teardown_file's sweep finds it. Prints the new tag.
|
||||
build_harness_layer() {
|
||||
local base="$1" tag df ctx
|
||||
tag="dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
|
||||
tag="localhost/dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
|
||||
df="$(mktemp)"
|
||||
# An empty build context, for the reason bin/dejima uses one: a build
|
||||
# whose context is a directory anything else can write is a way into the
|
||||
@@ -321,6 +347,37 @@ setup_file() {
|
||||
done < <(docker ps -a --filter name=^dejima-test- --filter status=exited \
|
||||
--filter status=created --format '{{.Names}}' 2>/dev/null)
|
||||
|
||||
# And the images this suite builds - harness, override, derived - which
|
||||
# nothing reaps across runs (#26). The per-test teardown removes only the
|
||||
# CURRENT run's suffix, so an abandoned run's images have no reaper at all.
|
||||
# They hold no subnet, so this is disk rather than the network's kind of
|
||||
# harm; it is also the largest thing here by disk, since each is built
|
||||
# FROM the session image.
|
||||
#
|
||||
# Same age guard as above, for the same reason: a concurrent run's images
|
||||
# are minutes old. The prefix is checked again on the name rather than
|
||||
# trusted to the CLI filter, so the reap can only ever reach this suite's
|
||||
# own namespace.
|
||||
local _img _born _suffix
|
||||
_suffix="$(basename "$BATS_RUN_TMPDIR")"
|
||||
while IFS= read -r _img; do
|
||||
[ -n "$_img" ] || continue
|
||||
# Both spellings, because the images this suite builds were renamed to
|
||||
# localhost/ (#53) after this reap was written against the bare form,
|
||||
# and for two releases it therefore matched nothing. A reap whose
|
||||
# pattern stops matching is not a reap that fails - it is one that
|
||||
# silently does nothing, which is indistinguishable from having
|
||||
# nothing to do, so nothing anywhere reported it.
|
||||
#
|
||||
# Matching both is also what stops the next rename doing it again.
|
||||
case "$_img" in dejima-test-*|localhost/dejima-test-*) ;; *) continue ;; esac
|
||||
case "$_img" in *":$_suffix") continue ;; esac
|
||||
_born="$(docker image inspect -f '{{.Created}}' "$_img" 2>/dev/null)" || continue
|
||||
_born="$(date -d "$_born" +%s 2>/dev/null)" || continue
|
||||
[ "$(( $(date +%s) - _born ))" -gt "$REAP_AFTER" ] || continue
|
||||
docker rmi -f "$_img" >/dev/null 2>&1 || true
|
||||
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null)
|
||||
|
||||
docker network create "$network" >/dev/null
|
||||
|
||||
# No DEJIMA_FORGE_HOSTS/LOKI_URL: this suite only asserts the default
|
||||
@@ -407,7 +464,7 @@ teardown() {
|
||||
docker rm -f "$c" >/dev/null 2>&1 || true
|
||||
done < <(docker ps -a --filter "name=^dejima-test-.*$suffix\$" \
|
||||
--format '{{.Names}}' 2>/dev/null)
|
||||
docker images -q "dejima-test-*:$suffix" 2>/dev/null \
|
||||
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
|
||||
| xargs -r docker rmi -f >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@@ -420,7 +477,7 @@ dexec_node() {
|
||||
}
|
||||
|
||||
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
|
||||
local test_image="dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
|
||||
local test_image="localhost/dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
|
||||
local test_container="$(test_container_name override)"
|
||||
local sandbox_dir="$BATS_TEST_DIRNAME/../docker"
|
||||
local repo_root="$BATS_TEST_DIRNAME/.."
|
||||
@@ -1256,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"
|
||||
@@ -1478,7 +1534,7 @@ EOF
|
||||
}
|
||||
|
||||
@test "a derived project image gets the package and keeps the base privilege posture" {
|
||||
local derived="dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
|
||||
local derived="localhost/dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
|
||||
local test_container="$(test_container_name derived)"
|
||||
local ctx; ctx="$(mktemp -d)"
|
||||
local store; store="$(mktemp -d)"
|
||||
@@ -1535,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 \
|
||||
@@ -1630,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"
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env bats
|
||||
# The per-project share directory: what a launch mounts at ~/shared, and the
|
||||
# host-side CLI that says where it is.
|
||||
#
|
||||
# `[core] share_dir` used to be mounted whole, so every session saw every other
|
||||
# session's handoff files, across projects. What is asserted here is that the
|
||||
# mount is now one project's subdirectory, that an operator can still find it,
|
||||
# and that the files left over from the old layout are neither hidden silently
|
||||
# nor moved without being asked about.
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
SLUG_LIB="$REPO_ROOT/bin/dejima-slug-lib.sh"
|
||||
HOST="$(mktemp -d)"
|
||||
CONFIG_DIR="$HOST/config"
|
||||
SHARE="$HOST/share"
|
||||
WS="$HOST/ws"
|
||||
mkdir -p "$CONFIG_DIR" "$SHARE" "$WS" "$HOST/home/.claude"
|
||||
echo '{}' > "$HOST/home/.claude/settings.json"
|
||||
KEY="$HOST/sandbox-key"
|
||||
: > "$KEY"
|
||||
chmod 600 "$KEY"
|
||||
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
||||
# A dejima session exports several of these into every shell it starts, so
|
||||
# a test that meant "unset" by not setting them would be reading the
|
||||
# invoking environment rather than stating its own precondition.
|
||||
unset SANDBOX_SHARE_DIR SANDBOX_CMD SANDBOX_EXTRA_MOUNTS LOKI_URL \
|
||||
ENABLE_FIREWALL ALLOWED_DOMAINS
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$HOST" ${STUB_DIR:+"$STUB_DIR"}
|
||||
}
|
||||
|
||||
write_config() {
|
||||
{
|
||||
printf '[core]\nharness = claude-code\nforge = none\nworkflow = local-only\n'
|
||||
printf 'update_check = off\nssh_key = %s\nshare_dir = %s\n' "$KEY" "$SHARE"
|
||||
[ "$#" -eq 0 ] || printf '%s\n' "$@"
|
||||
} > "$CONFIG_DIR/config"
|
||||
}
|
||||
|
||||
slug() { bash -c 'source "$1"; ws_slug "$2"' _ "$SLUG_LIB" "$1"; }
|
||||
|
||||
share() { "$REPO_ROOT/bin/dejima-share" "$@"; }
|
||||
|
||||
# Drives the real launch with a stubbed docker, and records what
|
||||
# SANDBOX_SHARE_DIR was when compose was invoked. Reading --print-config
|
||||
# instead would pass against a version that resolved the path correctly and
|
||||
# then mounted something else.
|
||||
launch() {
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "${SANDBOX_SHARE_DIR-}" >> "$SANDBOX_TEST_SHARE_LOG"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
SHARE_LOG="$HOST/share-log"
|
||||
: > "$SHARE_LOG"
|
||||
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_SHARE_LOG="$SHARE_LOG" \
|
||||
run "$REPO_ROOT/bin/dejima" "${1:-$WS}"
|
||||
}
|
||||
|
||||
# What the launch actually handed compose, deduplicated - compose is invoked
|
||||
# more than once per launch and every call must agree.
|
||||
mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
|
||||
|
||||
@test "a launch mounts one project's subdirectory, not share_dir itself" {
|
||||
write_config
|
||||
launch
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(mounted_share)" = "$SHARE/projects/$(slug "$WS")" ] \
|
||||
|| { echo "mounted '$(mounted_share)'"; return 1; }
|
||||
[ -d "$SHARE/projects/$(slug "$WS")" ] \
|
||||
|| { echo "the launch did not create the directory it mounts"; return 1; }
|
||||
}
|
||||
|
||||
@test "two projects get two directories" {
|
||||
local other="$HOST/other"
|
||||
mkdir -p "$other"
|
||||
write_config
|
||||
launch "$WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local first="$(mounted_share)"
|
||||
launch "$other"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$first" != "$(mounted_share)" ] \
|
||||
|| { echo "both projects mounted $first"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-share path prints the directory a launch mounts, and creates it" {
|
||||
write_config
|
||||
run share path "$WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -d "$output" ] || { echo "path did not create $output"; return 1; }
|
||||
local printed="$output"
|
||||
launch
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
# The point of the shared layout helpers: an operator dropping a file where
|
||||
# `path` says would otherwise be dropping it where nothing reads it.
|
||||
[ "$printed" = "$(mounted_share)" ] \
|
||||
|| { echo "path said $printed, the launch mounted $(mounted_share)"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-share path defaults to the current directory" {
|
||||
write_config
|
||||
cd "$WS"
|
||||
run share path
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$output" = "$SHARE/projects/$(slug "$WS")" ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-share path refuses a directory that does not exist" {
|
||||
# It would otherwise answer a typo with a share directory nothing will
|
||||
# ever mount, which is worse than refusing: the file goes somewhere real
|
||||
# and no session sees it.
|
||||
write_config
|
||||
run share path "$HOST/no-such-project"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"no such directory"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a launch says which files the split left behind, every time until they are gone" {
|
||||
write_config
|
||||
echo secret > "$SHARE/left-over.txt"
|
||||
launch
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"left-over.txt"* ]] \
|
||||
|| { echo "the launch hid a file it stopped mounting: $output"; return 1; }
|
||||
[[ "$output" == *"dejima-share adopt"* ]] \
|
||||
|| { echo "it does not say what to do about it: $output"; return 1; }
|
||||
# Said again rather than once: a file handed to a session yesterday and
|
||||
# silently not arriving today is the failure this exists against.
|
||||
launch
|
||||
[[ "$output" == *"left-over.txt"* ]] || { echo "$output"; return 1; }
|
||||
rm "$SHARE/left-over.txt"
|
||||
launch
|
||||
[[ "$output" != *"no session can see"* ]] \
|
||||
|| { echo "still warning with nothing left over: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the per-project root is not itself reported as left behind" {
|
||||
write_config
|
||||
launch
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"no session can see"* ]] \
|
||||
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-share list names each project and flags what is loose" {
|
||||
write_config
|
||||
share path "$WS" > /dev/null
|
||||
echo x > "$SHARE/loose-file"
|
||||
run share list
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"$(slug "$WS")"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"loose-file"* ]] || { echo "$output"; return 1; }
|
||||
# The retention story is the only one there is: nothing prunes this.
|
||||
[[ "$output" == *"Nothing prunes this"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "adopt moves nothing without a yes" {
|
||||
write_config
|
||||
echo keep > "$SHARE/left-over.txt"
|
||||
run bash -c 'printf "n\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -f "$SHARE/left-over.txt" ] || { echo "declining still moved it"; return 1; }
|
||||
# And an empty answer is a no, not a prompt loop or a yes.
|
||||
run bash -c 'printf "\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
|
||||
[ -f "$SHARE/left-over.txt" ] || { echo "an empty answer moved it"; return 1; }
|
||||
}
|
||||
|
||||
@test "adopt moves what is loose into one project's share" {
|
||||
write_config
|
||||
echo one > "$SHARE/a.txt"
|
||||
mkdir -p "$SHARE/adir"
|
||||
echo two > "$SHARE/adir/b.txt"
|
||||
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local dest="$SHARE/projects/$(slug "$WS")"
|
||||
[ -f "$dest/a.txt" ] || { echo "a.txt did not arrive"; return 1; }
|
||||
[ -f "$dest/adir/b.txt" ] || { echo "the directory did not arrive whole"; return 1; }
|
||||
[ ! -e "$SHARE/a.txt" ] || { echo "a.txt is still loose"; return 1; }
|
||||
[ "$(cat "$dest/a.txt")" = one ] || { echo "contents changed"; return 1; }
|
||||
}
|
||||
|
||||
@test "adopt never overwrites" {
|
||||
# Both files are the operator's and this cannot know which one they meant,
|
||||
# so the loose one stays loose and says so.
|
||||
write_config
|
||||
local dest="$SHARE/projects/$(slug "$WS")"
|
||||
mkdir -p "$dest"
|
||||
echo kept > "$dest/a.txt"
|
||||
echo incoming > "$SHARE/a.txt"
|
||||
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(cat "$dest/a.txt")" = kept ] || { echo "it overwrote the destination"; return 1; }
|
||||
[ -f "$SHARE/a.txt" ] || { echo "it removed the source it refused to move"; return 1; }
|
||||
[[ "$output" == *"already has one"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "adopt says so when there is nothing loose" {
|
||||
write_config
|
||||
share path "$WS" > /dev/null
|
||||
run share adopt "$WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"Nothing loose"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a writable mount naming the share root is refused" {
|
||||
# The ~/shared mount is per project only while nothing else hands the whole
|
||||
# of it over. A session's own share arrives without going through
|
||||
# [mounts] read_write at all, so refusing costs nothing here.
|
||||
write_config '[mounts]' "read_write = $SHARE/projects"
|
||||
mkdir -p "$SHARE/projects"
|
||||
launch
|
||||
[ "$status" -ne 0 ] || { echo "mounted every project's handoff files"; return 1; }
|
||||
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"handoff"* ]] || { echo "the refusal does not say why: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a read-only mount naming the share root is refused too" {
|
||||
write_config
|
||||
mkdir -p "$SHARE/projects"
|
||||
launch_ro() {
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$STUB_DIR/docker"
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" --extra-readonly "$SHARE/projects" "$WS"
|
||||
}
|
||||
launch_ro
|
||||
[ "$status" -ne 0 ] || { echo "read-only was enough to hand it over"; return 1; }
|
||||
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the briefing tells the session the share directory is this project's" {
|
||||
# The agent is told to leave things in ~/shared and to pick things up
|
||||
# there. The path inside the container did not change, so what has to
|
||||
# change is what it is told about who else can read it.
|
||||
local out flat
|
||||
out="$(bash -c 'source "$1"; briefing_compose none local-only claude-code' _ "$LIB")" \
|
||||
|| { echo "$out"; return 1; }
|
||||
[[ "$out" == *"~/shared"* ]] || { echo "the briefing lost ~/shared"; return 1; }
|
||||
# Flattened, because the sentence wraps: a phrase spanning a line break
|
||||
# matches nothing and the test fails on a reflow that changed no words.
|
||||
flat="$(tr '\n' ' ' <<< "$out" | tr -s ' ')"
|
||||
[[ "$flat" == *"this project's own"* ]] \
|
||||
|| { echo "the briefing still reads as one directory for the host"; return 1; }
|
||||
[[ "$flat" == *"every session against this project shares it"* ]] \
|
||||
|| { echo "the briefing does not say who else can read it"; return 1; }
|
||||
}
|
||||
+968
-53
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user