Compare commits
236
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ffe8564b | ||
|
|
cf8b3ee20f | ||
|
|
91befe91f1 | ||
|
|
d8b687f4e4 | ||
|
|
ab74531b63 | ||
|
|
06018fdda4 | ||
|
|
0e4717d7ed | ||
|
|
7da90f51e1 | ||
|
|
117622e144 | ||
|
|
a26c10e8c6 | ||
|
|
65caf4577b | ||
|
|
21eeaa5bf0 | ||
|
|
d279c93e13 | ||
|
|
439b9b2d8b | ||
|
|
6dcefc127d | ||
|
|
aff7f511e8 | ||
|
|
02159d95e2 | ||
|
|
51dc5ddf5c | ||
|
|
44ac1e6216 | ||
|
|
fc34c165df | ||
|
|
5f13b4015b | ||
|
|
f99868fb7c | ||
|
|
f5f228f7ae | ||
|
|
3eacfd26e8 | ||
|
|
1a4ba0d192 | ||
|
|
b12b303780 | ||
|
|
79853138fb | ||
|
|
affb2ac903 | ||
|
|
dfdc720434 | ||
|
|
34f8fb85c9 | ||
|
|
4395887fa9 | ||
|
|
44a74ce187 | ||
|
|
f6e2480d37 | ||
|
|
ab362e8d75 | ||
|
|
a808447747 | ||
|
|
c6afe46220 | ||
|
|
11b1d7f726 | ||
|
|
4289bb59d8 | ||
|
|
e8a80ed3e8 | ||
|
|
361a7413fe | ||
|
|
9586a464ea | ||
|
|
3bdc5a664b | ||
|
|
f86ceb9441 | ||
|
|
8b7b9cfff0 | ||
|
|
6c5f652375 | ||
|
|
ae91766acc | ||
|
|
cfc1b01b1f | ||
|
|
078b0d814b | ||
|
|
d8924693d5 | ||
|
|
bd590edfea | ||
|
|
1436bb2614 | ||
|
|
f679e5d59f | ||
|
|
d41483e073 | ||
|
|
6bf63c8325 | ||
|
|
0936ac80ac | ||
|
|
f35556ee0b | ||
|
|
5f595de0ab | ||
|
|
31e7b869c3 | ||
|
|
9acc280e03 | ||
|
|
55046cf863 | ||
|
|
a88c994954 | ||
|
|
0ffdfa6da2 | ||
|
|
2bee0e8b70 | ||
|
|
a822e19fd0 | ||
|
|
449ba3023d | ||
|
|
3cf12910f3 | ||
|
|
54f0dac269 | ||
|
|
075fcd1def | ||
|
|
d111bf0aed | ||
|
|
6aacfbd8dd | ||
|
|
53a448bc43 | ||
|
|
6bf9a1b60f | ||
|
|
cbdac96133 | ||
|
|
0eb418266c | ||
|
|
a53936b10c | ||
|
|
505c6f0623 | ||
|
|
7776aa3298 | ||
|
|
2a9606d873 | ||
|
|
885423a0a8 | ||
|
|
bec542ca48 | ||
|
|
402fac5d5d | ||
|
|
2d462ea53c | ||
|
|
8bea036701 | ||
|
|
8b9c94bfc5 | ||
|
|
43ac721355 | ||
|
|
60d0b8ec97 | ||
|
|
f0513208a1 | ||
|
|
a6e1de5f30 | ||
|
|
60d6736e37 | ||
|
|
b36e7c935f | ||
|
|
ebf3b1dfe1 | ||
|
|
84ed923f85 | ||
|
|
770e8778be | ||
|
|
ba3615ec4b | ||
|
|
83f9fcfe7f | ||
|
|
a687d1793b | ||
|
|
537ab56b04 | ||
|
|
a72ea3dcda | ||
|
|
006a2319e7 | ||
|
|
c9064053ab | ||
|
|
57317e14ec | ||
|
|
dd9fc844df | ||
|
|
b41097c253 | ||
|
|
f9f7a3f60f | ||
|
|
af6913ebbe | ||
|
|
e2a7dcd4dd | ||
|
|
a6585dd050 | ||
|
|
b142c31b22 | ||
|
|
c5b3c5d222 | ||
|
|
3d2e3d2699 | ||
|
|
2d8c86ea44 | ||
|
|
503addfc8b | ||
|
|
a279460318 | ||
|
|
76a5c5202e | ||
|
|
60a319b3ba | ||
|
|
c0bc131fd4 | ||
|
|
00a2df3b0c | ||
|
|
e7769bb3d6 | ||
|
|
e346ad3ae2 | ||
|
|
3f5dae88b5 | ||
|
|
5569e8d2fe | ||
|
|
e900c51e20 | ||
|
|
e91b5771f6 | ||
|
|
3b17604178 | ||
|
|
6511a0537c | ||
|
|
023efb3171 | ||
|
|
23462a3c71 | ||
|
|
2ddcf964c3 | ||
|
|
faeafa7f2d | ||
|
|
2cc6057c88 | ||
|
|
1d8725f089 | ||
|
|
aa40e0c455 | ||
|
|
d180581293 | ||
|
|
f61795381d | ||
|
|
805d9a2c35 | ||
|
|
5884930592 | ||
|
|
452d49394f | ||
|
|
1cda465898 | ||
|
|
cacb74fb28 | ||
|
|
4a9e789ba1 | ||
|
|
9b96d5781b | ||
|
|
bac3d45814 | ||
|
|
fa29e2ac00 | ||
|
|
b3f8252c73 | ||
|
|
720b91a569 | ||
|
|
3086bb884e | ||
|
|
02f5dd8b19 | ||
|
|
990ab94a40 | ||
|
|
940f632447 | ||
|
|
c8fd2dc605 | ||
|
|
ba2158e0e8 | ||
|
|
51c0a8bc65 | ||
|
|
d418de83df | ||
|
|
225abccece | ||
|
|
43081852ea | ||
|
|
4adfe9b36c | ||
|
|
12a222cace | ||
|
|
af0fce3326 | ||
|
|
2217d6d3c3 | ||
|
|
300097dcae | ||
|
|
30a2a5c288 | ||
|
|
ab67534887 | ||
|
|
bb4f06657f | ||
|
|
6d77293880 | ||
|
|
c7521c7b6d | ||
|
|
3474c96f95 | ||
|
|
4c81605df6 | ||
|
|
07d863d6dc | ||
|
|
2d3a8ef32a | ||
|
|
f0cf3b98f7 | ||
|
|
513187cef3 | ||
|
|
4caf9d8d76 | ||
|
|
bad60b2cbe | ||
|
|
b906f33ab5 | ||
|
|
2f921a0d6a | ||
|
|
79e18b916a | ||
|
|
ba2fb5b434 | ||
|
|
03b96b2da8 | ||
|
|
f8726b2ea9 | ||
|
|
1d91d07d5c | ||
|
|
172988a67c | ||
|
|
a7d7ec9dda | ||
|
|
6d6e40d243 | ||
|
|
eb5b34f3be | ||
|
|
9329c722cd | ||
|
|
978480c73d | ||
|
|
4f0fbff39c | ||
|
|
34ae5c4044 | ||
|
|
c400ff3ad0 | ||
|
|
57c76aa573 | ||
|
|
d188eb20be | ||
|
|
88779bd656 | ||
|
|
7535f4795b | ||
|
|
bd01875c7f | ||
|
|
d8263120e3 | ||
|
|
c302aaae96 | ||
|
|
a899d60d59 | ||
|
|
4425d83d88 | ||
|
|
c73fb4702b | ||
|
|
896773bfc5 | ||
|
|
8b579fdd59 | ||
|
|
f44fe47657 | ||
|
|
378ad85d8e | ||
|
|
9c1735de00 | ||
|
|
6522ad56e0 | ||
|
|
b91b8f5fb3 | ||
|
|
e9e1c2bc52 | ||
|
|
cb793edead | ||
|
|
832c4a4f6d | ||
|
|
0daae02833 | ||
|
|
a88393f769 | ||
|
|
b8c34bb98c | ||
|
|
df7a74481c | ||
|
|
337b2bce04 | ||
|
|
d8845206c4 | ||
|
|
c0935a52c3 | ||
|
|
2184bc76b2 | ||
|
|
393853a024 | ||
|
|
09d1881633 | ||
|
|
26ee847e35 | ||
|
|
c805208fac | ||
|
|
ebe63cfc30 | ||
|
|
9764ddd4fc | ||
|
|
5ee405fd6e | ||
|
|
39b7c9888d | ||
|
|
16025633a1 | ||
|
|
1005257774 | ||
|
|
110925c44a | ||
|
|
c7797d05ac | ||
|
|
e04131064c | ||
|
|
c57c6ba778 | ||
|
|
1e6711d1b2 | ||
|
|
8d61332742 | ||
|
|
d356191859 | ||
|
|
86e1a5be71 | ||
|
|
fce3dbc2ac |
+71
-38
@@ -89,20 +89,13 @@ jobs:
|
||||
- providers
|
||||
- conversation
|
||||
- share
|
||||
- mirror
|
||||
name: confined (${{ matrix.suite }})
|
||||
container:
|
||||
# Debian bookworm plus a node binary - node is what actions/checkout
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -114,7 +107,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -147,7 +141,11 @@ jobs:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
|
||||
# iproute2 for `ss`: the one test asserting dejima-ttyd refuses to
|
||||
# start when something already holds its port skips without it, and a
|
||||
# permanent skip on the check guarding a reverse-proxy port takeover
|
||||
# is the same as not having it.
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates iproute2
|
||||
# The code under test, named explicitly - see the header. Without `ref`
|
||||
# this checks out the base branch and the suite passes without ever
|
||||
# seeing the change.
|
||||
@@ -174,7 +172,11 @@ jobs:
|
||||
# otherwise refuses --jobs outright when GNU parallel is absent.
|
||||
- run: |
|
||||
chown -R node:node .
|
||||
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
|
||||
# DEJIMA_TEST_CONCURRENCY is what this job asks the machine for, which
|
||||
# the fixtures that wait on a bounded deadline multiply their bounds
|
||||
# by - see test/fixtures/loadscale.py. One suite per job here, so it
|
||||
# is the --jobs value and nothing more.
|
||||
su node -c "TERM=xterm-256color DEJIMA_TEST_CONCURRENCY=$(nproc) 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,
|
||||
@@ -251,14 +253,6 @@ jobs:
|
||||
# nothing to do with what the test asserts. That test is the containment
|
||||
# argument for the ttyd front door, so it is not one to skip in CI.
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -270,7 +264,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -442,6 +437,58 @@ jobs:
|
||||
fi
|
||||
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
|
||||
|
||||
# One fetch for the whole run, into the directory the job already shares
|
||||
# with the containers it starts. test/runtime.bats starts twenty-seven
|
||||
# container pairs and each filter fetched the ranges for itself, which is
|
||||
# roughly twenty-seven requests in five minutes - comfortably inside the
|
||||
# 5000/hour a token allows, and exactly the burst GitHub's SECONDARY limit
|
||||
# answers with a 403 (#357). One request cannot be a burst.
|
||||
#
|
||||
# Best-effort: a failure here leaves no file, and runtime.bats falls back
|
||||
# to each container fetching for itself. That is the behaviour this
|
||||
# replaces, so the worst case is the status quo rather than a red job -
|
||||
# and the step says which of the two the run is getting.
|
||||
- name: Fetch the GitHub ranges once for the whole run
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
|
||||
run: |
|
||||
# POSIX sh, like every other run: block here - the runner's default
|
||||
# shell is `sh -e`, where an array is a syntax error and the step dies
|
||||
# before any of this runs. Two curl branches rather than one with a
|
||||
# built-up argument list, for the same reason.
|
||||
mkdir -p /tmp/dejima-ci
|
||||
out=/tmp/dejima-ci/github-meta.json
|
||||
rm -f "$out" "$out.part"
|
||||
fetched=0
|
||||
if [ -n "${GH_TOKEN:-}" ]; then
|
||||
if curl -fsS --max-time 30 -H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
|
||||
fetched=1
|
||||
fi
|
||||
else
|
||||
if curl -fsS --max-time 30 \
|
||||
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
|
||||
fetched=1
|
||||
fi
|
||||
fi
|
||||
# Written only once it parses AND carries ranges, because a rate-limit
|
||||
# body is a 200 with a message and no ranges - and a cached one would
|
||||
# then fail every container in the run instead of one.
|
||||
ranges=""
|
||||
if [ "$fetched" = 1 ]; then
|
||||
ranges="$(jq -r '[.git[]?, .api[]?, .web[]?] | unique | .[]' < "$out.part" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "$ranges" ]; then
|
||||
mv "$out.part" "$out"
|
||||
echo "Cached GitHub ranges for this run: $(printf '%s\n' "$ranges" | wc -l) CIDRs."
|
||||
else
|
||||
rm -f "$out.part"
|
||||
echo "Could not cache the ranges, so each container will fetch its own."
|
||||
echo "That is the pre-#357 behaviour, not a new failure - but it is what"
|
||||
echo "trips the secondary limit, so a 403 in a container boot below is"
|
||||
echo "explained by this line."
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -536,14 +583,6 @@ jobs:
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -555,7 +594,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -607,14 +647,6 @@ jobs:
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -626,7 +658,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# Changelog
|
||||
|
||||
[← dejima README](README.md)
|
||||
|
||||
What each release changed, newest first, for one question: **should I update,
|
||||
and what will I be getting.** `dejima --version` says which release the
|
||||
checkout you are running is on.
|
||||
|
||||
A release is a date - `2026.10.14` - and the git tag of that name on `main` is
|
||||
where it lives. A date rather than a version number because the useful fact
|
||||
about a release is how old it is, and because `MAJOR.MINOR` would be a promise
|
||||
about compatibility and patch lines that this project does not make.
|
||||
|
||||
**No release here is supported.** The tip of `main` is the only supported
|
||||
version, and that is not a formality: there are no maintenance branches, no
|
||||
backports and no security releases for older tags. A tag is a record of what
|
||||
shipped so you can tell what you are missing - not a version to stay on. See
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
Entries name what an operator would notice. The full detail is in the pull
|
||||
requests, which are linked.
|
||||
|
||||
## Unreleased
|
||||
|
||||
The release pull request that merges `development` into `main` replaces this
|
||||
heading with the date it merges, and tags `main` with the same date.
|
||||
|
||||
### New
|
||||
|
||||
- **Every session has a name**, in both front doors' listings and as the tmux
|
||||
window name, so `choose-window` and the status line can tell a dozen
|
||||
sessions of one project apart. It is the project directory by default and
|
||||
`dejima --name <text>` when you choose one; a name already in use takes the
|
||||
first free `-N` rather than refusing the launch.
|
||||
- **The session list says which sessions want you.** `waiting` and `working`
|
||||
where every row used to say `attachable`, from the session's own report
|
||||
rather than from the notification markers, so it works with notifications
|
||||
switched off.
|
||||
- **The picker is three columns** - name, state, age - where it was five. On a
|
||||
phone the old row wrapped and put half of each session under the next
|
||||
session's number. The tmux label and the container id stay in `--list`.
|
||||
- **`dejima-attach --watch`**, the picker redrawn on a timer: one window for
|
||||
which sessions are working and which are waiting.
|
||||
- **`dejima-attach --count-waiting`** for a tmux status line - the same
|
||||
question as one number, and the one path that asks Docker nothing.
|
||||
**`--counts`** answers for every state in the same pass, so two numbers on a
|
||||
status bar cost one invocation rather than two, and **`--state-dir`** says
|
||||
where the registry and the reported states are for a reader that is not
|
||||
dejima.
|
||||
- **Inside tmux the picker switches your client** to the session's own window
|
||||
instead of attaching. tmux refuses to nest, so on your own machine the
|
||||
picker could list every session and reach none of them.
|
||||
- **The web front door's SESS button no longer reloads the page.** It opens a
|
||||
new WebSocket instead, which is all ttyd needs to run a different command,
|
||||
so the half a megabyte of inlined terminal is not fetched and re-run to
|
||||
reach the picker. On a phone that was most of the wait.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The GitHub mirrors carry a rewritten history.** AI co-author trailers are
|
||||
removed from commit messages, so GitHub credits no tool as a contributor,
|
||||
and operator-specific values are replaced throughout. Commits keep their
|
||||
authors and dates, but a forge SHA no longer resolves on GitHub.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The session listing is five times faster on a host running a dozen
|
||||
sessions**, which is the wait between pressing SESS on the web front door
|
||||
and the picker drawing. It asked Docker once per session and tmux once per
|
||||
session, and forked a subshell per row for each of four lookups; it now asks
|
||||
Docker twice and tmux once for the whole listing, whatever that holds.
|
||||
Measured against an eighteen-session fixture: 2259ms before, 468ms after.
|
||||
- **The credential proxy refuses an oversized request body before inviting
|
||||
it.** It answered `100 Continue` and only then read the declared length, so
|
||||
whether a session saw the `413` or only the `100` was timing. The cap itself
|
||||
always held - nothing oversized was read into host memory - but the
|
||||
conversation was a race, and it surfaced as an intermittently red `proxy`
|
||||
job.
|
||||
([#400](https://gitbox.branderson.io/dejima/dejima/issues/400))
|
||||
|
||||
## 2026.10.14
|
||||
|
||||
Nothing a session does has changed. This release is about where dejima can be
|
||||
read.
|
||||
|
||||
### New
|
||||
|
||||
- **Read-only mirrors on GitHub.** dejima, dejima-badge and the three sister
|
||||
projects are published to [github.com/dejima-org](https://github.com/dejima-org):
|
||||
`main` and its release tags, synced hourly. Development, issues and pull
|
||||
requests stay on the forge, and the mirrors accept neither. A clone of a
|
||||
mirror has GitHub links and clone URLs where the forge's copy has the forge's.
|
||||
([#406](https://gitbox.branderson.io/dejima/dejima/pulls/406))
|
||||
|
||||
### Rollback
|
||||
|
||||
This release adds no config key and changes nothing a session runs, so rolling
|
||||
back changes nothing an operator would notice.
|
||||
|
||||
## 2026.10.13
|
||||
|
||||
The first release with a version number. Everything before it is `unreleased`,
|
||||
and a checkout from before this tag reports itself that way.
|
||||
|
||||
144 commits, PRs #360 to #399.
|
||||
|
||||
### New
|
||||
|
||||
- **`dejima --isolated`** runs a session with nothing before it and nothing
|
||||
after it: no project mounted, no memory or transcript across either
|
||||
boundary, no forge credential unless the same command names a forge, and a
|
||||
`~/shared` made fresh for that session alone. For a disposable experiment,
|
||||
or anything that should not be able to read what an earlier session did.
|
||||
Supported on every shipped harness.
|
||||
([#390](https://gitbox.branderson.io/dejima/dejima/pulls/390),
|
||||
[docs](docs/configuration.md#isolated-sessions))
|
||||
- **`dejima-allow` takes up to twenty domains at once**, printed back one per
|
||||
line and confirmed before any is added. One domain is unchanged.
|
||||
([#383](https://gitbox.branderson.io/dejima/dejima/pulls/383))
|
||||
- **`dejima --version`**, a version on the startup banner, and an update
|
||||
notice that names the release you would be getting rather than only a commit
|
||||
count.
|
||||
|
||||
### Tightened
|
||||
|
||||
Each of these refuses or withholds something a previous release allowed.
|
||||
|
||||
- **The host entry is clamped to the ports it was told to open**, and the
|
||||
clamp is now matched *before* the wider rules. Configuring one host-resident
|
||||
service opened every port the host publishes, including sshd and the
|
||||
remote-access front door.
|
||||
([#371](https://gitbox.branderson.io/dejima/dejima/pulls/371),
|
||||
[#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
- **The staged `~/.claude.json` carries the launching project's entry and no
|
||||
other.** A session was handed the name of every project on the host, a
|
||||
sample of each one's filenames, and what each had cost.
|
||||
([#381](https://gitbox.branderson.io/dejima/dejima/pulls/381))
|
||||
- **The real model credential is mounted only when it is this backend's own
|
||||
credential and the proxy is off for it.** A backend needing no credential at
|
||||
all - a model you serve yourself - was handed the real subscription login.
|
||||
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
- **git's `--config-env` no longer walks past the push guard.** It sets a
|
||||
pushurl the way `-c` does, and in its separated spelling the guard read its
|
||||
value as the subcommand - so a push checked against neither the host
|
||||
allowlist nor the blocked branch.
|
||||
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
- **The push and merge guards are one policy, read out of argv**, closing
|
||||
several spellings that walked past them.
|
||||
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
|
||||
[#374](https://gitbox.branderson.io/dejima/dejima/pulls/374))
|
||||
- **Every store dejima keeps out of a session's reach is warned about** when
|
||||
it sits inside the mounted project, not three of the six.
|
||||
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
|
||||
### What an existing config stops being able to do
|
||||
|
||||
Read this part before updating. All of these fail closed, and the first fails
|
||||
quietly.
|
||||
|
||||
- **A host-resident Loki or self-served model configured by NAME needs
|
||||
`EGRESS_HOST_PORTS` set to its port.** The clamp is matched before the open
|
||||
class now, so an address permitted by name no longer reaches the host on
|
||||
every port. A literal address with an explicit port is handled for you. The
|
||||
model fails loudly - the session cannot reach it - but **the log shipper
|
||||
fails silently**: network logging simply stops. The policy's own summary
|
||||
line, `host <addr> on ports none`, is where that shows up.
|
||||
- **A configured `ssh_key` now withdraws your forwarded ssh-agent from the
|
||||
container entirely.** Choosing a dedicated key has to mean your personal
|
||||
identities are not in there, rather than merely not the default. If a
|
||||
session used the key for the forge and the agent for anything else - a
|
||||
second remote, a private submodule - the second half stops working.
|
||||
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
|
||||
- **An empty `ssh_key` file now counts as no key at all.** A pushing workflow
|
||||
with an empty key file and no agent is refused where it used to start and
|
||||
fail at the push. With an agent running it starts, and now says that the
|
||||
forwarded agent is still signing - which is what configuring a key is meant
|
||||
to prevent.
|
||||
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
|
||||
- **Under the github forge, `gh api` is held to this repository.** A call
|
||||
naming no endpoint under `repos/<this repo>/` is refused - `gh api user`,
|
||||
`rate_limit`, an org endpoint - as are graphql mutations and reading a body
|
||||
from a file (`-F x=@file`, `--input`). An agent that used `gh api` for
|
||||
anything outside the repository will notice.
|
||||
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
|
||||
[#373](https://gitbox.branderson.io/dejima/dejima/pulls/373))
|
||||
|
||||
### Known limitations
|
||||
|
||||
Not changes - things this release does not do, worth knowing before relying
|
||||
on the ones above.
|
||||
|
||||
- **The host clamp covers the bridge gateway only.** The host's other
|
||||
addresses - its `docker0` address when the run network is a different
|
||||
bridge, and its LAN address - are not in it, so a service configured at the
|
||||
host's LAN IP is still permitted on every port by the open class.
|
||||
([#401](https://gitbox.branderson.io/dejima/dejima/issues/401))
|
||||
- **The merge block reads subcommands, not API endpoints.** `gh api -X PUT
|
||||
repos/<owner>/<name>/pulls/<n>/merge` names this repository, so the scoping
|
||||
check passes and nothing else sees a merge in it.
|
||||
([#402](https://gitbox.branderson.io/dejima/dejima/issues/402))
|
||||
|
||||
### Fixed
|
||||
|
||||
- The egress policy's resolver checks had no name to check when the forge
|
||||
ranges came from a cache file rather than fetched, which is how CI supplies
|
||||
them - so every container in a CI run failed to come up. An ordinary
|
||||
launch, whose ranges URL always has a host, was unaffected.
|
||||
([#386](https://gitbox.branderson.io/dejima/dejima/pulls/386))
|
||||
- GitHub's `/meta` ranges are fetched once per CI run rather than once per
|
||||
container, and a rate limit is waited out instead of failing the run.
|
||||
([#378](https://gitbox.branderson.io/dejima/dejima/pulls/378))
|
||||
- Test-suite reliability: a self-check retry that could not see its own
|
||||
failure, deadlines that scale with what a run asked of the machine, and a
|
||||
teardown race that failed CI intermittently.
|
||||
([#379](https://gitbox.branderson.io/dejima/dejima/pulls/379),
|
||||
[#385](https://gitbox.branderson.io/dejima/dejima/pulls/385))
|
||||
- A harness's per-run configuration is seeded from a staging directory that
|
||||
goes away with the launch, rather than written onto the host where it will
|
||||
live. For cline that is what lets `--isolated` work at all; for an ordinary
|
||||
launch the end state is unchanged.
|
||||
([#393](https://gitbox.branderson.io/dejima/dejima/issues/393))
|
||||
|
||||
### Documentation
|
||||
|
||||
Two review passes over the whole tree corrected documents that stated the
|
||||
opposite of the code - including what Ctrl-Z does, what the sandbox tells a
|
||||
session about a hung connection, and what dejima gives you on its own, without
|
||||
any of the sister projects around it.
|
||||
([#370](https://gitbox.branderson.io/dejima/dejima/pulls/370),
|
||||
[#377](https://gitbox.branderson.io/dejima/dejima/pulls/377),
|
||||
[#391](https://gitbox.branderson.io/dejima/dejima/pulls/391))
|
||||
|
||||
### Rollback
|
||||
|
||||
This release adds no config key, so the previous `main` accepts any config
|
||||
this one accepts. Rolling back restores the behaviour the "stops being able
|
||||
to" section describes, including the host entry being reachable on every port.
|
||||
@@ -8,6 +8,9 @@ anonymous issues or pull requests. So there is currently no way for you to
|
||||
file either, and that is a property of where this lives rather than a comment
|
||||
on the contribution.
|
||||
|
||||
The repositories under [github.com/dejima-org](https://github.com/dejima-org)
|
||||
are read-only mirrors of `main`, with issues and pull requests turned off.
|
||||
|
||||
That will change: this project will provide a way to submit issues and pull
|
||||
requests. Until it does, if you are using dejima and need it to do something
|
||||
different, **fork it and make the change in your fork**. Nothing here depends
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
browser is set - so anything conditional shows dark ink on a dark page to
|
||||
a reader whose browser prefers light, and does it silently.
|
||||
The suffix names the mode a file is FOR, not the colour of its ink:
|
||||
-darkmode is the pale wordmark that belongs on a dark page. A mirror on a
|
||||
light-themed host swaps the filename below for dejima-wordmark-lightmode.png,
|
||||
which is committed alongside for exactly that. One line, on the mirror,
|
||||
where someone can see whether it worked.
|
||||
-darkmode is the pale wordmark that belongs on a dark page. GitHub does
|
||||
follow its own theme setting through <picture>, so the GitHub mirror
|
||||
replaces the tag below with one that falls back to
|
||||
dejima-wordmark-lightmode.png; see mirror/github-mirror.sh.
|
||||
Every here is spacing, and it has to be a character rather than CSS
|
||||
or a presentation attribute. Gitea's sanitizer strips `style` from the tag
|
||||
and from any wrapper, `hspace`/`vspace` survive sanitizing only to be
|
||||
@@ -151,6 +151,9 @@ what each one buys, what goes wrong without it, and where each one's boundary
|
||||
ends. None of them is required to run dejima; what they change is what the
|
||||
layer below the container costs, and whether anyone finds out.
|
||||
|
||||
**→ [Using dejima on its own](docs/threat-model.md#using-dejima-on-its-own)** -
|
||||
what holds without them, what does not, and what to do about it.
|
||||
|
||||
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
|
||||
the most common question about this design, and why the effort went into the
|
||||
layers below the container rather than into a harder one.
|
||||
@@ -160,8 +163,8 @@ layers below the container rather than into a harder one.
|
||||
This repo only holds the tooling; no project lives here. Point it at a project
|
||||
on disk and that project is bind-mounted into the container at runtime.
|
||||
|
||||
**Prerequisites:** Docker with the `docker compose` v2 plugin, and `python3`
|
||||
(stdlib only, no packages) for the host-side credential proxy.
|
||||
**Prerequisites:** Docker with the `docker compose` v2 plugin, `jq`, and
|
||||
`python3` (stdlib only, no packages) for the host-side credential proxy.
|
||||
|
||||
Only the first block below is required. The rest are independent, so take the
|
||||
ones you want.
|
||||
@@ -184,7 +187,9 @@ ones you want.
|
||||
config.
|
||||
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
|
||||
4. **Set `[core] harness`.** It has no default and a launch is refused without
|
||||
it, which is what stops a later release changing your selection for you.
|
||||
it, which is what stops a later release changing your selection for you. The
|
||||
starter config states `claude-code`, because a file with no default to fall
|
||||
back on has to name a choice; change that line for Cline.
|
||||
|
||||
That is enough to run `dejima`, and no push credential is needed to get this
|
||||
far. Everything else runs at its default, which means `[core] forge = none` and
|
||||
@@ -245,6 +250,7 @@ Applications, scopes `write:repository` and `write:issue`) into
|
||||
```sh
|
||||
dejima # sandbox the current directory
|
||||
dejima ~/repos/some-project
|
||||
dejima --isolated # no project at all - see below
|
||||
```
|
||||
|
||||
Point it at a project, ask for a change, and let the session work. What happens
|
||||
@@ -293,10 +299,30 @@ dejima --ro ~/other-project ~/myproject
|
||||
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
|
||||
both forms, and which host paths a mount is refused over and why.
|
||||
|
||||
**Ctrl-Z backgrounds all of dejima**, and `fg` brings back a working session.
|
||||
It makes no progress while suspended, though, so for one that should keep
|
||||
working while you are away, run dejima inside tmux and use
|
||||
[`dejima-attach`](docs/remote-access.md).
|
||||
### A session with no project
|
||||
|
||||
`dejima --isolated` is the other direction: nothing of your machine is
|
||||
mounted, no memory or transcript crosses either boundary, no forge credential
|
||||
unless the same command names a forge, and the one thing that outlives the
|
||||
session is a `~/shared` made fresh for it. Run it from anywhere - the
|
||||
directory you are in is not used, and naming one is refused rather than
|
||||
ignored, so a launch cannot look like it is working on a project it cannot
|
||||
see.
|
||||
|
||||
It narrows what a session can read of the host and what credential it carries.
|
||||
It is not a stronger sandbox: the firewall, the credential boundary and the
|
||||
capability drops are what they are for every other session.
|
||||
|
||||
**→ [docs/configuration.md](docs/configuration.md#isolated-sessions)** - the
|
||||
table of what differs, and what the mode gives up.
|
||||
|
||||
**Ctrl-Z backgrounds dejima's host side**, and `fg` brings back a working
|
||||
session. It gets your terminal back rather than pausing the agent - the
|
||||
container keeps running - so for a session you want to leave working and pick
|
||||
up elsewhere, run dejima inside tmux and use
|
||||
[`dejima-attach`](docs/remote-access.md). Every session names its tmux window
|
||||
after the project, or after `--name <text>`, with a `-2` on the second one -
|
||||
which is what tells a dozen parallel sessions of one project apart.
|
||||
→ [why that takes doing](docs/configuration.md#suspending-a-session)
|
||||
|
||||
## Sign in on the host first
|
||||
@@ -406,7 +432,8 @@ broken tool. IPv6 is refused outright rather than allowlisted: a sandbox has no
|
||||
IPv6 egress at all, and one table states that in the same transaction as
|
||||
everything else, so there is no moment where the family is partly filtered.
|
||||
|
||||
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
|
||||
The allowlist covers what the sandbox needs to function - DNS, the Docker host
|
||||
on the ports a host-resident service was configured on and no others,
|
||||
GitHub on port 443 for read access whichever forge is selected, whatever the
|
||||
selected forge needs, the harness's own update endpoints, and the selected
|
||||
backend's API endpoint when the credential proxy is off - plus whatever a
|
||||
@@ -503,7 +530,7 @@ at a fixed host path.
|
||||
|
||||
`.dejima.md` at a project's root is that project's agent-instruction file, the
|
||||
harness-agnostic equivalent of `CLAUDE.md`, loaded into the session's context
|
||||
with no authority over the sandbox itself. A `SessionStart` hook also briefs the
|
||||
with no authority over the sandbox itself. dejima also briefs the
|
||||
session on where it is running and on the instructions belonging to whichever
|
||||
workflow you selected, so a session under `local-only` is told something
|
||||
different from one under `worktree-pr`.
|
||||
@@ -562,9 +589,8 @@ After the container exits it diffs them and tells you what changed.
|
||||
|
||||
It is **advisory, not a block**, firing after the session has ended, so a
|
||||
finding is something to clean up before your next `git` command or session in
|
||||
that repo. Its diff is time-windowed: a finding says a path changed while the
|
||||
session was running, not that the session changed it. Findings are a time
|
||||
window, not a verdict on who caused them.
|
||||
that repo. Its findings are a time window, not a verdict on who caused them:
|
||||
one says a path changed while the session was running.
|
||||
|
||||
`dejima-audit show` indexes what is waiting and `dejima-audit ack` walks it, one
|
||||
finding at a time. `[audit] strict` refuses a headless launch while CRITICAL
|
||||
@@ -701,6 +727,7 @@ and how to run them.
|
||||
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
|
||||
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
|
||||
| [development.md](docs/development.md) | The file map, the test suites, running them |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What each release changed, for deciding whether to update |
|
||||
| [SECURITY.md](SECURITY.md) | What dejima claims, where to report a vulnerability, and what is promised in return |
|
||||
| [ATTRIBUTION.md](ATTRIBUTION.md) | Who holds the dejima marks and what may be done with them |
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ Whoever deploys this is responsible for the result.
|
||||
The tip of `main`, and nothing else. There are no maintenance branches, no
|
||||
backports and no security releases for older tags or commits.
|
||||
|
||||
Releases are tagged by date so you can tell what you are running -
|
||||
`dejima --version` - and [CHANGELOG.md](CHANGELOG.md) says what each one
|
||||
changed. A tag is a record of what shipped, not a version anyone may stay on.
|
||||
|
||||
## No warranty
|
||||
|
||||
The software is provided without warranty of any kind. See sections 15 and 16
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ page that renders dark, `-lightmode` for one that renders light. The suffix
|
||||
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
|
||||
pale one.
|
||||
|
||||
The README points at `-darkmode` unconditionally. A mirror on a light-themed
|
||||
host changes that one line; the comment above the tag says why it is not
|
||||
conditional.
|
||||
The README points at `-darkmode` unconditionally; the comment above the tag
|
||||
says why it is not conditional. The GitHub mirror rewrites that tag to pick
|
||||
between the two by the reader's theme.
|
||||
|
||||
**These are trademarks and are not covered by this repository's GPL licence.**
|
||||
Who holds them, and what may be done with them, is stated once for the whole
|
||||
|
||||
+946
-74
File diff suppressed because it is too large
Load Diff
+221
-103
@@ -6,11 +6,12 @@
|
||||
# actually needs, it should tell you the exact command to run here rather
|
||||
# than try to work around the firewall.
|
||||
#
|
||||
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
||||
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
|
||||
# dejima-allow crates.io # this running session only
|
||||
# dejima-allow --global crates.io # persist for every project
|
||||
# dejima-allow --project crates.io # persist for the project in $PWD
|
||||
# dejima-allow --project ~/myproj crates.io # persist for one named project
|
||||
# dejima-allow dl.example.com files.example.org # several, confirmed first
|
||||
#
|
||||
# --project and --global are approvals for future sessions and deliberately do
|
||||
# not touch a running container: a scope flag means "record this", not "patch
|
||||
@@ -25,32 +26,32 @@ case "${1:-}" in
|
||||
cat <<'DEJIMA_ALLOW_HELP'
|
||||
dejima-allow - let a sandbox reach a domain the egress firewall blocks
|
||||
|
||||
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
||||
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
|
||||
Up to twenty. More than one is confirmed first, defaulting to no, which
|
||||
needs a terminal - so a script does them one at a time.
|
||||
|
||||
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. 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.
|
||||
if exactly one is running. With several it offers them to
|
||||
pick from on a terminal, by project and by the number the
|
||||
dejima-attach picker gives each; anywhere else it lists
|
||||
them and stops, a prompt being 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. 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.
|
||||
--global every future session in every project on this host.
|
||||
|
||||
They combine. --project and --global deliberately do NOT touch a session that
|
||||
is already running: they are approvals for future ones. Pass --container as
|
||||
well to do both at once.
|
||||
They combine, and --project/--global deliberately do NOT touch a running
|
||||
session: they are approvals for future ones, so pass --container to do both.
|
||||
Prefer --project to --global - a domain one project needs is that project's,
|
||||
and a global grant widens every other project's firewall too.
|
||||
|
||||
Prefer --project to --global. A domain one project needs is that project's, and
|
||||
a global grant widens every other project's firewall too.
|
||||
|
||||
With --container, the domain is verified reachable afterwards and a warning is
|
||||
With --container, each domain is verified reachable afterwards and a warning is
|
||||
printed if it is not, so a typo or a blocked port does not look like success.
|
||||
One that fails stops there naming what landed; a scope flag has them all by
|
||||
then regardless.
|
||||
|
||||
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
|
||||
DEJIMA_ALLOW_HELP
|
||||
@@ -73,34 +74,138 @@ source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>..." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 1: the domain is the required trailing positional, which is what tells
|
||||
# parse_scope_args whether a token after --project is a path or the domain.
|
||||
# The most domains one command will take. A cap rather than no limit, because
|
||||
# the confirmation below only works if the operator reads the list, and a list
|
||||
# long enough to scroll is one nobody reads to the end of - which is where
|
||||
# something would be hidden. Twenty is enough for the real case (a toolchain
|
||||
# that fetches from several hosts) and short enough to take in at once.
|
||||
readonly DEJIMA_ALLOW_MAX=20
|
||||
|
||||
# 1: at least one trailing positional, which is what tells parse_scope_args
|
||||
# whether a token after --project is a path or a domain. It is a minimum, not
|
||||
# an exact count - several domains may follow.
|
||||
parse_scope_args 1 "$@" || usage
|
||||
[ "${#dj_positional[@]}" -eq 1 ] || usage
|
||||
domain="${dj_positional[0]}"
|
||||
[ "${#dj_positional[@]}" -ge 1 ] || usage
|
||||
domains=("${dj_positional[@]}")
|
||||
global="$dj_global"
|
||||
project=""
|
||||
if [ "$dj_project_set" -eq 1 ]; then
|
||||
# parse_scope_args consumes a token after --project as the path whenever
|
||||
# enough positionals follow it, which with several domains is any of them.
|
||||
# A path that is not a directory and does read as a domain was the first
|
||||
# domain: `dejima-allow --project a.com b.com` means both of those, in the
|
||||
# project in $PWD. A directory wins, so a project whose name happens to
|
||||
# look like a hostname still works.
|
||||
if [ -n "$dj_project" ] && [ ! -d "$dj_project" ] \
|
||||
&& [[ "$dj_project" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
domains=("$dj_project" "${domains[@]}")
|
||||
dj_project=""
|
||||
fi
|
||||
project="$(cd "${dj_project:-$PWD}" && pwd)"
|
||||
disclose_project_symlink "$project"
|
||||
fi
|
||||
container="$dj_container"
|
||||
|
||||
# Validated up front, not just passed as a positional arg below: this also
|
||||
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
|
||||
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
|
||||
# sandboxed agent can suggest this exact command to the user to copy-paste
|
||||
# (see sandbox-context.sh), so a prompt-injected agent handing back a
|
||||
# malicious "domain" is a real path here, not just a theoretical one.
|
||||
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
echo "Not a valid domain: $domain" >&2
|
||||
# Repeats dropped, order kept. A list with the same name twice asks the
|
||||
# operator to approve it twice and makes the count they are reading wrong.
|
||||
_seen=""
|
||||
_unique=()
|
||||
for _d in "${domains[@]}"; do
|
||||
case "$_seen" in *"|${_d,,}|"*) continue ;; esac
|
||||
_seen="$_seen|${_d,,}|"
|
||||
_unique+=("$_d")
|
||||
done
|
||||
domains=("${_unique[@]}")
|
||||
|
||||
# What to call this request where it is described as a whole, rather than once
|
||||
# per name. Anything outside the per-domain loops below has no $domain to use,
|
||||
# and under set -u reaching for one is fatal rather than merely wrong - which is
|
||||
# how the container picker's own prompt took this script down.
|
||||
if [ "${#domains[@]}" -eq 1 ]; then
|
||||
domain_label="${domains[0]}"
|
||||
else
|
||||
domain_label="${#domains[@]} domains"
|
||||
fi
|
||||
|
||||
if [ "${#domains[@]}" -gt "$DEJIMA_ALLOW_MAX" ]; then
|
||||
echo "That is ${#domains[@]} domains, and this takes at most $DEJIMA_ALLOW_MAX at once." >&2
|
||||
echo "" >&2
|
||||
echo "The limit is there because the confirmation below is only worth anything if" >&2
|
||||
echo "you read the whole list. Split it up, and read each part." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every domain validated BEFORE any of them is shown or recorded, and the whole
|
||||
# command refused if any fails. Two reasons beyond the obvious one.
|
||||
#
|
||||
# The list the operator confirms has to be the list that gets added: validating
|
||||
# as we went would ask them to approve names, then add a prefix of them and
|
||||
# fail. And the confirmation rests on the list being readable as printed - this
|
||||
# pattern allows letters, digits, hyphens and dots and nothing else, so no
|
||||
# entry can carry a carriage return or an escape sequence that rewrites the
|
||||
# lines above it on the way past.
|
||||
#
|
||||
# It also has to be a well-formed hostname to be a valid dnsmasq
|
||||
# server=/domain/... line (e.g. no embedded "/"), on top of ruling out shell
|
||||
# metacharacters. A sandboxed agent can suggest this exact command for the
|
||||
# operator to copy-paste (see sandbox-context.sh), so a prompt-injected agent
|
||||
# handing back a malicious "domain" is a real path here, not a theoretical one.
|
||||
#
|
||||
# All of them reported, not the first: an operator fixing a typo in a list of
|
||||
# ten should not have to run it ten times to find the second one.
|
||||
_bad=()
|
||||
for _d in "${domains[@]}"; do
|
||||
[[ "$_d" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]] || _bad+=("$_d")
|
||||
done
|
||||
if [ "${#_bad[@]}" -gt 0 ]; then
|
||||
if [ "${#_bad[@]}" -eq 1 ]; then
|
||||
echo "Not a valid domain: ${_bad[0]}" >&2
|
||||
else
|
||||
echo "Not valid domains:" >&2
|
||||
printf ' %s\n' "${_bad[@]}" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# One domain goes through as it always has. More than one is confirmed first,
|
||||
# because a list is where something unwanted can ride along unread - and the
|
||||
# agent that suggested the command is exactly what might have put it there.
|
||||
#
|
||||
# Default no, and the list on stdout one per line so nothing is off the right
|
||||
# edge of a narrow terminal.
|
||||
#
|
||||
# No terminal means no confirmation is possible, so it refuses rather than
|
||||
# assuming either answer. A script that wants several does them one at a time,
|
||||
# which is the behaviour this had before and still has for one.
|
||||
if [ "${#domains[@]}" -gt 1 ]; then
|
||||
echo "About to allow ${#domains[@]} domains:"
|
||||
printf ' %s\n' "${domains[@]}"
|
||||
echo ""
|
||||
_where=()
|
||||
[ -n "$project" ] && _where+=("every future session in $project")
|
||||
[ "$global" -eq 1 ] && _where+=("every future session in every project")
|
||||
[ -n "$container" ] && _where+=("the running session $container")
|
||||
[ "${#_where[@]}" -gt 0 ] || _where=("the running session")
|
||||
printf 'This grants them to: %s\n' "$(printf '%s; ' "${_where[@]}" | sed 's/; $//')"
|
||||
echo ""
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Several domains at once needs a terminal to confirm on, and this is not one." >&2
|
||||
echo "Run them one at a time, which needs no confirmation." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Add all %s? [y/N] ' "${#domains[@]}"
|
||||
read -r _reply || _reply=""
|
||||
case "$_reply" in
|
||||
y|Y|yes|YES|Yes) ;;
|
||||
*) echo "Nothing was added."; exit 1 ;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Recorded first, and unconditionally: --project/--global are the user's
|
||||
# approval decision for future sessions, not a claim about this one. Gating
|
||||
# either on a live container would break the documented workflow outright -
|
||||
@@ -111,6 +216,7 @@ fi
|
||||
# given together, and each persists to its own store.
|
||||
if [ -n "$project" ]; then
|
||||
store="$(ensure_project_store "$project")"
|
||||
for domain in "${domains[@]}"; do
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the approved domains for $project (applies to every future session in it)."
|
||||
# Printed only after the approval above, and guarded rather than left to
|
||||
@@ -124,11 +230,14 @@ if [ -n "$project" ]; then
|
||||
echo "The approval above IS recorded and applies to every future session in it;" >&2
|
||||
echo "only the project's own .dejima declaration could not be written." >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$global" -eq 1 ]; then
|
||||
store="$(ensure_global_store)"
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the global approved domains (applies to every future session)."
|
||||
for domain in "${domains[@]}"; do
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the global approved domains (applies to every future session)."
|
||||
done
|
||||
fi
|
||||
|
||||
# A scope flag means "record this approval", not "patch what is running now" -
|
||||
@@ -225,7 +334,7 @@ pick_container() {
|
||||
"$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"
|
||||
tui_pick "${#CT_IDS[@]}" "allow $domain_label in"
|
||||
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
|
||||
}
|
||||
|
||||
@@ -313,7 +422,7 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
echo "inside it - either half of the pair is accepted here. If the session has" >&2
|
||||
echo "already exited, record the domain for the next one instead:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --project $domain" >&2
|
||||
echo " dejima-allow --project ${domains[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
filter="$container"
|
||||
@@ -348,83 +457,92 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
# on the same name - with the failed launch as the operator's first hint
|
||||
# that the two commands had different ideas.
|
||||
#
|
||||
# $domain reaches the container as an argument rather than inside the
|
||||
# command string: it is validated above, and this keeps it that way if that
|
||||
# validation is ever loosened.
|
||||
# Each domain reaches the container as an argument rather than inside the
|
||||
# command string: they are validated above, and this keeps it that way if
|
||||
# that validation is ever loosened.
|
||||
#
|
||||
# Guarded on the file existing: config_get says so on stderr when it does
|
||||
# not, which is right for a launch and noise for this, where a host with no
|
||||
# configuration at all is an ordinary case and the answer is simply "not
|
||||
# listed". A config that exists and is malformed still reports itself.
|
||||
internal=0
|
||||
if [ -f "$(config_path)" ]; then
|
||||
# `|| [ -n "$_d" ]`, because config_get prints its value with no
|
||||
# trailing newline - so a single-entry list is one line read never
|
||||
# returns, and the loop body would not run at all.
|
||||
# Case-folded, like the class decision below and dejima_mark_internal:
|
||||
# a live add that misses the exemption the operator configured leaves
|
||||
# the name without it until the next launch, which then refuses to
|
||||
# start on the same mismatch.
|
||||
while IFS= read -r _d || [ -n "$_d" ]; do
|
||||
[ "${_d,,}" = "${domain,,}" ] && internal=1
|
||||
done < <(config_get core internal_domains | tr ':' '\n')
|
||||
fi
|
||||
|
||||
permit_args=(permit "$domain")
|
||||
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
|
||||
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
|
||||
|
||||
echo "Permitting $domain in $filter, this session's egress control..."
|
||||
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
|
||||
echo "" >&2
|
||||
echo "The permitted set was left as it was - the reason is above." >&2
|
||||
if [ "$internal" -eq 0 ]; then
|
||||
echo "" >&2
|
||||
echo "If $domain resolves inside your own network, say so in the config and" >&2
|
||||
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
|
||||
echo "not listed there, whether or not it is approved:" >&2
|
||||
echo "" >&2
|
||||
echo " [core]" >&2
|
||||
echo " internal_domains = $domain" >&2
|
||||
echo "" >&2
|
||||
echo "It still needs an approval to be reachable at all:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --global $domain" >&2
|
||||
# One domain at a time, because each is its own change to the running
|
||||
# policy: the class it belongs to, whether the operator declared it
|
||||
# internal, and whether it then answers. A failure stops the loop rather
|
||||
# than carrying on, so what did and did not land is the order printed
|
||||
# above - and the names after it are still recorded under any scope flag
|
||||
# given, which happened before this and is not undone by a live add
|
||||
# failing.
|
||||
for domain in "${domains[@]}"; do
|
||||
internal=0
|
||||
if [ -f "$(config_path)" ]; then
|
||||
# `|| [ -n "$_d" ]`, because config_get prints its value with no
|
||||
# trailing newline - so a single-entry list is one line read never
|
||||
# returns, and the loop body would not run at all.
|
||||
# Case-folded, like the class decision below and dejima_mark_internal:
|
||||
# a live add that misses the exemption the operator configured leaves
|
||||
# the name without it until the next launch, which then refuses to
|
||||
# start on the same mismatch.
|
||||
while IFS= read -r _d || [ -n "$_d" ]; do
|
||||
[ "${_d,,}" = "${domain,,}" ] && internal=1
|
||||
done < <(config_get core internal_domains | tr ':' '\n')
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
permit_args=(permit "$domain")
|
||||
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
|
||||
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
|
||||
|
||||
# TCP-level truth check from inside the very container that was just
|
||||
# updated - this script's job is the firewall, so "TCP connect succeeded"
|
||||
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
|
||||
# refused/blocked) and 28 (connect or overall timeout) mean still
|
||||
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
|
||||
# the connection itself went through the firewall fine. --max-time bounds
|
||||
# the whole request, not just the connect phase - without it, a server that
|
||||
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
|
||||
# would hang this script indefinitely; curl maps that case to the same
|
||||
# exit 28 the case below already handles, so no new branch is needed. This
|
||||
# is a report on this run, not a gate on either scope's approval: both
|
||||
# --project and --global are already recorded above, deliberately - a
|
||||
# declared host may legitimately not answer on 443 (an internal service,
|
||||
# an SSH-only host, an apex that only serves a subdomain), and the natural
|
||||
# moment to add a global approval is precisely when nothing is running to
|
||||
# probe in the first place. The exit 2 below still means what it says:
|
||||
# the live patch to this session did not verify.
|
||||
echo "Verifying $domain is reachable from $container..."
|
||||
rc=0
|
||||
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
|
||||
case "$rc" in
|
||||
6|7|28)
|
||||
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
|
||||
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
|
||||
echo "otherwise the allow did not take effect - re-check the container ID." >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
echo "Verified: $domain is reachable from $container."
|
||||
;;
|
||||
esac
|
||||
echo "Permitting $domain in $filter, this session's egress control..."
|
||||
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
|
||||
echo "" >&2
|
||||
echo "The permitted set was left as it was - the reason is above." >&2
|
||||
if [ "$internal" -eq 0 ]; then
|
||||
echo "" >&2
|
||||
echo "If $domain resolves inside your own network, say so in the config and" >&2
|
||||
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
|
||||
echo "not listed there, whether or not it is approved:" >&2
|
||||
echo "" >&2
|
||||
echo " [core]" >&2
|
||||
echo " internal_domains = $domain" >&2
|
||||
echo "" >&2
|
||||
echo "It still needs an approval to be reachable at all:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --global $domain" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
# TCP-level truth check from inside the very container that was just
|
||||
# updated - this script's job is the firewall, so "TCP connect succeeded"
|
||||
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
|
||||
# refused/blocked) and 28 (connect or overall timeout) mean still
|
||||
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
|
||||
# the connection itself went through the firewall fine. --max-time bounds
|
||||
# the whole request, not just the connect phase - without it, a server that
|
||||
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
|
||||
# would hang this script indefinitely; curl maps that case to the same
|
||||
# exit 28 the case below already handles, so no new branch is needed. This
|
||||
# is a report on this run, not a gate on either scope's approval: both
|
||||
# --project and --global are already recorded above, deliberately - a
|
||||
# declared host may legitimately not answer on 443 (an internal service,
|
||||
# an SSH-only host, an apex that only serves a subdomain), and the natural
|
||||
# moment to add a global approval is precisely when nothing is running to
|
||||
# probe in the first place. The exit 2 below still means what it says:
|
||||
# the live patch to this session did not verify.
|
||||
echo "Verifying $domain is reachable from $container..."
|
||||
rc=0
|
||||
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
|
||||
case "$rc" in
|
||||
6|7|28)
|
||||
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
|
||||
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
|
||||
echo "otherwise the allow did not take effect - re-check the container ID." >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
echo "Verified: $domain is reachable from $container."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
+380
-61
@@ -19,12 +19,21 @@ case "${1:-}" in
|
||||
cat <<'DEJIMA_ATTACH_HELP'
|
||||
dejima-attach - list live dejima sessions and attach to one
|
||||
|
||||
Usage: dejima-attach [run-id | --list]
|
||||
Usage: dejima-attach [run-id | --watch | --list | --count-waiting]
|
||||
|
||||
(no argument) numbered picker of live sessions; attaches to the choice
|
||||
run-id attach to one directly, which is what a notification's deep
|
||||
link and ttyd's --url-arg both produce
|
||||
--watch the same picker, redrawn every few seconds: which sessions
|
||||
are working and which are waiting for you, in one window
|
||||
--list one tab-separated row per live session, for scripting
|
||||
--count-waiting how many are waiting for you, for a tmux status line
|
||||
--counts one line of key=value for all of them, counted in one pass
|
||||
--state-dir where the registry and the reported states live
|
||||
|
||||
Inside tmux the picker switches this client to the session's own window
|
||||
instead of attaching, so it is a window switcher there and a front door
|
||||
everywhere else.
|
||||
|
||||
This is the shared core behind both remote front doors: it runs as a forced SSH
|
||||
command and as ttyd's command, so what an SSH key can reach and what the web
|
||||
@@ -72,16 +81,42 @@ source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||
# 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.
|
||||
# Coloured by meaning, not by name: waiting is the one an operator is looking
|
||||
# for, working is the one they can leave alone, and stale/no-tmux are the two
|
||||
# that will refuse if picked.
|
||||
STATE_COLOR=""
|
||||
state_color() {
|
||||
case "$1" in
|
||||
attachable) printf '%s' "$C_OK" ;;
|
||||
stale) printf '%s' "$C_ERR" ;;
|
||||
*) printf '%s' "$C_WARN" ;;
|
||||
working|attachable) STATE_COLOR="$C_OK" ;;
|
||||
stale) STATE_COLOR="$C_ERR" ;;
|
||||
*) STATE_COLOR="$C_WARN" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# What the session last said about itself, from the level directory
|
||||
# hooks/dejima-notify.sh maintains. Empty for a session that has said nothing
|
||||
# yet, for a harness that reports nothing, and for a launch from before the
|
||||
# directory existed - all three render as the plain reachability state.
|
||||
#
|
||||
# The enum is iterated rather than the directory read, so a name the session
|
||||
# invented is never looked at. `done` comes before `running` deliberately: the
|
||||
# hook clears the siblings before writing, so holding both means a session has
|
||||
# contradicted itself, and the answer that sends an operator to look is the
|
||||
# safe one. -L as well as -e because a dangling symlink is a file a session can
|
||||
# plant as easily as an empty one, and it is still the session reporting on
|
||||
# itself either way.
|
||||
SESSION_LEVEL=""
|
||||
session_level() {
|
||||
local dir="$STATE_DIR/notify/$1/level" event
|
||||
SESSION_LEVEL=""
|
||||
for event in 'done' waiting running; do
|
||||
if [ -e "$dir/$event" ] || [ -L "$dir/$event" ]; then
|
||||
SESSION_LEVEL="$event"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Only for a registry entry written before pid= existed, i.e. a session that
|
||||
# outlived an upgrade: how long after its own started= it may still have no
|
||||
# container before the prune treats it as gone. Sized against a cold first
|
||||
@@ -96,6 +131,7 @@ PRE_PID_STARTUP_GRACE=900
|
||||
read_entry() {
|
||||
local file="$1" line
|
||||
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""; entry_pid=""
|
||||
entry_name=""
|
||||
[ -r "$file" ] || return 1
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
@@ -104,6 +140,7 @@ read_entry() {
|
||||
label=*) entry_label="${line#*=}" ;;
|
||||
started=*) entry_started="${line#*=}" ;;
|
||||
pid=*) entry_pid="${line#*=}" ;;
|
||||
name=*) entry_name="${line#*=}" ;;
|
||||
esac
|
||||
done < "$file" || return 1
|
||||
# pid reaches `kill`, which takes a job spec as well as a number: "%1" or
|
||||
@@ -122,36 +159,97 @@ read_entry() {
|
||||
esac
|
||||
}
|
||||
|
||||
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
|
||||
# as env precisely so this correlation is possible without the container
|
||||
# having any write access to the registry.
|
||||
# Every running sandbox's container, keyed by the run id it reports.
|
||||
# bin/dejima passes DEJIMA_RUN_ID in as env precisely so this correlation is
|
||||
# possible without the container having any write access to the registry.
|
||||
#
|
||||
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
|
||||
# the caller prunes on the former, and pruning on the latter would permanently
|
||||
# unregister every running sandbox the moment dockerd happens to be restarting.
|
||||
container_for() {
|
||||
local run_id="$1" names name env unasked=""
|
||||
# Built once per listing rather than once per session: this used to run
|
||||
# `docker ps` and then inspect containers one by one until it found the match,
|
||||
# for every entry in the registry in turn, so a dozen sessions cost a dozen
|
||||
# `docker ps` calls and up to a hundred and forty-four inspects. The listing
|
||||
# is what both front doors open on, and that is time an operator waits.
|
||||
#
|
||||
# CONTAINER_MAP_UNASKED is what keeps "no such container" (1) and "couldn't
|
||||
# ask" (2) apart downstream: the caller prunes on the former, and pruning on
|
||||
# the latter would permanently unregister every running sandbox the moment
|
||||
# dockerd happened to be restarting. One unanswerable inspect anywhere makes
|
||||
# every miss a 2, which is the same reach the per-session loop had.
|
||||
declare -A CONTAINER_BY_RUN=()
|
||||
CONTAINER_MAP_UNASKED=""
|
||||
load_container_map() {
|
||||
local names line id name
|
||||
CONTAINER_BY_RUN=()
|
||||
CONTAINER_MAP_UNASKED=""
|
||||
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
|
||||
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
|
||||
for name in $names; do
|
||||
# Captured rather than piped straight into grep: a pipeline reports
|
||||
# only grep's status, which would turn "inspect couldn't answer for
|
||||
# this container" into "this isn't the one" - and one such container
|
||||
# anywhere in the list would prune an unrelated live session.
|
||||
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|
||||
|| { unasked=1; continue; }
|
||||
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
|
||||
printf '%s' "$name"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
[ -z "$unasked" ] || return 2
|
||||
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" \
|
||||
|| { CONTAINER_MAP_UNASKED=1; return 0; }
|
||||
[ -n "$names" ] || return 0
|
||||
# One inspect for every container rather than one each. Two round trips to
|
||||
# the daemon where there were one plus one per container, which on a host
|
||||
# running a dozen sessions was most of the wait before the picker drew.
|
||||
#
|
||||
# Both values as JSON, which is what makes one line per container a
|
||||
# property rather than a hope: a newline inside an environment value is
|
||||
# escaped rather than printed, so a line cannot be split and the name
|
||||
# cannot drift away from the environment beside it. A missing container
|
||||
# takes its line with it instead of shifting the rest.
|
||||
#
|
||||
# Read with shell string operations rather than a JSON parser: the only
|
||||
# question here is which element STARTS with DEJIMA_RUN_ID=, and an
|
||||
# unescaped quote is exactly where an element starts. A value that merely
|
||||
# contained that text would carry a \" there and not match.
|
||||
local -a name_list=()
|
||||
mapfile -t name_list <<< "$names"
|
||||
local out
|
||||
# A container that went away between the two calls makes inspect exit
|
||||
# nonzero while still answering for the others. Unverified rather than
|
||||
# gone, which is the same answer one failed inspect gave when there was one
|
||||
# per container: pruning on that unregisters a live session for good.
|
||||
out="$(docker inspect --format '{{json .Name}} {{json .Config.Env}}' \
|
||||
"${name_list[@]}" 2>/dev/null)" || CONTAINER_MAP_UNASKED=1
|
||||
while IFS= read -r line; do
|
||||
case "$line" in *'"DEJIMA_RUN_ID='*) ;; *) continue ;; esac
|
||||
id="${line#*\"DEJIMA_RUN_ID=}"
|
||||
id="${id%%\"*}"
|
||||
valid_run_id "$id" || continue
|
||||
# {{json .Name}} is a quoted "/name"; docker ps gave the same names
|
||||
# without the slash, and the registry is keyed by neither.
|
||||
name="${line%% *}"
|
||||
name="${name#\"}"; name="${name%\"}"; name="${name#/}"
|
||||
CONTAINER_BY_RUN["$id"]="$name"
|
||||
done <<< "$out"
|
||||
}
|
||||
|
||||
# The container reporting this run id, out of the map above. The answer comes
|
||||
# back in a variable rather than on stdout, like the two readers below: every
|
||||
# one of these is called once per session, and `$(...)` is a forked subshell
|
||||
# per call - which on a host running a dozen sessions was more of the wait than
|
||||
# the work.
|
||||
CONTAINER_NAME=""
|
||||
container_for() {
|
||||
CONTAINER_NAME="${CONTAINER_BY_RUN[$1]:-}"
|
||||
[ -z "$CONTAINER_NAME" ] || return 0
|
||||
[ -z "$CONTAINER_MAP_UNASKED" ] || return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Every pane on the server, asked once. This was a `tmux list-panes` and a
|
||||
# grep per session, so a dozen sessions meant a dozen round trips to the tmux
|
||||
# server and two dozen processes, for one answer that does not change while a
|
||||
# listing is being built.
|
||||
# Every caller of pane_alive loads this first: list_sessions and count_waiting
|
||||
# once for the listing they are building, and attach() again at the moment it
|
||||
# matters. A path that forgets reads an empty list and calls every session
|
||||
# stale, which is loud rather than subtle - it is what the attach tests catch.
|
||||
PANE_LIST=""
|
||||
load_pane_list() {
|
||||
PANE_LIST=$'\n'"$(tmux list-panes -a -F '#{pane_id}' 2>/dev/null || true)"$'\n'
|
||||
}
|
||||
|
||||
pane_alive() {
|
||||
[ -n "$1" ] || return 1
|
||||
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
|
||||
case "$PANE_LIST" in *$'\n'"$1"$'\n'*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Does session $1 actually hold pane $2? The only question that separates the
|
||||
@@ -197,16 +295,31 @@ launcher_alive() {
|
||||
# no matching container - and old enough that it should have had one - is
|
||||
# pruned outright; one whose pane is gone is shown as stale rather than
|
||||
# offered for attachment.
|
||||
#
|
||||
# Oldest first, by start time, which is the one order that holds still while an
|
||||
# operator is reading it: the numbers in the picker are how a session is
|
||||
# selected, so a list that re-sorted itself by state - the tempting order for a
|
||||
# live view - would renumber the rows between reading one and typing it. A new
|
||||
# session appears at the end and changes nothing above it.
|
||||
list_sessions() {
|
||||
local file run_id container state now rc warned=""
|
||||
# The sort key is prepended by the loop and dropped here, so the columns
|
||||
# the picker and --list read are unchanged.
|
||||
list_sessions_unsorted | sort -n | cut -f2-
|
||||
}
|
||||
|
||||
list_sessions_unsorted() {
|
||||
local file run_id container state project now rc warned=""
|
||||
[ -d "$SESSIONS_DIR" ] || return 0
|
||||
now="$(date +%s)"
|
||||
load_container_map
|
||||
load_pane_list
|
||||
for file in "$SESSIONS_DIR"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
run_id="$(basename "$file")"
|
||||
valid_run_id "$run_id" || continue
|
||||
rc=0
|
||||
container="$(container_for "$run_id")" || rc=$?
|
||||
container_for "$run_id" || rc=$?
|
||||
container="$CONTAINER_NAME"
|
||||
if [ "$rc" -eq 1 ] && launcher_alive "$file" "$now"; then
|
||||
# Registered, but bin/dejima is still building - "not started yet"
|
||||
# is a third answer, and pruning on it would unregister a session
|
||||
@@ -249,12 +362,102 @@ list_sessions() {
|
||||
else
|
||||
state="stale"
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
|
||||
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
|
||||
# One column for two questions, because they do not overlap: a session
|
||||
# that cannot be attached to has nothing useful to say about whether it
|
||||
# wants attention, and for every session that CAN be attached to,
|
||||
# "attachable" is the answer to a question nobody asked. The words are
|
||||
# distinct, so the column stays unambiguous while the row stays narrow
|
||||
# enough for a phone.
|
||||
# ${x##*/} rather than basename, which is a process per row.
|
||||
project="${entry_workspace##*/}"
|
||||
[ -n "$project" ] || project="$entry_workspace"
|
||||
if [ "$state" = attachable ]; then
|
||||
session_level "$run_id"
|
||||
# `done` and `waiting` are the harness events the hook reports -
|
||||
# a turn ended, and something was asked for. One state to an
|
||||
# operator, who does the same thing about either.
|
||||
case "$SESSION_LEVEL" in
|
||||
done|waiting) state="waiting" ;;
|
||||
running) state="working" ;;
|
||||
esac
|
||||
fi
|
||||
# The name is second, before the columns that were already here, so a
|
||||
# row reads id-name-project rather than ending on the one field an
|
||||
# operator scans for. It is empty for a session launched without
|
||||
# --name, which is what the picker's fallback to the project is for.
|
||||
printf '%d\t%s\t%s\t%s\t%s\t%dm\t%s\t%s\n' \
|
||||
"$(( 10#$entry_started ))" \
|
||||
"$run_id" "$entry_name" "${entry_label:-?}" "$project" \
|
||||
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
|
||||
done
|
||||
}
|
||||
|
||||
# What every live session is doing, counted in one pass, for a status line to
|
||||
# interpolate. Registry, panes and levels only: no docker, and nothing pruned.
|
||||
#
|
||||
# A status line re-runs this every few seconds, where --list is read once by a
|
||||
# person, so the one question that costs a round trip to the daemon per
|
||||
# container is the one question this does not ask. What that gives up is
|
||||
# noticing a session whose container died without taking bin/dejima with it -
|
||||
# counted here, shown as gone by every listing - and telling a session whose
|
||||
# image is still building from one that is simply quiet; both read as
|
||||
# `attachable`. The pane list is one tmux call for all of them, because the
|
||||
# alternative is one per session.
|
||||
#
|
||||
# The words are the picker's, so that a number on a status bar and a row on a
|
||||
# screen say the same thing about the same session rather than two vocabularies
|
||||
# meeting in an operator's head.
|
||||
declare -A SESSION_COUNTS=()
|
||||
count_states() {
|
||||
local file run_id now state
|
||||
SESSION_COUNTS=([waiting]=0 [working]=0 [attachable]=0 [stale]=0 [no-tmux]=0 [total]=0)
|
||||
[ -d "$SESSIONS_DIR" ] || return 0
|
||||
now="$(date +%s)"
|
||||
load_pane_list
|
||||
for file in "$SESSIONS_DIR"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
run_id="$(basename "$file")"
|
||||
valid_run_id "$run_id" || continue
|
||||
# A session whose launcher is gone is one the exit trap did not get to
|
||||
# remove. Not counted at all rather than counted as broken: the listing
|
||||
# is where an operator goes to find out about those, and a status bar
|
||||
# that never reaches zero stops being read.
|
||||
launcher_alive "$file" "$now" || continue
|
||||
read_entry "$file" || continue
|
||||
if [ -z "$entry_pane" ]; then
|
||||
state=no-tmux
|
||||
elif ! pane_alive "$entry_pane"; then
|
||||
state=stale
|
||||
else
|
||||
session_level "$run_id"
|
||||
case "$SESSION_LEVEL" in
|
||||
done|waiting) state=waiting ;;
|
||||
running) state=working ;;
|
||||
*) state=attachable ;;
|
||||
esac
|
||||
fi
|
||||
SESSION_COUNTS[$state]=$(( SESSION_COUNTS[$state] + 1 ))
|
||||
SESSION_COUNTS[total]=$(( SESSION_COUNTS[total] + 1 ))
|
||||
done
|
||||
}
|
||||
|
||||
count_waiting() {
|
||||
count_states
|
||||
printf '%s\n' "${SESSION_COUNTS[waiting]}"
|
||||
}
|
||||
|
||||
# One line of key=value, in the order a reader cares about them. A line rather
|
||||
# than a number because two numbers cost two invocations otherwise, and this
|
||||
# one is run on a timer.
|
||||
counts_line() {
|
||||
local key out=""
|
||||
count_states
|
||||
for key in waiting working attachable stale no-tmux total; do
|
||||
out="$out$key=${SESSION_COUNTS[$key]} "
|
||||
done
|
||||
printf '%s\n' "${out% }"
|
||||
}
|
||||
|
||||
# Attaches through a GROUPED session rather than the operator's own. Grouped
|
||||
# sessions share windows but track their own current window, so a phone can
|
||||
# sit on a different sandbox than the desktop, and destroy-unattached disposes
|
||||
@@ -278,6 +481,9 @@ attach() {
|
||||
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Freshly, not from whatever a listing left behind: the pane may have gone
|
||||
# away in the seconds between an operator reading the row and picking it.
|
||||
load_pane_list
|
||||
if ! pane_alive "$entry_pane"; then
|
||||
echo "Session $run_id is stale - its tmux pane is gone." >&2
|
||||
exit 1
|
||||
@@ -306,6 +512,20 @@ attach() {
|
||||
echo "Session $run_id is stale - its tmux pane went away." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Already inside tmux: move this client rather than attaching. Everything
|
||||
# below is written for a client that has no tmux of its own - a phone, an
|
||||
# ssh forced command - and tmux refuses to nest a session inside one of its
|
||||
# own panes, so on the operator's own machine the picker could list
|
||||
# sessions and then fail to reach any of them. Switching is also the
|
||||
# cheaper thing by a long way: no throwaway session to create, verify and
|
||||
# reap, and the window the operator lands on is the real one.
|
||||
#
|
||||
# switch-client rather than select-window, so a sandbox launched from some
|
||||
# other tmux session is reachable too and not just a window of this one.
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
tmux switch-client -t "=$base:$win"
|
||||
return 0
|
||||
fi
|
||||
if tmux has-session -t "=$group" 2>/dev/null; then
|
||||
# Verified exactly like the session created below, and for the same
|
||||
# reason: nothing here created this one, so carrying the name is not
|
||||
@@ -366,51 +586,150 @@ attach() {
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
# An age a glance can read: minutes for the first hour, then hours, then days.
|
||||
# `--list` keeps the minutes it has always printed, because a number is what a
|
||||
# script wants; three characters is what lets the row fit the phone, and "2h"
|
||||
# is what tells an operator a session has been working since before lunch
|
||||
# where "137m" is a sum to do first.
|
||||
AGE_SHORT=""
|
||||
age_short() { # <minutes, as --list prints them>
|
||||
local m="${1%m}"
|
||||
if [ "$m" -lt 60 ]; then AGE_SHORT="${m}m"
|
||||
elif [ "$m" -lt 2880 ]; then AGE_SHORT="$(( m / 60 ))h"
|
||||
else AGE_SHORT="$(( m / 1440 ))d"
|
||||
fi
|
||||
}
|
||||
|
||||
# Draws the list and reads a choice. With a timeout it returns 1 when nothing
|
||||
# was typed, which is what --watch loops on.
|
||||
picker() {
|
||||
local -a ids=() labels=() projects=() ages=() states=() containers=()
|
||||
local i choice run_id label project age state container
|
||||
local w_label=0 w_project=0 w_age=0 w_state=0
|
||||
# Split on tabs only, so a space in a project name stays part of one field.
|
||||
# The columns are measured on the way in because they are padded to the
|
||||
# widest value rather than to a guess: a project name is a directory name,
|
||||
# and a fixed width either truncates a long one or indents every short one
|
||||
# off the right edge of a phone.
|
||||
while IFS=$'\t' read -r run_id label project age state container; do
|
||||
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
|
||||
ages+=("$age"); states+=("$state"); containers+=("$container")
|
||||
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
|
||||
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
|
||||
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
|
||||
local timeout="${1:-}"
|
||||
local -a ids=() names=() ages=() states=()
|
||||
local i choice run_id name label project age state container
|
||||
local w_name=0 w_state=0
|
||||
# Three columns, because the front door this is read through is a phone.
|
||||
# The tmux label and the container id are in `--list` and not here: the
|
||||
# label is a coordinate an operator no longer has to act on, since picking
|
||||
# a session goes to it, and the container id shares a prefix with every
|
||||
# other one, so a column of them says nothing. Carrying all five made a row
|
||||
# about eighty columns wide, which wrapped onto a second line and put the
|
||||
# state and the age under the NEXT session's number.
|
||||
#
|
||||
# The name is first, against the index, because those two are what an
|
||||
# operator matches up: the number is only useful next to the thing it
|
||||
# selects. It is measured on the way in rather than padded to a guess, and
|
||||
# capped, since the padding decides where the state sits.
|
||||
# Split on a unit separator, not on the tab the rows are built with. A tab
|
||||
# is IFS whitespace, so a RUN of them collapses into one delimiter: a row
|
||||
# whose name is empty - every entry written before that field existed -
|
||||
# loses the field entirely and shifts the five after it along. What that
|
||||
# looked like was the tmux label sitting where the name goes, the state
|
||||
# sitting in the age column, and the row coloured from the container id,
|
||||
# so a stale session was neither red nor labelled stale where it was read.
|
||||
while IFS=$'\037' read -r run_id name label project age state container; do
|
||||
# The project stands in for an entry written before the name existed.
|
||||
[ -n "$name" ] || name="$project"
|
||||
# The two answers the container column carried that were not
|
||||
# decoration, where they are read rather than in a column of their
|
||||
# own: a session whose image is still building is not an ordinary
|
||||
# attachable one, and "?" is already said once on stderr for the whole
|
||||
# listing when docker could not be asked at all.
|
||||
[ "$container" != starting ] || state=starting
|
||||
ids+=("$run_id"); names+=("$name"); states+=("$state")
|
||||
age_short "$age"
|
||||
ages+=("$AGE_SHORT")
|
||||
[ "${#name}" -le "$w_name" ] || w_name="${#name}"
|
||||
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
|
||||
done < <(list_sessions)
|
||||
done < <(list_sessions | tr '\t' '\037')
|
||||
banner
|
||||
if [ "${#ids[@]}" -eq 0 ]; then
|
||||
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
|
||||
# A live view waits for one to appear, where the one-shot picker has
|
||||
# its answer and is done. Launching a session is exactly when an
|
||||
# operator is watching this, so exiting here would close the window
|
||||
# they opened to watch it in.
|
||||
[ -z "$timeout" ] || { sleep "$timeout"; return 1; }
|
||||
exit 0
|
||||
fi
|
||||
# The container column stays, and last, because it is not decoration: it
|
||||
# reads "starting" for a session bin/dejima is still building and "?" when
|
||||
# docker could not be asked at all, and dropping it would make either one
|
||||
# look like an ordinary attachable session.
|
||||
# The name column takes whatever the terminal has left after the fixed
|
||||
# parts, rather than a width picked here. A fixed one is wrong in both
|
||||
# directions at once: a desktop has room for every name and would cut them
|
||||
# anyway, and a narrow phone would still overflow and wrap, which is the
|
||||
# failure this layout exists to avoid. 80 when there is no terminal to ask,
|
||||
# which is the width of a pipe by convention.
|
||||
#
|
||||
# 15 is the row minus the name: two leading spaces, two for the index, two
|
||||
# spaces, the dot, a space, two spaces after the name, two before the age
|
||||
# and three for the age itself.
|
||||
#
|
||||
# Asked of the controlling terminal, not of stdin. `tput cols` reads the
|
||||
# size from fd 0, and this picker's stdin is whatever the front door handed
|
||||
# it - a pipe wherever the choice is fed in rather than typed - where tput
|
||||
# answers with terminfo's 80 and names are cut on a phone that has fewer.
|
||||
local cols budget
|
||||
cols="$(stty size </dev/tty 2>/dev/null | cut -d' ' -f2 || true)"
|
||||
case "$cols" in ''|*[!0-9]*) cols=80 ;; esac
|
||||
# A pty nobody set a size on reports zero columns, which is not a width but
|
||||
# an unanswered question: taken literally it clamps every name to the floor
|
||||
# below, on a terminal that may be perfectly wide.
|
||||
[ "$cols" -ge 20 ] || cols=80
|
||||
budget=$(( cols - 15 - w_state ))
|
||||
# A terminal too narrow for a name at all still gets something to read.
|
||||
[ "$budget" -ge 8 ] || budget=8
|
||||
[ "$w_name" -le "$budget" ] || w_name="$budget"
|
||||
for i in "${!ids[@]}"; do
|
||||
local sc; sc="$(state_color "${states[$i]}")"
|
||||
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%*s%s %s%-*s%s %s%s%s\n' \
|
||||
state_color "${states[$i]}"
|
||||
# Cut names are marked. A name quietly losing its last characters is
|
||||
# worse than a short column: two projects can share twenty-four
|
||||
# characters and differ in the twenty-fifth, and nothing on the row
|
||||
# would say so.
|
||||
#
|
||||
# An ASCII marker even where the rest of the glyphs are not, because
|
||||
# this one sits INSIDE a padded field: bash measures a multi-byte
|
||||
# character as its bytes wherever the locale is not UTF-8, and printf
|
||||
# then pads the field to that many bytes, so the row would come out two
|
||||
# columns short and take the state column with it. This script runs
|
||||
# under whatever locale a forced ssh command or the ttyd unit has.
|
||||
if [ "${#names[$i]}" -gt "$w_name" ]; then
|
||||
names[$i]="${names[$i]:0:$(( w_name - 1 ))}~"
|
||||
fi
|
||||
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%3s%s\n' \
|
||||
"$C_WARN" "$((i + 1))" "$C_OFF" \
|
||||
"$sc" "$GLYPH_DOT" "$C_OFF" \
|
||||
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
|
||||
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
|
||||
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
||||
"$STATE_COLOR" "$GLYPH_DOT" "$C_OFF" \
|
||||
"$C_BOLD$C_TEXT" "$w_name" "${names[$i]}" "$C_OFF" \
|
||||
"$STATE_COLOR" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "${ages[$i]}" "$C_OFF"
|
||||
done
|
||||
tui_pick "${#ids[@]}" 'attach to'
|
||||
tui_pick "${#ids[@]}" 'attach to' "$timeout" || return 1
|
||||
choice="$TUI_CHOICE"
|
||||
attach "${ids[$((choice - 1))]}"
|
||||
}
|
||||
|
||||
# How often --watch redraws. Five seconds, because a redraw is a full listing -
|
||||
# one `docker ps`, one inspect per container - and the thing being watched is a
|
||||
# session either finishing a turn or starting one, which is not a sub-second
|
||||
# event. The screen clear in the banner is what keeps a redraw from scrolling.
|
||||
WATCH_INTERVAL=5
|
||||
|
||||
# The at-a-glance view: the same picker, redrawn until something is typed.
|
||||
# Deliberately not a second tool. A separate monitor would have to grow its own
|
||||
# list, its own columns and its own idea of what a session is, and the thing an
|
||||
# operator does next after seeing which session wants them is to go to it -
|
||||
# which this can already do, through the same numbers.
|
||||
watch_picker() {
|
||||
while ! picker "$WATCH_INTERVAL"; do :; done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
'') picker ;;
|
||||
--watch) watch_picker ;;
|
||||
--list) list_sessions ;;
|
||||
--count-waiting) count_waiting ;;
|
||||
--counts) counts_line ;;
|
||||
# Where the registry and the reported states live, for anything on this
|
||||
# host that wants to read them without re-deriving which of
|
||||
# XDG_RUNTIME_DIR and /tmp this user got.
|
||||
--state-dir) printf '%s\n' "$STATE_DIR" ;;
|
||||
*)
|
||||
# This argument arrives from ttyd's --url-arg, i.e. from whoever can
|
||||
# reach the web front door. Validate its shape before it is used as a
|
||||
|
||||
+4
-1
@@ -974,7 +974,10 @@ tcb_snapshot() {
|
||||
# backends/ likewise: a backend.conf names the upstream every request
|
||||
# is forwarded to and the host path the credential is read from, so an
|
||||
# edit here redirects where a real credential is sent.
|
||||
for dir in bin hooks docker test forges workflows harnesses tools backends; do
|
||||
#
|
||||
# mirror/ runs on the host from a timer, holding a key that can push
|
||||
# to every repository in the GitHub organisation.
|
||||
for dir in bin hooks docker test forges workflows harnesses tools backends mirror; do
|
||||
if [ -d "$ws/$dir" ]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
stage_entry "tcb:${f#"$ws"/}" "$f"
|
||||
|
||||
+130
-12
@@ -52,6 +52,35 @@ SUPPLIED_NAMES = {
|
||||
}
|
||||
|
||||
|
||||
# What an operator has to do when the stored credential can no longer be
|
||||
# refreshed, in one place. Both the startup check and a refresh that the
|
||||
# provider rejects reach this, and an operator who reads one and not the other
|
||||
# would otherwise get the remedy from whichever happened to fire.
|
||||
# Written to be read in a crowded terminal: the action first, in one short
|
||||
# sentence, because this is competing with a wall of harness output and an
|
||||
# operator scanning it should not have to reach the end of a clause to find the
|
||||
# verb. What went wrong is the subordinate half - by the time this is printed,
|
||||
# what to do about it is the only part that changes anything.
|
||||
REAUTH_REMEDY = ("run `claude` on the host to log in again - the stored "
|
||||
"credential expired and cannot be refreshed")
|
||||
|
||||
|
||||
class CredentialNeedsOperator(RuntimeError):
|
||||
"""A credential failure whose remedy is a command, not a diagnosis.
|
||||
|
||||
Carries two texts. The message is written here rather than derived from a
|
||||
provider's response, which is what makes it safe to send to the container
|
||||
as well as the host; `detail` is whatever the provider actually said and
|
||||
stays host-side. The rule being respected is that upstream bytes and
|
||||
exception detail do not cross into the session - not that the session must
|
||||
be told nothing.
|
||||
"""
|
||||
|
||||
def __init__(self, message, detail=None):
|
||||
super().__init__(message)
|
||||
self.detail = detail or message
|
||||
|
||||
|
||||
class StaticCredential:
|
||||
"""A key in a file, sent as-is. No expiry, so nothing to refresh."""
|
||||
|
||||
@@ -130,11 +159,9 @@ class OAuthCredential:
|
||||
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")
|
||||
# credential shape, so the remedy is a command the operator can run
|
||||
# rather than an instruction to work out.
|
||||
raise CredentialNeedsOperator(REAUTH_REMEDY)
|
||||
|
||||
def _read(self):
|
||||
with open(self.path) as f:
|
||||
@@ -172,6 +199,19 @@ class OAuthCredential:
|
||||
payload = resp.read()
|
||||
conn.close()
|
||||
if resp.status != 200:
|
||||
# invalid_grant is the provider saying this refresh token is
|
||||
# finished - not a transient failure, and not something waiting
|
||||
# or retrying improves. It is what an overnight session meets in
|
||||
# the morning, and the raw body says `invalid_grant` without
|
||||
# saying that logging in on the host is the way out.
|
||||
#
|
||||
# The body is still logged in full: this only decides which
|
||||
# remedy to name.
|
||||
if b"invalid_grant" in payload:
|
||||
raise CredentialNeedsOperator(
|
||||
REAUTH_REMEDY,
|
||||
detail="token refresh failed: HTTP %d %.200s"
|
||||
% (resp.status, payload))
|
||||
raise RuntimeError("token refresh failed: HTTP %d %.200s"
|
||||
% (resp.status, payload))
|
||||
fresh = json.loads(payload)
|
||||
@@ -360,29 +400,81 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# forwarded upstream is a worse outcome than a rejected one.
|
||||
MAX_BODY_BYTES = 32 * 1024 * 1024
|
||||
|
||||
def _length(self, raw, what):
|
||||
"""A byte count from the wire, or BodyTooLarge.
|
||||
|
||||
`int()` alone accepts a negative, and a negative passes a `> MAX` test
|
||||
and then means "read to EOF" to a buffered reader - so the cap above,
|
||||
whose whole reason is that this runs on the host as the operator, was
|
||||
bypassed by sending `Content-Length: -1`.
|
||||
|
||||
A value that is not a number still raises out of here, deliberately: the
|
||||
suppression around handle_error is meant to be specific to the client
|
||||
going away, and a malformed chunk length is the fault the test for that
|
||||
uses to prove it. Refusing it cleanly here would remove the canary and
|
||||
trade noise for blindness.
|
||||
"""
|
||||
n = int(raw, 16) if what == "chunk size" else int(raw)
|
||||
if n < 0 or n > self.MAX_BODY_BYTES:
|
||||
# The offending value, not the cap. The host log said "refused a
|
||||
# request body: 33554432 bytes" whatever arrived, so a hostile -1
|
||||
# and a genuinely oversized upload read identically - and the
|
||||
# comment on the 413 path promises the log says what came in.
|
||||
raise BodyTooLarge("%s of %d, cap %d"
|
||||
% (what, n, self.MAX_BODY_BYTES))
|
||||
return n
|
||||
|
||||
def handle_expect_100(self):
|
||||
"""Refuse an oversized body before inviting it.
|
||||
|
||||
The default answers `100 Continue` and returns True, and it runs
|
||||
before do_* - so the proxy invited the upload, read the length in
|
||||
_read_body, refused it and closed mid-flight. Whether the client saw
|
||||
the 413 or only the 100 was timing, which is a race a client cannot
|
||||
be expected to win and a test cannot be expected to pass reliably.
|
||||
|
||||
The declared length is all there is to go on before a body arrives.
|
||||
A chunked request declares nothing and is capped as it is read, which
|
||||
is the one case where the conversation still ends mid-upload - there
|
||||
is no earlier moment to end it at.
|
||||
"""
|
||||
try:
|
||||
self._length(self.headers.get("Content-Length", 0), "Content-Length")
|
||||
except BodyTooLarge as e:
|
||||
# Closed for the same reason the other 413 closes: a client that
|
||||
# sends anyway - curl gives up waiting after a second - leaves its
|
||||
# bytes queued, and the next parse would read them as a request
|
||||
# line and answer with garbage 400s instead of one clean refusal.
|
||||
self.close_connection = True
|
||||
self._fail(413, "credential proxy: request body too large",
|
||||
log_message="refused a request body: %s" % e)
|
||||
return False
|
||||
return super().handle_expect_100()
|
||||
|
||||
def _read_body(self):
|
||||
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
size = int(self.rfile.readline().split(b";")[0], 16)
|
||||
size = self._length(self.rfile.readline().split(b";")[0], "chunk size")
|
||||
if size == 0:
|
||||
self.rfile.readline()
|
||||
return b"".join(chunks)
|
||||
total += size
|
||||
if total > self.MAX_BODY_BYTES:
|
||||
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
|
||||
raise BodyTooLarge("chunked body of %d or more, cap %d"
|
||||
% (total, self.MAX_BODY_BYTES))
|
||||
chunks.append(self.rfile.read(size))
|
||||
self.rfile.read(2)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length > self.MAX_BODY_BYTES:
|
||||
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
|
||||
length = self._length(self.headers.get("Content-Length", 0), "Content-Length")
|
||||
return self.rfile.read(length)
|
||||
|
||||
def _fail(self, status, message, log_message=None):
|
||||
# log_message (host stderr) may contain upstream response bytes or
|
||||
# exception detail; message (sent to the container in the response
|
||||
# body) never should - see docs/security.md.
|
||||
# exception detail; message (sent to the container in the response body)
|
||||
# never should. The one thing that does cross is text written in this
|
||||
# file naming something the operator must do - see CredentialNeedsOperator,
|
||||
# and docs/security.md for why that is not an exception to the rule.
|
||||
self.log_error("%s", log_message if log_message is not None else message)
|
||||
body = json.dumps(
|
||||
{"type": "error", "error": {"type": "proxy_error", "message": message}}
|
||||
@@ -479,10 +571,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 413 rather than a traceback and a 500: the session gets a status
|
||||
# that says what to do about it, and the host log says what came
|
||||
# in. Nothing is forwarded upstream.
|
||||
#
|
||||
# And the connection ends here. The body was refused before it was
|
||||
# read, so its bytes are still queued; left open, the next parse
|
||||
# takes them as a request line and answers a keep-alive client with
|
||||
# garbage 400s instead of one clean refusal.
|
||||
self.close_connection = True
|
||||
return self._fail(413, "credential proxy: request body too large",
|
||||
log_message="refused a request body: %s" % e)
|
||||
try:
|
||||
token = self.server.creds.token()
|
||||
except CredentialNeedsOperator as e:
|
||||
# This one names the remedy in the response as well. The session is
|
||||
# where the failure is met, the host terminal is behind a window the
|
||||
# agent cannot read, and "see host terminal" costs an operator the
|
||||
# trip to find one sentence. Safe to send because the text is this
|
||||
# file's own - see the class.
|
||||
# Two lines, detail first. bin/dejima's exit audit prints the LAST
|
||||
# line of this log when a session ends, so the one an operator reads
|
||||
# without going looking is the one that says what to do. The
|
||||
# provider's own words stay directly above it for anyone who does go
|
||||
# looking.
|
||||
self.log_error("%s", "cannot load credentials: %s" % e.detail)
|
||||
return self._fail(502, "credential proxy: %s" % e, log_message=str(e))
|
||||
except Exception as e:
|
||||
return self._fail(502, "credential proxy: could not load or refresh the "
|
||||
"credential - see host terminal",
|
||||
@@ -499,6 +610,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.server.creds.refresh(stale_token=token)
|
||||
token = self.server.creds.token()
|
||||
conn, resp = self._upstream(token, body)
|
||||
except CredentialNeedsOperator as e:
|
||||
# The refresh on the 401 retry above can reach this, and it is not
|
||||
# an upstream failure - reporting it as one sends whoever reads it
|
||||
# looking at the provider's availability instead of at their own
|
||||
# login. Same remedy as the load path, for the same reason.
|
||||
self.log_error("%s", "cannot refresh credentials: %s" % e.detail)
|
||||
return self._fail(502, "credential proxy: %s" % e, log_message=str(e))
|
||||
except Exception as e:
|
||||
return self._fail(502, "credential proxy: upstream request failed - "
|
||||
"see host terminal",
|
||||
|
||||
+45
-21
@@ -160,17 +160,31 @@ def classify(path, value):
|
||||
return (None, None)
|
||||
|
||||
|
||||
def walk_json(doc, prefix=""):
|
||||
def walk_json(doc, prefix="", parts=()):
|
||||
"""Yields (display path, value, components).
|
||||
|
||||
The display path is what an operator reads and what finding_id hashes, so
|
||||
its spelling is fixed - it is recorded in the decisions file and a change
|
||||
would orphan every standing answer. It is also ambiguous: a dict key of
|
||||
`x[0]` and element 0 of a list `x` produce the same string, and so do a key
|
||||
containing a dot and the traversal a dot denotes.
|
||||
|
||||
The components are the same walk as a tuple, which is not ambiguous. Anything
|
||||
that has to FIND the value again uses those; re-parsing the string deleted
|
||||
the wrong element, and for a dotted key raised KeyError out of the scrub,
|
||||
leaving the file to be copied unscrubbed.
|
||||
"""
|
||||
if isinstance(doc, dict):
|
||||
for k, v in doc.items():
|
||||
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
|
||||
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k),
|
||||
parts + (("key", k),))
|
||||
elif isinstance(doc, list):
|
||||
# Indexed, so a decision recorded against one element does not silently
|
||||
# cover a different one later.
|
||||
for i, v in enumerate(doc):
|
||||
yield from walk_json(v, "%s[%d]" % (prefix, i))
|
||||
yield from walk_json(v, "%s[%d]" % (prefix, i), parts + (("idx", i),))
|
||||
else:
|
||||
yield (prefix, doc)
|
||||
yield (prefix, doc, parts)
|
||||
|
||||
|
||||
def ini_logical_lines(text):
|
||||
@@ -227,17 +241,28 @@ def walk_ini(text):
|
||||
yield (key, value)
|
||||
|
||||
|
||||
def delete_json_path(doc, path):
|
||||
# Mirrors walk_json's construction exactly, including list indices.
|
||||
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
|
||||
cur = doc
|
||||
for part in parts[:-1]:
|
||||
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
|
||||
last = parts[-1]
|
||||
if last.startswith("["):
|
||||
del cur[int(last[1:-1])]
|
||||
else:
|
||||
del cur[last]
|
||||
def components_sort_key(parts):
|
||||
"""Orders paths so a list element is deleted after any later sibling.
|
||||
|
||||
`del cur[i]` shifts every later index down, so applying findings in document
|
||||
order deletes the wrong elements from the second onwards: two secrets in one
|
||||
array left the second one in the file and removed an innocent value instead,
|
||||
and the file is then mounted into the container - the operator answers "keep
|
||||
it out" and it goes in. Deepest and highest index first makes each deletion
|
||||
independent of the ones after it.
|
||||
"""
|
||||
return [(1, p[1]) if p[0] == "idx" else (0, p[1]) for p in parts]
|
||||
|
||||
|
||||
def delete_json_components(doc, drop):
|
||||
"""Every component path, in an order under which no deletion invalidates
|
||||
another: deepest and highest index first, so a list deletion cannot shift an
|
||||
element that is still to be removed."""
|
||||
for parts in sorted(drop, key=components_sort_key, reverse=True):
|
||||
cur = doc
|
||||
for kind, item in parts[:-1]:
|
||||
cur = cur[item]
|
||||
del cur[parts[-1][1]]
|
||||
|
||||
|
||||
def drop_ini_paths(text, paths):
|
||||
@@ -311,21 +336,20 @@ def main():
|
||||
print("%s is not readable as JSON" % args.label, file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
pairs = list(walk_ini(text))
|
||||
pairs = [(p, v, ()) for p, v in walk_ini(text)]
|
||||
|
||||
if args.scrub:
|
||||
wanted = set(load_list(args.scrub))
|
||||
drop = [path for path, value in pairs
|
||||
drop = [(path, parts) for path, value, parts in pairs
|
||||
if finding_id(args.label, path) in wanted]
|
||||
if not drop:
|
||||
return 0
|
||||
if args.format == "json":
|
||||
doc = json.loads(text)
|
||||
for path in drop:
|
||||
delete_json_path(doc, path)
|
||||
delete_json_components(doc, [parts for _path, parts in drop])
|
||||
out = json.dumps(doc, indent=2) + "\n"
|
||||
else:
|
||||
out = drop_ini_paths(text, drop)
|
||||
out = drop_ini_paths(text, [p for p, _parts in drop])
|
||||
with open(args.file, "w") as f:
|
||||
f.write(out)
|
||||
return 0
|
||||
@@ -350,7 +374,7 @@ def main():
|
||||
return True
|
||||
return False
|
||||
|
||||
for path, value in pairs:
|
||||
for path, value, _parts in pairs:
|
||||
fid = finding_id(args.label, path)
|
||||
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
|
||||
continue
|
||||
|
||||
@@ -308,10 +308,10 @@ emit_host_exec() {
|
||||
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
[ -n "$leaf" ] && [ -n "$label" ] || {
|
||||
if [ -z "$leaf" ] || [ -z "$label" ]; then
|
||||
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
|
||||
}
|
||||
|
||||
@@ -495,6 +495,14 @@ _emit_seed_record() {
|
||||
DEJIMA_SEED+=("$rel"$'\t'"$src")
|
||||
}
|
||||
|
||||
# Called twice by core, and the second call is not a mistake. The first is
|
||||
# early, with the other two list loads, because the mount guard has to see
|
||||
# every host path a plugin names before anything is mounted. The second is
|
||||
# after harness_prepare_config, whose job is to PRODUCE per-run files - a
|
||||
# hook that makes a file has to run before the list that mounts it.
|
||||
#
|
||||
# So this resets rather than appends: the second call rebuilds the list, and a
|
||||
# plugin that emits the same entries twice gets them once.
|
||||
harness_seed_load() {
|
||||
DEJIMA_SEED=()
|
||||
harness_hook harness_seed
|
||||
|
||||
+4
-4
@@ -162,7 +162,7 @@ case "$action" in
|
||||
echo "dependencies:"
|
||||
report dependencies valid_dep_spec
|
||||
echo "allowed-domains:"
|
||||
report allowed-domains valid_domain
|
||||
report allowed-domains valid_permitted_domain
|
||||
echo "corpus:"
|
||||
report corpus valid_corpus_rule
|
||||
echo "tools:"
|
||||
@@ -170,12 +170,12 @@ case "$action" in
|
||||
;;
|
||||
approve)
|
||||
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
|
||||
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
|
||||
pending_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^pending //p')
|
||||
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
|
||||
pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
|
||||
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
|
||||
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_permitted_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
|
||||
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
|
||||
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
|
||||
trunc_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^truncated //p')
|
||||
if [ -n "$trunc_deps" ]; then
|
||||
echo "Warning: dependencies declares $trunc_deps entries; only the first $DEJIMA_MAX_DECLARED were considered." >&2
|
||||
fi
|
||||
|
||||
@@ -233,7 +233,7 @@ resolve_project_store() {
|
||||
|
||||
_approved_validator() {
|
||||
case "$1" in
|
||||
allowed-domains) printf 'valid_permitted_domain' ;;
|
||||
allowed-domains) printf 'valid_domain' ;;
|
||||
dependencies) printf 'valid_dep_spec' ;;
|
||||
corpus) printf 'valid_corpus_rule' ;;
|
||||
tools) printf 'valid_tool_name' ;;
|
||||
@@ -312,6 +312,19 @@ approve_entry() {
|
||||
local store="$1" kind="$2" entry="$3" validator
|
||||
validator="$(_approved_validator "$kind")" || return 1
|
||||
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
|
||||
# Granting is where the narrower rule belongs, not reading. An approval
|
||||
# already in the store was valid when it was made, and a rule that tightens
|
||||
# afterwards must not turn it into the corruption message below - that
|
||||
# message means tampering, and it would be saying so about the operator's
|
||||
# own file on every launch.
|
||||
if [ "$kind" = allowed-domains ] && ! valid_permitted_domain "$entry"; then
|
||||
echo "Not a domain this can permit: $entry" >&2
|
||||
echo "A permitted destination has to name at least two labels. The" >&2
|
||||
echo "sandbox's resolver does not forward a dotless query, and a bare" >&2
|
||||
echo "label would be permitted as a suffix, covering every name under" >&2
|
||||
echo "it. Use the fully qualified name." >&2
|
||||
return 1
|
||||
fi
|
||||
touch "$store/$kind"
|
||||
grep -qxF "$entry" "$store/$kind" || printf '%s\n' "$entry" >> "$store/$kind"
|
||||
}
|
||||
|
||||
+124
-20
@@ -1661,7 +1661,7 @@ BRIEFING_FRAGMENT_MAX=16384
|
||||
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
|
||||
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
|
||||
# kind of silent drift this whole function exists to prevent.
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir"
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir workspace_posture project_posture"
|
||||
|
||||
_briefing_whitelisted() {
|
||||
_set_contains "$_BRIEFING_WHITELIST" "$1"
|
||||
@@ -1671,14 +1671,30 @@ _briefing_whitelisted() {
|
||||
# allowlist is load-bearing for the agent too. Lives here, not in any
|
||||
# provider directory - see the module-level comment. A quoted heredoc, so
|
||||
# backticks and $ signs are literal and need no escaping.
|
||||
_briefing_core_preamble() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
You are running inside the dejima Docker container, not directly on
|
||||
the user's machine, with
|
||||
{{no_confirm_args}} active (no tool-use confirmations).
|
||||
|
||||
What this means:
|
||||
- The workspace directory is bind-mounted read-write and is the real
|
||||
# What the session is told about where it is: which of the host it can see,
|
||||
# what it may hand back, and what survives it.
|
||||
#
|
||||
# Two variants rather than one paragraph with caveats, because the ordinary
|
||||
# one states four things an isolated launch makes false - that the workspace
|
||||
# is the real project, that ~/shared is shared with every other session
|
||||
# against it, that the harness keeps state on the host, and that the work is
|
||||
# recoverable there afterwards. A session acts on these: one that believes
|
||||
# its transcript outlives the container leaves things for a resume that
|
||||
# cannot happen.
|
||||
#
|
||||
# Continuation lines carry the two spaces the preamble's bullet list uses,
|
||||
# since this is substituted into one.
|
||||
_briefing_workspace_posture() {
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
printf '%s' "Nothing of the user's machine is mounted here and this session has no
|
||||
past: no project checkout, no memory, and no transcript of an earlier
|
||||
run. Your working directory is the container's own and goes with it, as
|
||||
does anything you clone or write into it. ~/shared is the single
|
||||
exception - a real host directory, made fresh for this session, which no
|
||||
earlier session wrote to and no later one will read. Put there whatever
|
||||
the user should still have afterwards."
|
||||
else
|
||||
printf '%s' "The workspace directory is bind-mounted read-write and is the real
|
||||
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,
|
||||
@@ -1687,8 +1703,37 @@ What this means:
|
||||
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
|
||||
packages, anything outside the mounted paths, and the container itself go
|
||||
briefing."
|
||||
fi
|
||||
}
|
||||
|
||||
# The same split for what the session should expect to FIND. The ordinary
|
||||
# text is about a mounted checkout holding things that are not code; an
|
||||
# isolated session has no checkout to be careful with, and the useful
|
||||
# instruction is the opposite one - ask rather than look.
|
||||
_briefing_project_posture() {
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
printf '%s' "There is no project directory, so nothing you find in this container
|
||||
came from the user. If a task needs a file of theirs, say what you need
|
||||
rather than going looking for it: ~/shared is the only way they can hand
|
||||
you one."
|
||||
else
|
||||
printf '%s' "The project directory is mounted whole, so it can hold things that are not
|
||||
code: a .env, a database dump, a key someone left in the tree. Say so if
|
||||
you find one, and do not open, copy or send it anywhere unless the task
|
||||
actually requires it."
|
||||
fi
|
||||
}
|
||||
|
||||
_briefing_core_preamble() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
You are running inside the dejima Docker container, not directly on
|
||||
the user's machine, with
|
||||
{{no_confirm_args}} active (no tool-use confirmations).
|
||||
|
||||
What this means:
|
||||
- {{workspace_posture}}{{writable_mounts}} Everything else is disposable: installed system
|
||||
packages, anything the mounts do not cover, and the container itself go
|
||||
away when this run ends.
|
||||
- A headless browser is installed: playwright, with chromium, importable
|
||||
from any directory in either module system. If you change something with a
|
||||
@@ -1696,8 +1741,10 @@ What this means:
|
||||
and leave the screenshot in ~/shared.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, {{network_allowlist}}, plus anything
|
||||
approved via dejima-allow). Requests to anything else will simply
|
||||
fail to connect. That restriction is not only a limit on your reach: it
|
||||
approved via dejima-allow). Anything else is refused at once rather than
|
||||
left to time out, so a connection that HANGS is not this allowlist - it is
|
||||
something past the sandbox, and from in here the timing is the only thing
|
||||
that tells the two apart. That restriction is not only a limit on your reach: it
|
||||
is also what bounds where a prompt injection or a compromised dependency
|
||||
in this session could send the user's code or fetch its next instruction
|
||||
from. You cannot audit yourself for that from the inside, so the
|
||||
@@ -1709,6 +1756,10 @@ What this means:
|
||||
dejima-allow --container {{container_id}} <domain> # just this session
|
||||
dejima-allow --project <path> <domain> # this project, always
|
||||
dejima-allow --global <domain> # every project
|
||||
Several domains can go on one line, up to twenty, and more than one is
|
||||
printed back for the operator to confirm before anything is added. So ask
|
||||
for the whole set a task needs in one command rather than one at a time -
|
||||
but ask only for what it needs, because the list is read.
|
||||
Pick deliberately rather than asking for all three: a domain this project
|
||||
needs is --project, not --global - a global grant widens every other
|
||||
project's firewall too, which is rarely what one project's dependency
|
||||
@@ -1720,13 +1771,10 @@ What this means:
|
||||
payment or production API acts on the world the moment it is called, with
|
||||
no diff for anyone to review afterwards. Do not ask for one of those to
|
||||
finish a task - say what you need and let the operator decide.
|
||||
- The project directory is mounted whole, so it can hold things that are not
|
||||
code: a .env, a database dump, a key someone left in the tree. Say so if
|
||||
you find one, and do not open, copy or send it anywhere unless the task
|
||||
actually requires it.
|
||||
- {{project_posture}}
|
||||
- Stay within the task you were given. Since there are no permission
|
||||
prompts here, be more conservative than usual about anything destructive,
|
||||
irreversible, or outside the project directory.
|
||||
irreversible, or outside your working directory.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
@@ -1736,8 +1784,36 @@ BRIEFING_EOF
|
||||
# workflow can only opt into it (requires_worktree = on), never edit or omit
|
||||
# it. _briefing_resolve_worktree splices this into the workflow fragment at
|
||||
# its `<!-- core: worktree -->` marker.
|
||||
_briefing_core_worktree() {
|
||||
# Where a branch's working tree must live, and why.
|
||||
#
|
||||
# The ordinary rule rests on one fact - that the bind-mounted project
|
||||
# directory is the only path backed by host storage - and an isolated launch
|
||||
# has no such path, so every clause of it would be advice about a checkout
|
||||
# that is not there. What stays true is the question the rule answers: where
|
||||
# work can be put such that it is not thrown away.
|
||||
#
|
||||
# Both worktree workflows stated the first bullet identically in their own
|
||||
# fragments, which left the same argument in three places and only two of
|
||||
# them substitutable. It is core's now, so a workflow declaring
|
||||
# requires_worktree says where the block goes and nothing about what it says.
|
||||
_briefing_core_worktree_isolated() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
- A worktree here is just a directory, and nothing in this container is the
|
||||
user's checkout: clone what you need into your working directory and work
|
||||
there. The rule about keeping a worktree under the mounted project does
|
||||
not apply, because there is no mounted project, and a `git config` in
|
||||
here reaches no repository of theirs. What leaves this container is what
|
||||
you push and what you leave in ~/shared.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
_briefing_core_worktree() {
|
||||
[ "${DEJIMA_ISOLATED:-0}" = 0 ] || { _briefing_core_worktree_isolated; return 0; }
|
||||
cat <<'BRIEFING_EOF'
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
- The worktree MUST live under the mounted project directory itself, e.g.
|
||||
`git worktree add .worktrees/<branch> -b <branch>` run from the project
|
||||
root - never `../<branch>` or any other path outside it, including
|
||||
@@ -1980,6 +2056,10 @@ _briefing_resolve_key() {
|
||||
printf '%s' "GitHub read-only$hosts"
|
||||
fi
|
||||
;;
|
||||
# Both of these differ by whether this is an isolated launch, and the
|
||||
# prose for each case lives with the preamble it is substituted into.
|
||||
workspace_posture) _briefing_workspace_posture ;;
|
||||
project_posture) _briefing_project_posture ;;
|
||||
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
|
||||
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
@@ -2155,6 +2235,18 @@ _briefing_core_tools() {
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# What closes an isolated briefing, in place of the harness's own sentence
|
||||
# about resuming on the host.
|
||||
_briefing_core_isolated_close() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
This session cannot be resumed. Its transcript is inside the container and
|
||||
goes when the container does, so anything worth keeping has to leave before
|
||||
you finish: into ~/shared, and into a pushed branch where this session has a
|
||||
forge to push to at all. Say what you left there, rather than assuming the
|
||||
user can read it back out of the session afterwards.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
briefing_compose() {
|
||||
local forge="$1" workflow="$2" harness="$3"
|
||||
local caps autonomy workflow_frag harness_frag text
|
||||
@@ -2215,7 +2307,19 @@ briefing_compose() {
|
||||
# business resolving.
|
||||
text+="$(_briefing_core_closing_a)"
|
||||
text+=$'\n\n'
|
||||
text+="$harness_frag"
|
||||
# The harness fragment is about resuming this session on the host, which
|
||||
# for an isolated launch is the one thing it cannot do: the state that
|
||||
# resume reads is the state this mode does not mount. Replaced rather
|
||||
# than dropped, because a session told nothing would reasonably assume
|
||||
# the usual arrangement and leave work for a resume that never comes.
|
||||
#
|
||||
# The fragment is still composed and validated above, so a harness whose
|
||||
# own briefing is broken is refused in this mode too.
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
text+="$(_briefing_core_isolated_close)"
|
||||
else
|
||||
text+="$harness_frag"
|
||||
fi
|
||||
|
||||
_briefing_substitute "$text" "$forge" "$workflow" "$harness"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,27 @@ share_projects_root() { projects_root "$1"; }
|
||||
# The host directory mounted at ~/shared for a launch against $2.
|
||||
share_dir_for() { project_dir_for "$1" "$2"; }
|
||||
|
||||
# Where an isolated launch's ~/shared lives: a sibling of the per-project
|
||||
# root, holding one directory per SESSION rather than one per project.
|
||||
#
|
||||
# A sibling rather than a slug inside it because an isolated session has no
|
||||
# project to be keyed by, and the one thing this layout must never do is hand
|
||||
# a second session the directory a first one used. A name is not enough to
|
||||
# promise that, which is why the directory below is made rather than
|
||||
# computed.
|
||||
isolated_share_root() { printf '%s/isolated' "${1%/}"; }
|
||||
|
||||
# Makes this session's share directory under that root and prints its path.
|
||||
# Creating is the point: `mktemp -d` is what makes "no other session has this
|
||||
# directory" true for two launches in the same second, where a name built from
|
||||
# a timestamp would silently hand both of them one directory.
|
||||
isolated_share_make() {
|
||||
local root
|
||||
root="$(isolated_share_root "$1")"
|
||||
mkdir -p "$root" || return 1
|
||||
mktemp -d "$root/$(date -u +%Y%m%d-%H%M%S)-XXXXXX"
|
||||
}
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
#
|
||||
|
||||
+17
-2
@@ -84,9 +84,14 @@ tui_banner() {
|
||||
#
|
||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
|
||||
# better than arrow keys, and dejima-attach is the phone's entry point.
|
||||
# A third argument makes the prompt time out after that many seconds and
|
||||
# return 1 with no choice, for a caller that redraws and asks again. Kept here
|
||||
# rather than in the caller's own read so that there is one prompt, one range
|
||||
# check and one set of exits: a live list whose prompt behaved differently from
|
||||
# the static one is two front doors wearing the same colours.
|
||||
TUI_CHOICE=""
|
||||
tui_pick() {
|
||||
local count="$1" verb="$2" choice
|
||||
local count="$1" verb="$2" timeout="${3:-}" choice rc=0
|
||||
TUI_CHOICE=""
|
||||
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
|
||||
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
|
||||
@@ -94,7 +99,17 @@ tui_pick() {
|
||||
# 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
|
||||
#
|
||||
# A timed read separates the two: bash reports a timeout as a status above
|
||||
# 128 and an unreadable stdin as 1, so a front door with no terminal still
|
||||
# exits rather than spinning on a prompt nobody can answer.
|
||||
if [ -n "$timeout" ]; then
|
||||
read -r -t "$timeout" choice || rc=$?
|
||||
[ "$rc" -le 128 ] || return 1
|
||||
[ "$rc" -eq 0 ] || exit 0
|
||||
else
|
||||
read -r choice || exit 0
|
||||
fi
|
||||
case "$choice" in
|
||||
q|Q|'') exit 0 ;;
|
||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||
|
||||
+49
-2
@@ -58,6 +58,8 @@ esac
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||
# shellcheck source=dejima-version-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-version-lib.sh"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
|
||||
|
||||
@@ -110,6 +112,25 @@ _remote_tip() {
|
||||
printf '%s' "${out%%$'\t'*}"
|
||||
}
|
||||
|
||||
# The remote's newest release tag, or nothing. Same hardened invocation as
|
||||
# _remote_tip and for the same reasons; `--tags` with a pattern so a tag that
|
||||
# is not a release cannot become the answer.
|
||||
#
|
||||
# Newest by sort order, which for a date is newest in time - the whole reason
|
||||
# a release is a date rather than a number somebody increments.
|
||||
_remote_release() {
|
||||
local url="$1" out timeout=()
|
||||
command -v timeout >/dev/null 2>&1 && timeout=(timeout 5)
|
||||
out="$(env -u GIT_DIR -u GIT_WORK_TREE "${timeout[@]}" \
|
||||
git -C / \
|
||||
-c protocol.allow=never \
|
||||
-c protocol.https.allow=always \
|
||||
-c protocol.ssh.allow=always \
|
||||
-c protocol.file.allow=always \
|
||||
ls-remote --tags --refs "$url" '[0-9]*' 2>/dev/null)" || return 1
|
||||
printf '%s' "$(printf '%s\n' "$out" | sed -n 's#.*refs/tags/##p' | sort -V | tail -1)"
|
||||
}
|
||||
|
||||
# stderr dropped rather than shown: a config this cannot read is one bin/dejima
|
||||
# has already refused to start on, and a version check is not the place anyone
|
||||
# should first hear about it.
|
||||
@@ -138,11 +159,15 @@ _usable_url "$url" || exit 0
|
||||
|
||||
cache="$(_cache_file)"
|
||||
tip=""
|
||||
remote_release=""
|
||||
if [ -f "$cache" ]; then
|
||||
read -r c_branch c_tip < "$cache" 2>/dev/null || true
|
||||
# Three fields now; a cache written before the third existed reads as an
|
||||
# unknown release, which is the same answer a failed lookup gives.
|
||||
read -r c_branch c_tip c_release < "$cache" 2>/dev/null || true
|
||||
c_age=$(( $(date +%s) - $(stat -c %Y "$cache" 2>/dev/null || echo 0) ))
|
||||
if [ "${c_branch:-}" = "$upstream" ] && [ "$c_age" -lt "$CHECK_INTERVAL" ]; then
|
||||
tip="${c_tip:-}"
|
||||
case "${c_release:-}" in ""|"-") remote_release="" ;; *) remote_release="$c_release" ;; esac
|
||||
cached=1
|
||||
fi
|
||||
fi
|
||||
@@ -151,8 +176,14 @@ if [ -z "${cached:-}" ]; then
|
||||
# A failed lookup is recorded, not just skipped. Without that, a checkout
|
||||
# with no route to its remote pays the timeout on every single launch.
|
||||
tip="$(_remote_tip "$url" "$upstream")" || tip=""
|
||||
# Only when there is something to report: a checkout that is up to date
|
||||
# exits below without printing, and asking for the tags as well would be
|
||||
# a second request per launch for an answer nobody reads.
|
||||
if [ -n "$tip" ] && [ "$tip" != "$head_sha" ]; then
|
||||
remote_release="$(_remote_release "$url")" || remote_release=""
|
||||
fi
|
||||
mkdir -p "$(dirname "$cache")" 2>/dev/null \
|
||||
&& printf '%s %s\n' "$upstream" "${tip:--}" > "$cache" 2>/dev/null
|
||||
&& printf '%s %s %s\n' "$upstream" "${tip:--}" "${remote_release:--}" > "$cache" 2>/dev/null
|
||||
fi
|
||||
|
||||
# "-" is a recorded failure; empty is a branch the remote does not have.
|
||||
@@ -192,6 +223,22 @@ else
|
||||
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
|
||||
fi
|
||||
# Which release, when the remote has tags to say so. A commit count answers
|
||||
# "is there something newer" and nothing else; a release answers "how far
|
||||
# behind am I, and what would I be getting" - which is the question, and the
|
||||
# reason the changelog is named right here rather than left to be found.
|
||||
#
|
||||
# Both sides printed even when they name the same release: a checkout on
|
||||
# 2026.10.12 whose remote is also on 2026.10.12 is behind by commits not yet
|
||||
# released, and saying that is more honest than naming one version.
|
||||
if [ -n "$remote_release" ]; then
|
||||
_local_release="$(dejima_version "$REPO_ROOT")"
|
||||
printf ' %s is on %s%s%s; this checkout is on %s%s%s.\n' \
|
||||
"$_remote_shown" "$C_KEY" "$(sanitize "$remote_release")" "$C_RESET" \
|
||||
"$C_KEY" "$_local_release" "$C_RESET" >&2
|
||||
printf ' What each release changed: %s%s/CHANGELOG.md%s\n' \
|
||||
"$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
|
||||
fi
|
||||
# install.sh is safe to re-run, and a release can change what it links on PATH,
|
||||
# so pulling is only half the update. Both paths absolute because the operator
|
||||
# reading this is not necessarily standing in this checkout.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Shared library: which release a checkout is on. Sourced by bin/dejima (for
|
||||
# --version and the startup banner) and bin/dejima-update-check (to say what
|
||||
# the remote has that this does not). Not on PATH and not executable.
|
||||
#
|
||||
# A release is a date - 2026.10.12 - and the git tag on `main` is where it
|
||||
# lives. Nothing in the tree records it, which is the point: a VERSION file
|
||||
# says what was released and goes on saying it while a checkout is pulled
|
||||
# past that release, and the one moment the answer matters is the moment it
|
||||
# would be wrong. `git describe` cannot drift from history.
|
||||
#
|
||||
# A date rather than a version number because the question an operator has is
|
||||
# how old theirs is, and because MAJOR.MINOR would be a promise about
|
||||
# compatibility and patch lines that SECURITY.md explicitly does not make:
|
||||
# the tip of `main` is the only supported version, and a tag is a record of
|
||||
# what shipped rather than something to stay on.
|
||||
|
||||
# The release this checkout is on, as one word.
|
||||
#
|
||||
# 2026.10.12 exactly that release
|
||||
# 2026.10.12+37 thirty-seven commits past it, so on no release
|
||||
# unreleased a git checkout with no release tag yet
|
||||
# unknown no git at all, e.g. an unpacked tarball
|
||||
#
|
||||
# A second release on one day is 2026.10.12b - see docs/release.md - which
|
||||
# needs nothing here: the match below accepts it and sort -V orders it after
|
||||
# the bare date.
|
||||
#
|
||||
# Local-only git, so none of the transport configuration dejima-update-check
|
||||
# has to work around is in the path here.
|
||||
dejima_version() { # <repo root>
|
||||
local root="$1" tag n
|
||||
git -C "$root" rev-parse --git-dir >/dev/null 2>&1 || { printf 'unknown'; return 0; }
|
||||
# Only tags that look like a release. A tag someone pushed for anything
|
||||
# else must not become the answer to "which release is this".
|
||||
tag="$(git -C "$root" describe --tags --abbrev=0 --match '[0-9]*' 2>/dev/null)" || tag=""
|
||||
[ -n "$tag" ] || { printf 'unreleased'; return 0; }
|
||||
n="$(git -C "$root" rev-list --count "$tag..HEAD" 2>/dev/null)" || n=0
|
||||
case "${n:-0}" in
|
||||
0|"") printf '%s' "$tag" ;;
|
||||
*) printf '%s+%s' "$tag" "$n" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The release part alone, for comparing two of them. `2026.10.12+37` and
|
||||
# `2026.10.12` are the same release; what differs is whether the checkout
|
||||
# stopped there.
|
||||
dejima_version_release() { # <version>
|
||||
printf '%s' "${1%%+*}"
|
||||
}
|
||||
@@ -13,14 +13,14 @@
|
||||
# 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.
|
||||
# No Notification handler, 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
|
||||
@@ -40,7 +40,13 @@ DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
|
||||
harness_register_notify() {
|
||||
local settings="$1" tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
|
||||
# UserPromptSubmit is the complement of Stop, and it is registered for the
|
||||
# level alone: the hook writes no notification edge for `running`, so this
|
||||
# adds nothing to the phone. What it adds is the difference between "this
|
||||
# session has stopped" and "nothing has been heard from this session",
|
||||
# which a picker cannot tell apart from the Stop event by itself.
|
||||
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])
|
||||
| .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh running"}]}])' \
|
||||
"$settings" > "$tmp"; then
|
||||
mv "$tmp" "$settings"
|
||||
else
|
||||
|
||||
+55
-13
@@ -133,18 +133,23 @@ _cline_host_allowlisted() {
|
||||
#
|
||||
# $1 is the override file core owns for harnesses that take one on the command
|
||||
# line. Cline does not - it reads a JSON file at a fixed place inside its data
|
||||
# directory - so that argument is unused here and the file is written into the
|
||||
# state directory instead, before it is mounted. Returning nonzero stops the
|
||||
# directory - so that argument is unused here. $2 is the per-run staging
|
||||
# directory, which is where this writes instead. Returning nonzero stops the
|
||||
# launch.
|
||||
#
|
||||
# Rewritten every launch on purpose. A session can edit this file (it is inside
|
||||
# a read-write mount, and Cline rewrites it itself on its own schedule anyway),
|
||||
# so it is not a control while a session is running - the firewall is what
|
||||
# bounds where a repointed endpoint could reach. What rewriting buys is that a
|
||||
# repointing cannot persist into the next session.
|
||||
# Written per run and seeded in, not written where it will live. A session can
|
||||
# edit the installed copy - Cline rewrites it on its own schedule anyway - so
|
||||
# it is not a control while a session is running; the firewall is what bounds
|
||||
# where a repointed endpoint could reach. What this buys is that a repointing
|
||||
# cannot persist into the next session, which writing into the mounted state
|
||||
# directory only approximated by rewriting the file each launch.
|
||||
harness_prepare_config() {
|
||||
local state model settings
|
||||
state="$(_cline_state_dir "$WORKSPACE_DIR")" || {
|
||||
local model settings
|
||||
# Checked, not used: this function no longer writes into the state
|
||||
# directory, but a launch with no state_dir has nowhere to keep Cline's
|
||||
# state at all and the resume command in the briefing has no path to
|
||||
# name. Refusing here is where an operator can still act on it.
|
||||
_cline_state_dir "$WORKSPACE_DIR" >/dev/null || {
|
||||
echo "harness.cline: state_dir is not set, so there is nowhere to keep" >&2
|
||||
echo "this project's Cline state. Set it in [harness.cline]." >&2
|
||||
return 1
|
||||
@@ -260,11 +265,34 @@ harness_prepare_config() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
settings="$state/settings"
|
||||
# Into the per-run staging directory core hands this hook, not into the
|
||||
# state directory on the host. Two things follow, and the second is why
|
||||
# --isolated works for this harness at all:
|
||||
#
|
||||
# The file is seeded rather than mounted, so entrypoint.sh installs a copy
|
||||
# the session owns and nothing Cline writes to it reaches the host. That
|
||||
# is what the comment above this function wanted - a repointing cannot
|
||||
# persist into the next session - and writing into the mounted state
|
||||
# directory only approximated it by rewriting the file each launch.
|
||||
#
|
||||
# And it no longer needs the state mount to arrive. An isolated launch
|
||||
# mounts no host state, which used to mean this file was written to a host
|
||||
# directory keyed on the container-local working directory and then not
|
||||
# mounted: Cline started with no model and fell back to its own hosted
|
||||
# endpoint, which the firewall was never asked to allow (#393).
|
||||
_CLINE_PROVIDERS_FILE=""
|
||||
settings="${2:-}"
|
||||
[ -n "$settings" ] || {
|
||||
echo "harness.cline: core passed no staging directory, so there is" >&2
|
||||
echo "nowhere to put this launch's provider configuration." >&2
|
||||
return 1
|
||||
}
|
||||
settings="$settings/cline-providers"
|
||||
mkdir -p "$settings" || return 1
|
||||
# 0700: the placeholder is not a secret, but this directory is where a real
|
||||
# one would land if anything ever wrote one here by mistake.
|
||||
chmod 700 "$state" 2>/dev/null || true
|
||||
# 0700 on the staging directory, for the reason the state directory had
|
||||
# it: the placeholder is not a secret, but this is where a real one would
|
||||
# land if anything ever wrote one here by mistake.
|
||||
chmod 700 "$settings" 2>/dev/null || true
|
||||
|
||||
# Cline DISCARDS a provider entry with no updatedAt and falls back to its
|
||||
# own hosted endpoint, silently - the run reports provider "cline" and a
|
||||
@@ -324,6 +352,10 @@ harness_prepare_config() {
|
||||
fi || { rm -f "$tmp"; return 1; }
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$settings/providers.json"
|
||||
# Read by harness_seed below, which runs again after this hook for exactly
|
||||
# this: a hook whose job is to produce a per-run file has to run before
|
||||
# the list that mounts it.
|
||||
_CLINE_PROVIDERS_FILE="$settings/providers.json"
|
||||
}
|
||||
|
||||
# The per-project state this harness keeps on the host.
|
||||
@@ -413,6 +445,16 @@ harness_staged_config() {
|
||||
# servers, which are commands cline executes: a session that could write the
|
||||
# host's copy would be choosing what runs there next time.
|
||||
harness_seed() {
|
||||
# This launch's provider configuration, which harness_prepare_config
|
||||
# wrote into the staging directory. Seeded rather than mounted so the
|
||||
# session owns the copy it edits and nothing it writes reaches the host -
|
||||
# and so it arrives without the state mount, which an isolated launch
|
||||
# does not have (#393).
|
||||
#
|
||||
# Empty until that hook has run. Core loads this list twice for exactly
|
||||
# that reason, and the first pass simply has nothing to emit here.
|
||||
[ -z "${_CLINE_PROVIDERS_FILE:-}" ] \
|
||||
|| emit_seed_from "$_CLINE_PROVIDERS_FILE" ".cline/data/settings/providers.json"
|
||||
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
|
||||
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
|
||||
}
|
||||
|
||||
@@ -230,6 +230,14 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
chown $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh
|
||||
|
||||
# The working directory an isolated session gets instead of a project mount.
|
||||
# Outside $SANDBOX_HOME on purpose: bin/dejima still reads this path on the
|
||||
# HOST for a few things (an origin URL, a self-hosting check), so a path the
|
||||
# operator might plausibly have - ~/work - would have those read a real
|
||||
# directory of theirs. Nothing on a host is at /dejima.
|
||||
RUN mkdir -p /dejima/work && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /dejima
|
||||
|
||||
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
|
||||
# since they're mounted as named volumes in docker-compose.yml: Docker
|
||||
# seeds a fresh named volume from whatever already exists at the mount
|
||||
@@ -324,6 +332,9 @@ COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
|
||||
# nothing until something puts them on PATH.
|
||||
COPY docker/git-guard.sh /usr/local/lib/dejima/git-guard.sh
|
||||
COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
|
||||
# The policy both of those apply, and that the two hooks beside them apply to
|
||||
# the same command from the other side. One copy, so a bypass is fixed once.
|
||||
COPY docker/guard-lib.sh /usr/local/lib/dejima/guard-lib.sh
|
||||
# The briefing renderer, which every harness needs and no harness owns. Baked
|
||||
# in rather than reached through the ~/.claude/hooks mount, because that mount
|
||||
# is Claude Code's configuration: sandbox-context.sh calls this, and so does
|
||||
|
||||
@@ -157,7 +157,13 @@ services:
|
||||
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
|
||||
# itself for github.com over HTTPS - see the firewall/Dockerfile for
|
||||
# why that's the only path GitHub is reachable through at all.
|
||||
GH_TOKEN: ${GH_TOKEN:-}
|
||||
#
|
||||
# bin/dejima's SANDBOX_ prefixed copy rather than GH_TOKEN itself,
|
||||
# because the filter container needs that one for the authenticated
|
||||
# ranges fetch whatever the session is trusted with, and an isolated
|
||||
# launch is trusted with less. Empty is an ordinary state here: it is
|
||||
# what a host that configured no such token has always had.
|
||||
GH_TOKEN: ${SANDBOX_GH_TOKEN-}
|
||||
# Points git at the gitconfig copy mounted below instead of the
|
||||
# default ~/.gitconfig - see that mount's comment for why it can't
|
||||
# just live at ~/.gitconfig directly.
|
||||
@@ -225,7 +231,16 @@ services:
|
||||
# fixed /workspace) so Claude Code's project/session-history keying
|
||||
# (based on cwd) lines up with the host, letting a session started
|
||||
# here be resumed on the host with `claude --resume`.
|
||||
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
||||
#
|
||||
# One variable holding the whole spec rather than a source and a target,
|
||||
# because `--isolated` has to mount no host path here at all and an
|
||||
# entry in this list cannot be left empty. There it names the
|
||||
# isolated-work volume below, which bin/dejima gives this run's own name
|
||||
# and removes afterwards - the same rule as the other four, rather than
|
||||
# an anonymous volume whose reaping depends on what `compose run --rm`
|
||||
# does with one. bin/dejima always sets it: ordinarily to the project
|
||||
# directory at its own path, for the keying reason above.
|
||||
- ${SANDBOX_WORKSPACE_MOUNT:?set by bin/dejima - the project directory, or a container-local path for --isolated}
|
||||
# ~/.claude is enumerated, never mounted whole. A single-file bind
|
||||
# mount is pinned to the dentry at its path, so a host-side rename -
|
||||
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
@@ -289,7 +304,10 @@ services:
|
||||
- nvim-state:${HOME}/.local/state/nvim
|
||||
- nvim-cache:${HOME}/.cache/nvim
|
||||
# Required only if SANDBOX_SSH_KEY isn't set - bin/dejima
|
||||
# itself enforces that at least one of the two is present.
|
||||
# itself enforces that at least one of the two is present, and blanks
|
||||
# SSH_AUTH_SOCK when the key is the one chosen, so this mounts nothing
|
||||
# then. A forwarded agent left here signs with the operator's personal
|
||||
# identities for anything in the container that names this path.
|
||||
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
|
||||
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
|
||||
# Dedicated, purpose-built key for headless git push - see README
|
||||
@@ -316,7 +334,18 @@ services:
|
||||
volumes:
|
||||
# Shell history, shared by every harness - it is the container's shell that
|
||||
# writes it, not the agent.
|
||||
#
|
||||
# Named like the three below so bin/dejima can give an `--isolated` launch
|
||||
# one of its own: this volume is shared by every project, so the default is
|
||||
# a session reading what the last one typed anywhere on the host.
|
||||
#
|
||||
# The default repeats the project name, which is not a typo. An entry with
|
||||
# no `name:` gets `<compose project>_<key>`, and the project here is
|
||||
# `dejima` while the key is `dejima-bashhistory` - so anything shorter is a
|
||||
# DIFFERENT volume, and every ordinary launch would start from an empty
|
||||
# history with the real one orphaned.
|
||||
dejima-bashhistory:
|
||||
name: ${SANDBOX_BASH_HISTORY:-dejima_dejima-bashhistory}
|
||||
# Named explicitly so bin/dejima can give each project its own set. These
|
||||
# persist across containers by design (a plugin install should not have to
|
||||
# happen every launch), which without the split meant one project's session
|
||||
@@ -326,6 +355,11 @@ volumes:
|
||||
#
|
||||
# The defaults reproduce the previous names exactly, so a `docker compose`
|
||||
# invocation that bypasses bin/dejima behaves as it did.
|
||||
# The working directory of an `--isolated` launch, which mounts no host
|
||||
# path there. Referenced only when the workspace mount above names it, so
|
||||
# an ordinary launch neither uses nor creates it.
|
||||
isolated-work:
|
||||
name: ${SANDBOX_ISOLATED_WORK:-dejima_isolated-work}
|
||||
nvim-data:
|
||||
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
|
||||
nvim-state:
|
||||
|
||||
+370
-41
@@ -126,6 +126,12 @@ INTERNAL_OK=${EGRESS_INTERNAL_OK:-}
|
||||
INTERNAL_OK_HINT=${EGRESS_INTERNAL_OK_HINT:-EGRESS_INTERNAL_OK}
|
||||
FORGE_SSH=${EGRESS_FORGE_SSH:-0}
|
||||
HOST_ADDR=${EGRESS_HOST_ADDR:-}
|
||||
# Which TCP ports the host entry opens. Empty - the default - opens none, so
|
||||
# the address is a known destination that is refused rather than an implicit
|
||||
# grant over everything the host publishes. `any` restores the unclamped rule,
|
||||
# explicitly. See the clamp below for why none is the right default.
|
||||
HOST_PORTS=${EGRESS_HOST_PORTS:-}
|
||||
HOST_CANDIDATES=${EGRESS_HOST_CANDIDATES:-}
|
||||
# Self-check probes. example.com is reserved by RFC 2606 for documentation and
|
||||
# resolves globally, which is what makes it a usable negative: if it does not
|
||||
# resolve here, the resolver is restricting names rather than simply broken.
|
||||
@@ -138,6 +144,9 @@ PROBE_AAAA_NAME=${EGRESS_PROBE_AAAA_NAME:-registry.npmjs.org}
|
||||
PROBE_ADDR4=${EGRESS_PROBE_ADDR4:-192.0.2.1}
|
||||
FETCH_ATTEMPTS=${EGRESS_FETCH_ATTEMPTS:-5}
|
||||
FETCH_TIMEOUT=${EGRESS_FETCH_TIMEOUT:-20}
|
||||
# The ceiling on one wait between attempts, so a Retry-After nobody expected
|
||||
# cannot turn a boot into an indefinite hang.
|
||||
FETCH_MAX_BACKOFF=${EGRESS_FETCH_MAX_BACKOFF:-60}
|
||||
|
||||
say() { printf '[egress-policy] %s\n' "$*" >&2; }
|
||||
die() { printf '[egress-policy] FAILED: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -179,6 +188,17 @@ need_tools() {
|
||||
# address can be in more than one class, and guessing would pick the wrong one
|
||||
# exactly when it matters.
|
||||
|
||||
# Which address set a class's answers go into. One definition, because the
|
||||
# resolver config is what fills a set and the verification is what reads it back
|
||||
# - and a check that looked in a different set from the one being filled would
|
||||
# pass while the class meant nothing.
|
||||
set_for_class() {
|
||||
case $1 in
|
||||
forge) printf 'fname_v4' ;;
|
||||
*) printf 'oname_v4' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
classify_entry() {
|
||||
local value=$1
|
||||
# An address or a prefix is permitted directly; a name is permitted by
|
||||
@@ -213,15 +233,23 @@ classify_entry() {
|
||||
}
|
||||
|
||||
record_entries() {
|
||||
local class=$1 raw=$2 value kind exemption
|
||||
local class=$1 raw=$2 value kind exemption ok_name
|
||||
for value in ${raw//,/ }; do
|
||||
value=${value%.}
|
||||
[ -n "$value" ] || continue
|
||||
kind=$(classify_entry "$value")
|
||||
exemption=strict
|
||||
case " ${INTERNAL_OK//,/ } " in
|
||||
*" $value "*) exemption=internal-ok ;;
|
||||
esac
|
||||
# Case-folded and dot-stripped on both sides, like build_entries below and
|
||||
# dejima_class_for/dejima_mark_internal on the launcher's side. Compared
|
||||
# verbatim, a pair the launcher had accepted was recorded strict here, and
|
||||
# the boot then refused the name's internal answer while telling the
|
||||
# operator to configure the exemption they had already configured.
|
||||
for ok_name in ${INTERNAL_OK//,/ }; do
|
||||
ok_name=${ok_name%.}
|
||||
if [ "${ok_name,,}" = "${value,,}" ]; then
|
||||
exemption=internal-ok
|
||||
fi
|
||||
done
|
||||
if [ "$exemption" = internal-ok ] && [ "$kind" = addr ]; then
|
||||
# Harmless, but say so: the check it exempts is a check on answers, and
|
||||
# an address the operator typed is not an answer.
|
||||
@@ -241,10 +269,33 @@ build_entries() {
|
||||
record_entries forge "$FORGE_ENTRIES $forge_host"
|
||||
record_entries open "$OPEN_ENTRIES"
|
||||
|
||||
# One line per name, forge class winning, because forge is recorded first and
|
||||
# this keeps the first occurrence.
|
||||
#
|
||||
# A name can reach both lists - release-assets.githubusercontent.com is an
|
||||
# open-class entry of this component's own and also sits under a forge suffix,
|
||||
# and any harness or backend domain under one would do the same. Two lines
|
||||
# meant two resolver lines naming different sets for one name, of which the
|
||||
# resolver honours one: the entries file and `status` could not say which
|
||||
# class was in force, and the check that reads an answer back out of its
|
||||
# class's set would look in the set that lost.
|
||||
#
|
||||
# Narrower wins, so a name that is forge class by any route keeps the port
|
||||
# restriction rather than inheriting the open class from a second mention.
|
||||
local deduped
|
||||
deduped=$(awk '!seen[$4]++' "$ENTRIES_FILE")
|
||||
printf '%s\n' "$deduped" > "$ENTRIES_FILE"
|
||||
|
||||
local value
|
||||
for value in ${INTERNAL_OK//,/ }; do
|
||||
[ -n "$value" ] || continue
|
||||
listed=$(awk -v v="${value%.}" '$4 == v' "$ENTRIES_FILE")
|
||||
# Case-folded on both sides, like dejima_class_for and dejima_mark_internal
|
||||
# on the launcher's side of this. DNS names are case-insensitive and the
|
||||
# resolver matches them so, and comparing verbatim here refused a launch
|
||||
# whose configuration was correct - the launcher's own check had already
|
||||
# passed on the same pair, so the refusal arrived from the wrong layer with
|
||||
# a message saying the name was permitted by nothing.
|
||||
listed=$(awk -v v="${value%.}" 'BEGIN { v = tolower(v) } tolower($4) == v' "$ENTRIES_FILE")
|
||||
[ -n "$listed" ] || missing+=("$value")
|
||||
done
|
||||
[ ${#missing[@]} -eq 0 ] \
|
||||
@@ -315,8 +366,20 @@ host_of_url() {
|
||||
# through the policy. The session container has not been started at this point -
|
||||
# the launcher waits for the readiness file - so the window is not one the agent
|
||||
# can be in.
|
||||
# The seconds the endpoint asked us to wait, if it asked. Header names are
|
||||
# case-insensitive, and the value is either a count of seconds or an HTTP date
|
||||
# - only the numeric form is honoured, because a date needs parsing to compare
|
||||
# against a clock and a wrong answer there is a sleep nobody chose.
|
||||
retry_after_from() {
|
||||
local v
|
||||
v=$(tr -d '\r' < "$1" 2>/dev/null \
|
||||
| awk -F': *' 'tolower($1) == "retry-after" { print $2; exit }') || return 1
|
||||
case "$v" in ''|*[!0-9]*) return 1 ;; esac
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
fetch_forge_ranges() {
|
||||
local out=$1 attempt rc http err
|
||||
local out=$1 attempt rc http err retry wait limited
|
||||
# Authenticated when the launcher supplied a token. The endpoint this
|
||||
# defaults to is rate-limited per source address, unauthenticated, across
|
||||
# everything on the host - so a host that starts sandboxes in a loop exhausts
|
||||
@@ -325,15 +388,38 @@ fetch_forge_ranges() {
|
||||
# the launcher's to choose.
|
||||
local -a auth=()
|
||||
[ -n "$FORGE_META_AUTH" ] && auth=(-H "$FORGE_META_AUTH")
|
||||
|
||||
# A file:// source is a test fixture and nothing else. test/runtime.bats
|
||||
# starts twenty-seven container pairs in one run, each of which fetched the
|
||||
# ranges for itself - which is what trips the endpoint's secondary limit
|
||||
# (#357) - so the suite fetches once and points every pair at the result.
|
||||
#
|
||||
# Refused outside a test container, because the ranges decide which addresses
|
||||
# the forge class reaches: a launch that could name its own source could name
|
||||
# its own ranges. SANDBOX_TEST_MODE is set by that suite and by nothing in
|
||||
# bin/dejima, so there is no launch this is reachable from.
|
||||
case "$FORGE_META_URL" in
|
||||
file://*)
|
||||
[ "${SANDBOX_TEST_MODE:-0}" = 1 ] \
|
||||
|| die "EGRESS_FORGE_META_URL names a file:// source, which is a test fixture only. The forge's address ranges decide what the forge class reaches, so they are not read from the filesystem in a real launch."
|
||||
;;
|
||||
esac
|
||||
|
||||
for ((attempt = 1; attempt <= FETCH_ATTEMPTS; attempt++)); do
|
||||
rc=0
|
||||
: > "$out.hdr"
|
||||
http=$(curl --silent --show-error --location \
|
||||
--max-time "$FETCH_TIMEOUT" \
|
||||
--write-out '%{http_code}' \
|
||||
--dump-header "$out.hdr" \
|
||||
--output "$out.part" \
|
||||
${auth[@]+"${auth[@]}"} \
|
||||
"$FORGE_META_URL" 2>"$out.err") || rc=$?
|
||||
if [ "$rc" -eq 0 ] && [ "$http" = 200 ]; then
|
||||
# curl reports no status for a scheme that has none, so what counts as a
|
||||
# good transfer is the scheme's own answer rather than 200 always.
|
||||
local want_http=200
|
||||
case "$FORGE_META_URL" in file://*) want_http=000 ;; esac
|
||||
if [ "$rc" -eq 0 ] && [ "$http" = "$want_http" ]; then
|
||||
# Whether the body carries ranges is part of whether the fetch worked,
|
||||
# so it is decided here rather than after the loop. A rate limit is the
|
||||
# reason: it answers with a message and no ranges, sometimes over a 200,
|
||||
@@ -365,12 +451,39 @@ fetch_forge_ranges() {
|
||||
# reason at all - which is the diagnostic this whole path exists to give.
|
||||
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
|
||||
[ -n "$err" ] || err="response carried no ranges"
|
||||
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: $err"
|
||||
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: http $http, $err"
|
||||
else
|
||||
err=$(tr '\n' ' ' < "$out.err")
|
||||
# The body first, because an HTTP failure from this endpoint carries one
|
||||
# and curl's stderr does not: "API rate limit exceeded" names the cause,
|
||||
# where "curl exit 0, http 403" names only the symptom. curl's own
|
||||
# message is what is left when there was no response to carry a body.
|
||||
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
|
||||
[ -n "$err" ] || err=$(tr '\n' ' ' < "$out.err")
|
||||
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: curl exit $rc, http ${http:-none}${err:+, }$err"
|
||||
fi
|
||||
[ "$attempt" -lt "$FETCH_ATTEMPTS" ] && sleep $((attempt * 2))
|
||||
|
||||
# A rate limit is the one failure here that clears by waiting, and the only
|
||||
# one worth waiting long for. GitHub reports a secondary limit as 403 with
|
||||
# a Retry-After, and a primary one as 403 or 429 - but it will also answer
|
||||
# 200 with a message and no ranges, so the body counts as evidence too.
|
||||
limited=0
|
||||
case "$http" in 403|429) limited=1 ;; esac
|
||||
case "${err,,}" in *"rate limit"*|*"abuse"*|*"too many requests"*) limited=1 ;; esac
|
||||
|
||||
# attempt*2 spends twenty seconds over five tries, and a secondary limit is
|
||||
# measured in tens of seconds - so the whole sequence could sit inside one
|
||||
# window and exhaust itself without ever reaching the other side (#357).
|
||||
wait=$((attempt * 2))
|
||||
[ "$limited" -eq 1 ] && wait=$((attempt * 15))
|
||||
# What the endpoint asked for beats what this guessed, which is the only
|
||||
# number here that is not an estimate.
|
||||
retry=$(retry_after_from "$out.hdr") && wait=$retry
|
||||
[ "$wait" -le "$FETCH_MAX_BACKOFF" ] || wait=$FETCH_MAX_BACKOFF
|
||||
if [ "$attempt" -lt "$FETCH_ATTEMPTS" ]; then
|
||||
say "waiting ${wait}s before attempt $((attempt + 1))${retry:+ (Retry-After: ${retry}s)}"
|
||||
sleep "$wait"
|
||||
fi
|
||||
retry=""
|
||||
done
|
||||
die "could not fetch the forge address ranges after $FETCH_ATTEMPTS attempts; the reason for each is above. The sandbox cannot start without them - the policy would refuse every forge range, so git, gh and the API would all fail once inside."
|
||||
}
|
||||
@@ -407,6 +520,90 @@ emit_forge_clamp() {
|
||||
printf ' %s daddr @%s goto refuse\n' "$family" "$set"
|
||||
}
|
||||
|
||||
# The host entry is the one destination nobody declared: it is the gateway of
|
||||
# the default route, derived rather than configured. Docker publishes container
|
||||
# ports on that address, so permitting it on every port reached the host's sshd,
|
||||
# anything bound to 0.0.0.0, and every published port of every other container
|
||||
# on the machine - including the remote-access front door dejima itself ships,
|
||||
# which is a host shell behind a tmux prefix key (#363).
|
||||
#
|
||||
# Nothing in a launch needs it. A host-resident forge, Loki or self-served model
|
||||
# is reached through its own entry, by name or as a literal address, and either
|
||||
# way lands in a set of its own. So the default here is no ports at all, and the
|
||||
# address stays a named destination that is refused rather than one that falls
|
||||
# through to the end of the chain - the counter says the host was reached for,
|
||||
# which an operator debugging a clamp needs to see.
|
||||
#
|
||||
# TCP only: nothing here has ever permitted UDP to the host, and DNS to it is
|
||||
# refused above with everyone else's.
|
||||
emit_host_clamp() {
|
||||
local set=$1 p list=""
|
||||
if [ "$HOST_PORTS" = any ]; then
|
||||
printf ' ip daddr @%s goto permit\n' "$set"
|
||||
return
|
||||
fi
|
||||
# Rebuilt element by element rather than by substituting the separators: the
|
||||
# value may be comma-separated, space-separated or both, and a substitution
|
||||
# turns ", " into an empty element that nft rejects.
|
||||
for p in ${HOST_PORTS//,/ }; do
|
||||
list="${list:+$list, }$p"
|
||||
done
|
||||
if [ -n "$list" ]; then
|
||||
printf ' ip daddr @%s tcp dport { %s } goto permit\n' "$set" "$list"
|
||||
fi
|
||||
printf ' ip daddr @%s goto refuse\n' "$set"
|
||||
}
|
||||
|
||||
# A typo stops the container rather than becoming a rule that permits the wrong
|
||||
# port or a ruleset nft refuses after the fact, which is the same reasoning as
|
||||
# classify_entry's: a destination that looks configured and is silently refused
|
||||
# is the worst of the three.
|
||||
# The ports of destinations the launcher configured AT the host's own address.
|
||||
#
|
||||
# The launcher knows which destinations this launch configured and cannot know
|
||||
# the host's address; this process works that out from the default route a few
|
||||
# lines below. So it passes candidates - a literal address with an explicit
|
||||
# port, each one it is sure of - and the decision is made here, where both
|
||||
# halves are known. Nothing resolves a name on either side: a candidate whose
|
||||
# address is not the host's is simply not the host's.
|
||||
#
|
||||
# Without this the clamp, which is matched before every wider rule, answers
|
||||
# `ports none` for a host-resident Loki or self-served model at the gateway
|
||||
# address - working before only because the open-class permit answered first.
|
||||
fold_host_candidates() { # <host address>...
|
||||
local cand addr port a
|
||||
[ -n "$HOST_CANDIDATES" ] || return 0
|
||||
# `any` is already wider than anything a candidate could add.
|
||||
[ "$HOST_PORTS" = any ] && return 0
|
||||
for cand in $HOST_CANDIDATES; do
|
||||
addr=${cand%:*}
|
||||
port=${cand##*:}
|
||||
case "$port" in
|
||||
""|*[!0-9]*) die "EGRESS_HOST_CANDIDATES entry '$cand' has no TCP port" ;;
|
||||
esac
|
||||
for a in "$@"; do
|
||||
[ "$addr" = "$a" ] || continue
|
||||
case ",${HOST_PORTS}," in
|
||||
*",$port,"*) ;;
|
||||
*) HOST_PORTS="${HOST_PORTS:+$HOST_PORTS,}$port" ;;
|
||||
esac
|
||||
break
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
validate_host_ports() {
|
||||
local p
|
||||
[ "$HOST_PORTS" = any ] && return 0
|
||||
for p in ${HOST_PORTS//,/ }; do
|
||||
[[ $p =~ ^[0-9]+(-[0-9]+)?$ ]] \
|
||||
|| die "EGRESS_HOST_PORTS entry '$p' is not a TCP port or a low-high range (or the whole value 'any')"
|
||||
local lo=${p%%-*} hi=${p##*-}
|
||||
{ [ "$lo" -ge 1 ] && [ "$hi" -le 65535 ] && [ "$lo" -le "$hi" ]; } \
|
||||
|| die "EGRESS_HOST_PORTS entry '$p' is not within 1-65535, low first"
|
||||
done
|
||||
}
|
||||
|
||||
# Rules are generated into one file and loaded with `nft -f`, which is a single
|
||||
# transaction: the namespace is never left half-filtered. The table is replaced
|
||||
# rather than the ruleset flushed - the runtime has its own rules in this
|
||||
@@ -609,13 +806,28 @@ EOF
|
||||
|
||||
cat <<EOF
|
||||
|
||||
# The container's own host, as a single address, on the ports it was told
|
||||
# to open and no others.
|
||||
#
|
||||
# Before the open class, not after it, because this is the narrower rule
|
||||
# and the first match wins. The host's address reaches literal_v4 by
|
||||
# several ordinary routes - a host-resident LOKI_URL, a self-served
|
||||
# model's base_url, \`dejima-allow <gateway>\` - and from there the open
|
||||
# permit below answered for it first, on every port, including sshd and
|
||||
# the remote-access front door. Configuring one host service opens that
|
||||
# service, which is what docs/security.md says it does.
|
||||
EOF
|
||||
emit_host_clamp host_v4
|
||||
|
||||
cat <<EOF
|
||||
|
||||
# The open class: names the launcher permitted and addresses it supplied,
|
||||
# on any port. DNS to them is refused above along with everyone else's.
|
||||
ip daddr @oname_v4 goto permit
|
||||
ip daddr @literal_v4 goto permit
|
||||
EOF
|
||||
|
||||
# The container's own host, as a single address.
|
||||
ip daddr @host_v4 goto permit
|
||||
cat <<EOF
|
||||
|
||||
# Anything else: logged, then refused. The chain policy is drop as a
|
||||
# backstop, but nothing should ever reach it.
|
||||
@@ -704,10 +916,7 @@ EOF
|
||||
|
||||
# Which set the answers go into is the entry's class, stated in the entries
|
||||
# file rather than worked out from the addresses that come back.
|
||||
case $class in
|
||||
forge) sets="4#inet#$TABLE#fname_v4" ;;
|
||||
*) sets="4#inet#$TABLE#oname_v4" ;;
|
||||
esac
|
||||
sets="4#inet#$TABLE#$(set_for_class "$class")"
|
||||
if [ "$exemption" = internal-ok ]; then
|
||||
sets="$sets,4#inet#$TABLE#internal_ok_v4"
|
||||
printf 'rebind-domain-ok=/%s/\n' "$name" >> "$RESOLVER_CONF"
|
||||
@@ -732,9 +941,21 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# The forge credential, removed from a daemon's environment at the point it
|
||||
# starts rather than somewhere upstream of it. All three of these parse content
|
||||
# the session's own traffic decides, and all three are reachable by more than
|
||||
# one call path: `dejima-allow` runs `egress-policy permit` through `docker
|
||||
# exec`, which gets the filter container's configured environment, and both
|
||||
# permit_name and rollback_entries restart the resolver. An unset earlier in
|
||||
# one path says nothing about the others; this says it where it has to hold.
|
||||
#
|
||||
# `env -u` rather than an unset, so the scrub is visible on the line that
|
||||
# starts the daemon and cannot be separated from it by an edit elsewhere.
|
||||
readonly NO_CREDENTIAL=(env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH)
|
||||
|
||||
start_resolver() {
|
||||
stop_daemon "$RESOLVER_PID" dnsmasq
|
||||
dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
|
||||
"${NO_CREDENTIAL[@]}" dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
|
||||
wait_for_daemon dnsmasq
|
||||
|
||||
# Waiting for the process is not waiting for the resolver. dnsmasq
|
||||
@@ -823,7 +1044,7 @@ start_connection_log() {
|
||||
stop_daemon "$ULOGD_PID" ulogd
|
||||
id -u "$LOGGER_USER" >/dev/null 2>&1 \
|
||||
|| die "the image did not create the $LOGGER_USER account, which the connection logger drops to"
|
||||
ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
|
||||
"${NO_CREDENTIAL[@]}" ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
|
||||
|| die "the connection logger did not start"
|
||||
wait_for_daemon ulogd
|
||||
}
|
||||
@@ -846,7 +1067,7 @@ start_lookup_log() {
|
||||
# resolver's log - and that is the last thing in this container that should
|
||||
# be running with privilege. It opens two files and then only reads and
|
||||
# writes them, so the drop costs it nothing.
|
||||
nohup gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
|
||||
nohup "${NO_CREDENTIAL[@]}" gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
|
||||
>/dev/null 2>>"$LOGDIR/lookup-log.err" &
|
||||
echo $! > "$LOOKUP_PID"
|
||||
sleep 0.3
|
||||
@@ -914,8 +1135,31 @@ retry_positive() {
|
||||
# Ordered so that a failure names its own cause: the positive checks come
|
||||
# first, because "a blocked destination was refused" means nothing until
|
||||
# something that should work has been shown to work.
|
||||
# Which permitted name the resolver checks are about.
|
||||
#
|
||||
# Normally the meta URL's host, which is a permitted forge-class name by
|
||||
# construction. A file:// source has no host at all - that is the suite's
|
||||
# cached-ranges fixture (#357) - and handing an empty name to check 3 asked the
|
||||
# resolver to look up "" and failed the boot on it.
|
||||
#
|
||||
# The fallback is a permitted forge-class name, because the property those
|
||||
# checks state is about the POLICY: that the resolver resolves a permitted name
|
||||
# and files its address in that name's own set. Where the ranges were read from
|
||||
# is a different question, and check 5's.
|
||||
#
|
||||
# sed rather than head: a producer whose pipe closes early takes SIGPIPE, and
|
||||
# pipefail makes that the pipeline's status - see the note on grep -q above.
|
||||
verify_subject() {
|
||||
local host=$1
|
||||
if [ -n "$host" ]; then
|
||||
printf '%s' "$host"
|
||||
return 0
|
||||
fi
|
||||
entries_of forge name | sed -n '1p'
|
||||
}
|
||||
|
||||
verify() {
|
||||
local forge_host=$1 rc answer set_name
|
||||
local forge_host=$1 rc answer set_name subject
|
||||
|
||||
pgrep -x dnsmasq >/dev/null || die "check 1: the resolver is not running"
|
||||
pgrep -x ulogd >/dev/null || die "check 1: the connection logger is not running"
|
||||
@@ -929,23 +1173,45 @@ verify() {
|
||||
[ "$running_as" = "$RESOLVER_USER" ] \
|
||||
|| die "check 2: the resolver is running as '${running_as:-unknown}', not $RESOLVER_USER, so the rule permitting it to reach an upstream resolver is keyed to the wrong account"
|
||||
|
||||
answer=$(retry_positive 3 resolve_one "$forge_host") || true
|
||||
subject=$(verify_subject "$forge_host")
|
||||
[ -n "$subject" ] \
|
||||
|| die "check 3: there is no permitted forge-class name to check the resolver against, and no host in $FORGE_META_URL to fall back on"
|
||||
|
||||
answer=$(retry_positive 3 resolve_one "$subject") || true
|
||||
[ -n "$answer" ] \
|
||||
|| die "check 3: the permitted name $forge_host did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$forge_host")"
|
||||
|| die "check 3: the permitted name $subject did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$subject")"
|
||||
|
||||
# The resolver is also what puts a permitted name's addresses into the
|
||||
# allow-list, and which set it uses is the name's class. Checking membership
|
||||
# of the class set proves both halves of that wiring; without it a name could
|
||||
# resolve and still be unreachable, or be reachable in the wrong class.
|
||||
set_name=fname_v4
|
||||
# proves both halves of that wiring; without it a name could resolve and still
|
||||
# be unreachable, or be reachable in the wrong class.
|
||||
#
|
||||
# Here as well as in check 7, which asserts the same property of every
|
||||
# permitted name: this one runs before the connectivity checks, so the
|
||||
# resolver being wired to the wrong set is named as itself rather than
|
||||
# reported later as a destination that would not connect. Same helper, so the
|
||||
# two cannot look in different sets.
|
||||
set_name=$(set_for_class forge)
|
||||
nft get element inet "$TABLE" "$set_name" "{ $answer }" >/dev/null 2>&1 \
|
||||
|| die "check 4: $forge_host resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
|
||||
|| die "check 4: $subject resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
|
||||
|
||||
rc=0
|
||||
retry_positive 3 curl --silent --show-error --output /dev/null \
|
||||
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
|
||||
[ "$rc" -eq 0 ] \
|
||||
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
|
||||
# Skipped for a file:// source, and said out loud. This check asserts that a
|
||||
# permitted destination is reachable THROUGH the policy, and a local file is
|
||||
# not one - reaching it would pass while proving nothing. That is a real
|
||||
# reduction on the runs using the cached ranges, which is why it is a line in
|
||||
# the log rather than a silent pass.
|
||||
case "$FORGE_META_URL" in
|
||||
file://*)
|
||||
say "check 5 skipped: the ranges came from a file, so reading it again says nothing about what the policy permits"
|
||||
;;
|
||||
*)
|
||||
rc=0
|
||||
retry_positive 3 curl --silent --show-error --output /dev/null \
|
||||
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
|
||||
[ "$rc" -eq 0 ] \
|
||||
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# The same address, on the port the opt-in gates. With the opt-in off it must
|
||||
# be refused even though the address was reached by name, which is the whole
|
||||
@@ -1090,14 +1356,35 @@ verify_ipv6_refused() {
|
||||
# refusing to start a session over a host that happens to be down would cost more
|
||||
# than it prevents.
|
||||
check_permitted_names() {
|
||||
local label=$1 class kind exemption name mark refused=() silent=()
|
||||
local label=$1 class kind exemption name mark refused=() silent=() stranded=()
|
||||
local addr want
|
||||
mark=$(wc -c < "$RESOLVER_LOG")
|
||||
# Resolving every permitted name also leaves its current addresses in the
|
||||
# allow-list before the session container is started, so the first thing the
|
||||
# agent does is not waiting on a cold set.
|
||||
#
|
||||
# And every name that answers is checked into the set its class names, which
|
||||
# is the only place the whole chain is asserted end to end: the class the
|
||||
# entry was recorded with, the resolver line built from it, whether an answer
|
||||
# actually reaches a set, and whether it reaches the one the ports are keyed
|
||||
# to. Reading the code proves none of that - each half can be right about a
|
||||
# different set - and every defect this check would have caught got through a
|
||||
# green suite.
|
||||
while read -r class kind exemption name; do
|
||||
[ "$kind" = name ] || continue
|
||||
[ -n "$(resolve_one "$name")" ] || silent+=("$name")
|
||||
# `|| addr=""` and not a bare assignment: resolve_one returns non-zero for
|
||||
# a name with no address, which is an ordinary outcome here, and under
|
||||
# `set -e` the assignment alone would abort the policy - taking the
|
||||
# container down over a permitted name whose host happens to be unreachable,
|
||||
# which is exactly what the note below exists to avoid.
|
||||
addr=$(resolve_one "$name") || addr=""
|
||||
if [ -z "$addr" ]; then
|
||||
silent+=("$name")
|
||||
continue
|
||||
fi
|
||||
want=$(set_for_class "$class")
|
||||
nft get element inet "$TABLE" "$want" "{ $addr }" >/dev/null 2>&1 \
|
||||
|| stranded+=("$name -> $addr (class $class, expected in $want)")
|
||||
done < "$ENTRIES_FILE"
|
||||
|
||||
# Only the lines this sweep produced: the log outlives a previous apply in the
|
||||
@@ -1111,6 +1398,13 @@ check_permitted_names() {
|
||||
fi
|
||||
done < "$ENTRIES_FILE"
|
||||
|
||||
# Fatal, unlike a name that did not answer. That one is the world being
|
||||
# unavailable; this one is the policy's own wiring disagreeing with itself,
|
||||
# and the session it would produce reaches a destination it was configured to
|
||||
# permit only by accident, or not at all.
|
||||
if [ ${#stranded[@]} -ne 0 ]; then
|
||||
die "$label: these permitted names resolved and their addresses did not reach the set their class names: ${stranded[*]}. A session would resolve them and then be refused, or reach them in the wrong class - which decides the ports. Either the entry's class and the resolver line built from it disagree, or the resolver is not populating that set."
|
||||
fi
|
||||
if [ ${#refused[@]} -ne 0 ]; then
|
||||
die "$label: these permitted names are answered with an address inside a private network, and this policy refuses such answers: ${refused[*]}. That is this configuration disagreeing with itself - list them in $INTERNAL_OK_HINT if pointing inside is intended, or remove them."
|
||||
fi
|
||||
@@ -1170,7 +1464,12 @@ resolve_one() {
|
||||
local answers line
|
||||
# A only: the resolver is configured to answer no AAAA at all, so asking for
|
||||
# one spends a query to be told nothing.
|
||||
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 0
|
||||
# Non-zero when dig itself failed, not zero: that is a lookup which did not
|
||||
# come back - a resolver a moment behind the container is exactly it - and so
|
||||
# the one case the positive checks' retry exists to absorb. Returning 0 there
|
||||
# reported success with no address, which retry_positive takes as an answer
|
||||
# and the caller then dies on (#345).
|
||||
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 1
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
|
||||
printf '%s\n' "$line"
|
||||
@@ -1233,7 +1532,14 @@ cmd_apply() {
|
||||
discover_upstreams
|
||||
|
||||
local host_addrs forge_host
|
||||
# Discover, then fold the launcher's candidates in, then validate the whole
|
||||
# list: the candidates' ports become part of what the clamp permits, so they
|
||||
# are held to the same check rather than trusted for having crossed a
|
||||
# process boundary.
|
||||
host_addrs=$(discover_host_addr)
|
||||
# shellcheck disable=SC2086
|
||||
fold_host_candidates $host_addrs
|
||||
validate_host_ports
|
||||
forge_host=$(host_of_url "$FORGE_META_URL")
|
||||
|
||||
build_entries "$forge_host"
|
||||
@@ -1247,6 +1553,11 @@ cmd_apply() {
|
||||
|
||||
local meta="$RUNDIR/forge-meta.json"
|
||||
fetch_forge_ranges "$meta"
|
||||
# The one request that needed the credential has been made, so it stops here
|
||||
# for the rest of this process too. Each daemon start scrubs its own
|
||||
# environment as well (NO_CREDENTIAL), because `permit` reaches them without
|
||||
# ever coming through here.
|
||||
unset FORGE_META_AUTH EGRESS_FORGE_META_AUTH
|
||||
|
||||
local ranges forge4
|
||||
ranges=$(jq -r "$FORGE_META_FILTER" < "$meta" | grep -v '^null$' | grep -v '^$') \
|
||||
@@ -1266,7 +1577,7 @@ cmd_apply() {
|
||||
resolver_uid=$(id -u "$RESOLVER_USER")
|
||||
|
||||
apply_ruleset "$forge4" "$host_addrs" "$resolver_uid"
|
||||
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs), forge ssh=$FORGE_SSH"
|
||||
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs) on ports ${HOST_PORTS:-none}, forge ssh=$FORGE_SSH"
|
||||
|
||||
write_log_config
|
||||
start_connection_log
|
||||
@@ -1353,13 +1664,26 @@ rollback_entries() {
|
||||
# An address needs no resolution and no restart: it goes straight into the sets
|
||||
# its class uses. Both sets are interval sets, so a prefix works as well as a
|
||||
# single address.
|
||||
# A forge-class address belongs in both sets, and the adds are not one
|
||||
# transaction: if the second fails, the first has to come back out. Otherwise
|
||||
# the address the forge class says is HTTPS-only is left in literal_v4, which
|
||||
# permits every port, while the entries file rolls back - so nothing reports a
|
||||
# grant that is live for the rest of the session.
|
||||
#
|
||||
# Deleting it is unconditional, which can take back an element that was in that
|
||||
# set from somewhere else. That direction is safe: the refusal already rolled
|
||||
# the record back, so a narrower live set agrees with what `entries` says.
|
||||
permit_address() {
|
||||
local class=$1 value=$2
|
||||
nft add element inet "$TABLE" literal_v4 "{ $value }" \
|
||||
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the allow-list"; }
|
||||
if [ "$class" = forge ]; then
|
||||
nft add element inet "$TABLE" forge_v4 "{ $value }" \
|
||||
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the forge class"; }
|
||||
nft add element inet "$TABLE" forge_v4 "{ $value }" || {
|
||||
nft delete element inet "$TABLE" literal_v4 "{ $value }" 2>/dev/null \
|
||||
|| say "WARNING: $value is still in the any-port allow-list and could not be removed; restart the session"
|
||||
mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"
|
||||
die "could not add $value to the forge class"
|
||||
}
|
||||
fi
|
||||
say "$value permitted ($class class, direct)"
|
||||
}
|
||||
@@ -1370,7 +1694,7 @@ permit_address() {
|
||||
# up in the set its class uses. A name that resolved but never reached the
|
||||
# allow-list would fail later as a mystery, so it is undone here instead.
|
||||
permit_name() {
|
||||
local class=$1 name=$2 set_prefix addr addrs ok='' mark
|
||||
local class=$1 name=$2 set_name addr addrs ok='' mark
|
||||
|
||||
write_resolver_config
|
||||
mark=$(wc -c < "$RESOLVER_LOG")
|
||||
@@ -1390,13 +1714,18 @@ permit_name() {
|
||||
die "$name returned no address - the name may not exist, or its upstream may be unreachable. Nothing was changed."
|
||||
fi
|
||||
|
||||
[ "$class" = forge ] && set_prefix=fname || set_prefix=oname
|
||||
# Through the same helper as the config that fills the set and the check that
|
||||
# reads it back at boot. This was the fourth place naming a set for a class,
|
||||
# and the one the helper's own comment claimed did not exist - a live add
|
||||
# looking in a different set from the one being filled would report a name as
|
||||
# unreachable while it was permitted, or the reverse.
|
||||
set_name=$(set_for_class "$class")
|
||||
for addr in $addrs; do
|
||||
nft get element inet "$TABLE" "${set_prefix}_v4" "{ $addr }" >/dev/null 2>&1 && ok=yes
|
||||
nft get element inet "$TABLE" "$set_name" "{ $addr }" >/dev/null 2>&1 && ok=yes
|
||||
done
|
||||
if [ -z "$ok" ]; then
|
||||
rollback_entries
|
||||
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached the ${set_prefix} sets; nothing was changed"
|
||||
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached $set_name; nothing was changed"
|
||||
fi
|
||||
say "$name permitted ($class class, $exemption): $(printf '%s ' $addrs)"
|
||||
}
|
||||
|
||||
+29
-25
@@ -44,7 +44,10 @@ fi
|
||||
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
|
||||
# write to it directly as the sandbox user, so fix the ownership up here,
|
||||
# before dropping privileges.
|
||||
if [ -t 1 ]; then
|
||||
# Both halves read stdin, because that is what `tty` reports on. Testing stdout
|
||||
# instead meant a container with one attached and the other not produced an
|
||||
# empty path and a chown that silently did nothing.
|
||||
if [ -t 0 ]; then
|
||||
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -70,6 +73,9 @@ fi
|
||||
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
|
||||
# can ever read it - copied out, loaded into the agent, and the copy
|
||||
# deleted, all before dropping to the sandbox user below.
|
||||
# The forwarded agent is not mounted when this key is configured (bin/dejima
|
||||
# blanks SSH_AUTH_SOCK), so this is the only agent in the container rather than
|
||||
# the preferred one of two.
|
||||
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
|
||||
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
|
||||
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
|
||||
@@ -90,31 +96,25 @@ fi
|
||||
# Linked here rather than COPYed into place at build time because both depend
|
||||
# on values that are only known per launch: which hosts this forge may be
|
||||
# pushed to, and which subcommand this workflow blocks.
|
||||
if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
|
||||
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it
|
||||
# and the guard execs /usr/bin/git directly.
|
||||
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
|
||||
fi
|
||||
|
||||
# Every CLI the shadow has something to do about: the one whose merge a
|
||||
# workflow blocks, and every one that writes a body the session authored. More
|
||||
# than one, because a forge can have a CLI per artifact rather than one with
|
||||
# subcommands, and because a forge with nothing blocked can still have
|
||||
# something to disclose.
|
||||
# The git shadow is installed unconditionally, because DEJIMA_PUSH_HOSTS fails
|
||||
# CLOSED: empty means no host may be pushed to, which is how a no-forge or
|
||||
# non-pushing workflow becomes enforced rather than described. Gating the
|
||||
# shadow on a non-empty value would leave it absent in exactly that case - and
|
||||
# local-only, the shipped default, is exactly that case.
|
||||
#
|
||||
# The first word of each record names the CLI. The real one is already behind
|
||||
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
|
||||
# fixed path to exec.
|
||||
_shadow_clis() {
|
||||
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
|
||||
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
|
||||
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
|
||||
while read -r _ _cli _; do
|
||||
[ -z "$_cli" ] || printf '%s\n' "$_cli"
|
||||
done
|
||||
}
|
||||
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it and
|
||||
# the guard execs /usr/bin/git directly.
|
||||
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
|
||||
|
||||
_shadow_cli_list="$(_shadow_clis | sort -u)"
|
||||
# Every CLI the shadow has something to do about. Derived by the same function
|
||||
# hooks/merge-guard.sh uses to decide whether a command is one of these, so the
|
||||
# set the shadow is linked over and the set the hook checks against cannot
|
||||
# disagree. The real CLI is already behind the symlink this replaces (see
|
||||
# install-forge-bins.sh), so the guard has a fixed path to exec.
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
_shadow_cli_list="$(dejima_forge_commands | sort -u)"
|
||||
if [ -n "$_shadow_cli_list" ]; then
|
||||
while read -r _cli; do
|
||||
[ -n "$_cli" ] || continue
|
||||
@@ -315,7 +315,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ] || [ -n "${SANDBOX_HARNESS_BRIEFI
|
||||
mkdir -p /etc/dejima
|
||||
# Written and renamed, so a harness starting while this is written reads
|
||||
# either the whole briefing or no file at all.
|
||||
if ! /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
|
||||
# As the sandbox user, not as root. It reads the project's own .dejima.md
|
||||
# and asks the package managers what is installed, with its cwd inside the
|
||||
# workspace the session can write - and the file it produces is written by
|
||||
# the redirect out here, which root still owns.
|
||||
if ! gosu "$SANDBOX_USER" /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
|
||||
> "$DEJIMA_BRIEFING_RENDERED.part"; then
|
||||
rm -f "$DEJIMA_BRIEFING_RENDERED.part"
|
||||
echo "entrypoint: could not render the session briefing. Refusing to" >&2
|
||||
|
||||
+35
-2
@@ -101,8 +101,28 @@ fi
|
||||
|
||||
_join() { local IFS=' '; echo "${*:-}"; }
|
||||
|
||||
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"})"
|
||||
EGRESS_ALLOWED_NAMES="$(_join ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"})"
|
||||
# The forge class wins wherever the two overlap, applied here rather than
|
||||
# asserted in a comment. Every route into the open class is normalised, not just
|
||||
# the approved-domains loop: the built-in list carries
|
||||
# release-assets.githubusercontent.com, which is under a forge suffix, and a
|
||||
# harness or backend domain under one would arrive the same way. Left alone, such
|
||||
# a name gets a resolver line more specific than the suffix entry, feeding the
|
||||
# open-class set - and the port restriction then rests on the forge's published
|
||||
# ranges alone, which is one layer where the design has two.
|
||||
_forge_extra=""
|
||||
_open_kept=""
|
||||
for _domain in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"}; do
|
||||
if [ "$(dejima_class_for "$_domain")" = forge ]; then
|
||||
_forge_extra="$_forge_extra $_domain"
|
||||
else
|
||||
_open_kept="$_open_kept $_domain"
|
||||
fi
|
||||
done
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"} $_forge_extra)"
|
||||
# shellcheck disable=SC2086
|
||||
EGRESS_ALLOWED_NAMES="$(_join $_open_kept)"
|
||||
EGRESS_INTERNAL_OK="$(_join ${DEJIMA_INTERNAL_CLASS[@]+"${DEJIMA_INTERNAL_CLASS[@]}"})"
|
||||
# What the policy should call that list when it tells an operator to add
|
||||
# something to it. It knows the variable it reads; only dejima knows the
|
||||
@@ -116,8 +136,17 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
|
||||
# was available. Same read-only token `gh` already gets (docker-compose.yml),
|
||||
# sent to the API it belongs to and nowhere else.
|
||||
[ -n "${GH_TOKEN:-}" ] && export EGRESS_FORGE_META_AUTH="Authorization: Bearer $GH_TOKEN"
|
||||
# The raw token has no other use here, and the header is the only thing the
|
||||
# policy needs it for. Both are a credential anything started below has no
|
||||
# reason to hold: they parse content the session's own traffic decides.
|
||||
unset GH_TOKEN
|
||||
|
||||
/usr/local/sbin/egress-policy apply
|
||||
# The header carries the token in another wrapper, so it goes the moment the
|
||||
# request that needed it is made. egress-policy scrubs its own daemons at the
|
||||
# line that starts each one; nothing started below this point can hold either
|
||||
# half, which is how the shipper is covered.
|
||||
unset EGRESS_FORGE_META_AUTH
|
||||
|
||||
# Best-effort visibility on top of the enforcement above, not part of it -
|
||||
# log-shipper.sh no-ops if LOKI_URL isn't set. It stays root, deliberately:
|
||||
@@ -134,4 +163,8 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
|
||||
# It outlives this script, and reparents to the container's init when this one
|
||||
# exits - which is where a daemon watching a session belongs. Started from the
|
||||
# entrypoint, it was a child of the very session process it exists to watch.
|
||||
# No `env -u` prefix here, unlike the daemons egress-policy starts: drop_caps
|
||||
# is a shell function, and env can only exec a binary - so a prefix turns this
|
||||
# line into exit 127, which the `&` hides from set -e. The two unsets above
|
||||
# already ran, which is what this one relies on instead.
|
||||
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
|
||||
|
||||
@@ -58,11 +58,13 @@ DEJIMA_OPEN_CLASS=(
|
||||
|
||||
DEJIMA_INTERNAL_CLASS=()
|
||||
|
||||
# release-assets.githubusercontent.com is under a forge-class suffix, and the
|
||||
# forge class wins wherever the two overlap - by design, not by accident. It is
|
||||
# fetched over HTTPS like everything else under that suffix, so the narrower
|
||||
# class costs it nothing and the alternative would be a name that quietly
|
||||
# widened the forge.
|
||||
# release-assets.githubusercontent.com is under a forge-class suffix, so it ends
|
||||
# up in the forge class: firewall-up.sh routes every open-class entry through
|
||||
# dejima_class_for, this list included, rather than only the ones a project
|
||||
# declared. It is fetched over HTTPS like everything else under that suffix, so
|
||||
# the narrower class costs it nothing, and the alternative is a name that
|
||||
# quietly widens the forge - which is what it did until the normalisation
|
||||
# covered this list too.
|
||||
|
||||
# Split on spaces explicitly, not on whatever IFS the caller happens to have,
|
||||
# so every consumer gets the same list from the same value. A harness declaring
|
||||
|
||||
+25
-187
@@ -3,21 +3,22 @@
|
||||
# one subcommand a workflow blocks is refused, a command aimed at another
|
||||
# repository is refused, and a body the session authored says so.
|
||||
#
|
||||
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
|
||||
# operator's decision, so the sandbox opens a PR and stops - moved off the
|
||||
# harness's hook protocol and onto the command. It works for a harness that
|
||||
# cannot intercept a tool call, and it compares argv rather than
|
||||
# regex-matching a command string, so quoting, spacing and pipelines stop
|
||||
# mattering.
|
||||
# The first two are docker/guard-lib.sh, which hooks/merge-guard.sh applies to
|
||||
# the same command from the other side. Here they sit on the command itself: it
|
||||
# works for a harness that cannot intercept a tool call, and it compares argv
|
||||
# rather than regex-matching a command string, so quoting, spacing and
|
||||
# pipelines stop mattering.
|
||||
#
|
||||
# The second is here for the same reason it is not in the forge CLIs: dejima
|
||||
# writes two of them and not the third, so a disclosure living in the tools is
|
||||
# one that depends on which forge an operator picked. This is the one place
|
||||
# every forge passes through.
|
||||
# The third is this layer's alone, because a hook is handed a command string
|
||||
# and cannot rewrite the body an argument points at. It is here rather than in
|
||||
# the forge CLIs for the same reason the repository rule is: dejima writes two
|
||||
# of them and not the third, so a disclosure living in the tools is one that
|
||||
# depends on which forge an operator picked. This is the one place every forge
|
||||
# passes through.
|
||||
#
|
||||
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
|
||||
# whether a merge is possible is the scope of the token the forge issued. This
|
||||
# stops an eager agent doing it by accident.
|
||||
# Fast-fail, not a boundary: what actually decides whether a merge is possible
|
||||
# is the scope of the token the forge issued. This stops an eager agent doing
|
||||
# it by accident.
|
||||
#
|
||||
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
|
||||
# merge". Its first word is the command this shadows;
|
||||
@@ -51,180 +52,12 @@ if [ -z "${DEJIMA:-}" ] ||
|
||||
exec "$real" "$@"
|
||||
fi
|
||||
|
||||
# The words of a command prefix, minus the CLI name, appear as a run of
|
||||
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
|
||||
# --verbose merge 7` is still a merge.
|
||||
#
|
||||
# Anywhere in that run rather than at the start of it, because a flag's value
|
||||
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
|
||||
# the first non-flag argument is `o/r`, and requiring the match to begin there
|
||||
# let the most ordinary way to name a PR from outside its checkout walk
|
||||
# straight past the block. Knowing which flags take a value would mean knowing
|
||||
# each CLI, which is the coupling this guard exists to avoid.
|
||||
#
|
||||
# The cost is a command that happens to pass the words as consecutive values -
|
||||
# `--title pr --body merge` - matching. For the merge block that is a loud
|
||||
# refusal against a merge going through unnoticed; for a disclosure it is a
|
||||
# line of text on something an agent did write anyway. Both are the direction
|
||||
# to be wrong in.
|
||||
plain=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
plain+=("$arg")
|
||||
done
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# argv_has <word>... - true when those words appear consecutively in `plain`.
|
||||
argv_has() {
|
||||
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
|
||||
[ "$m" -gt 0 ] || return 1
|
||||
while [ "$((i + m))" -le "$n" ]; do
|
||||
k=0
|
||||
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
|
||||
k=$((k + 1))
|
||||
done
|
||||
[ "$k" -lt "$m" ] || return 0
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- the merge block --------------------------------------------------------
|
||||
|
||||
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
|
||||
# The first word names the command; only the rest is a subcommand sequence
|
||||
# to compare against argv. A block naming a different command than the one
|
||||
# being run is not this invocation's business.
|
||||
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
|
||||
if [ "${#blocked[@]}" -eq 1 ]; then
|
||||
# The whole command is blocked, not one of its subcommands.
|
||||
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
|
||||
exit 1
|
||||
fi
|
||||
if argv_has "${blocked[@]:1}"; then
|
||||
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- the repository the session was launched against ------------------------
|
||||
#
|
||||
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
|
||||
# repo_env). The gitea CLIs declare neither, because they resolve the repo
|
||||
# from origin and refuse another host; `gh` declares both, so on that path the
|
||||
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||
# file an issue on a repository the session has nothing to do with (#187).
|
||||
#
|
||||
# Fast-fail, not a boundary, for the same reason the merge block is not one:
|
||||
# the session holds the token and could call the API directly. What bounds
|
||||
# this is the scope of push_token_file.
|
||||
#
|
||||
# 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:-}" ] ||
|
||||
[ -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
|
||||
}
|
||||
# Case-insensitively: forges treat an owner and a repository name as
|
||||
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||
# would be a false one.
|
||||
same_repo() {
|
||||
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||
}
|
||||
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
|
||||
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||
args_left=("$@")
|
||||
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||
arg="${args_left[0]}"
|
||||
args_left=("${args_left[@]:1}")
|
||||
for flag in "${repo_flags[@]}"; do
|
||||
value=""
|
||||
case "$arg" in
|
||||
"$flag")
|
||||
# The value is the next argument, and its absence is
|
||||
# the CLI's error to report rather than this one's.
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# One-dash flags only; a two-dash flag carries its
|
||||
# value with `=` or as the next argument, both above.
|
||||
case "$flag" in
|
||||
-?) ;;
|
||||
*) 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
|
||||
same_repo "$value" || refuse_repo "$flag" "$value"
|
||||
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
|
||||
if ! dejima_forge_verdict "$self" "$@"; then
|
||||
echo "Blocked by dejima's forge guard: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- the authorship disclosure ---------------------------------------------
|
||||
@@ -242,13 +75,18 @@ marker_for() {
|
||||
esac
|
||||
}
|
||||
|
||||
# The non-flag arguments the records below are compared against. Set here
|
||||
# rather than relied on from the verdict above, so this section says what it
|
||||
# depends on.
|
||||
dejima_forge_plain "$@"
|
||||
|
||||
kind=""
|
||||
while read -r record_kind record_rest; do
|
||||
[ -n "$record_kind" ] || continue
|
||||
read -ra words <<< "$record_rest"
|
||||
[ "${#words[@]}" -ge 1 ] || continue
|
||||
[ "${words[0]}" = "$self" ] || continue
|
||||
if argv_has "${words[@]:1}"; then
|
||||
if dejima_argv_has "${words[@]:1}"; then
|
||||
kind="$record_kind"
|
||||
break
|
||||
fi
|
||||
|
||||
+17
-129
@@ -1,20 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Shadows git on PATH so `git push` is checked whatever ran it.
|
||||
#
|
||||
# The rules are the ones hooks/git-push-guard.sh applies - push only to the
|
||||
# forge's own hosts, never straight to a blocked branch - moved off the
|
||||
# harness's hook protocol and onto the command itself. Two things follow from
|
||||
# that. It works for a harness with no way to intercept a tool call, which is
|
||||
# most of them. And it reads the decision out of argv rather than out of a
|
||||
# regex over a reconstructed command string, so `git push`, `git -c k=v
|
||||
# push` and a push buried in a pipeline are all just argv here.
|
||||
# The rules are in docker/guard-lib.sh, which hooks/git-push-guard.sh applies
|
||||
# to the same push from the other side - push only to the forge's own hosts,
|
||||
# never straight to a blocked branch. Here they sit on the command itself,
|
||||
# which means two things. It works for a harness with no way to intercept a
|
||||
# tool call, which is most of them. And it reads the decision out of argv
|
||||
# rather than out of a regex over a reconstructed command string, so
|
||||
# `git push`, `git -c k=v push` and a push buried in a pipeline are all just
|
||||
# argv here.
|
||||
#
|
||||
# It is a fast-fail guard, not a boundary, and the distinction is the same one
|
||||
# docs/security.md draws about the hook it replaces: a session that means to
|
||||
# get round it can call /usr/bin/git directly, exactly as it could have used a
|
||||
# tool the hook did not cover. What stops a push reaching somewhere it should
|
||||
# not is the firewall, and what stops it being accepted is the forge. This
|
||||
# stops an eager agent doing the obvious thing by accident.
|
||||
# docs/security.md draws about the hook beside it: a session that means to get
|
||||
# round it can call /usr/bin/git directly, exactly as it could have used a tool
|
||||
# the hook did not cover. What stops a push reaching somewhere it should not is
|
||||
# the firewall, and what stops it being accepted is the forge.
|
||||
#
|
||||
# Everything it needs is already in the container's environment, resolved on
|
||||
# the host from the forge and workflow manifests (see bin/dejima):
|
||||
@@ -32,125 +32,13 @@ if [ -z "${DEJIMA:-}" ]; then
|
||||
exec "$REAL_GIT" "$@"
|
||||
fi
|
||||
|
||||
# Find the subcommand, and -C if it is there. git's own global options come
|
||||
# before the subcommand and several take a value, so the ones that do are
|
||||
# skipped in pairs rather than assumed to be attached.
|
||||
repo_dir="$PWD"
|
||||
subcommand=""
|
||||
argv=("$@")
|
||||
# Every `-c key=value` this push carries, kept rather than only skipped. The
|
||||
# question below is where the push will go, and git answers that from the
|
||||
# config the push runs under - so resolving it without these would answer for
|
||||
# a different configuration than the one about to be used. A one-command
|
||||
# `-c remote.origin.pushurl=...` is exactly that case.
|
||||
config_overrides=()
|
||||
i=0
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
-C)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
|
||||
;;
|
||||
-c)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(-c "${argv[$i]}")
|
||||
;;
|
||||
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
subcommand="${argv[$i]}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
DEJIMA_GUARD_GIT="$REAL_GIT"
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
[ "$subcommand" = "push" ] || exec "$REAL_GIT" "$@"
|
||||
|
||||
# What comes after `push`: the first two non-flag words are the remote and the
|
||||
# refspec. Flags that take a value are skipped in pairs for the same reason as
|
||||
# above - `git push --repo origin main` must not read "origin" as the remote.
|
||||
# --repo names the remote in git's own legacy form and wins over a positional,
|
||||
# so it is read rather than skipped: reading it as an opaque flag would leave
|
||||
# `git push --repo=origin main` looking like a push to a remote called "main",
|
||||
# which resolves to no URL - and passing that through unchecked is a push to
|
||||
# origin/main that nothing looked at.
|
||||
remote=""
|
||||
refspec=""
|
||||
i=$((i + 1))
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
--repo=*)
|
||||
remote="${argv[$i]#--repo=}"
|
||||
;;
|
||||
--repo)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && remote="${argv[$i]}"
|
||||
;;
|
||||
-o|--push-option|--receive-pack|--exec)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
if [ -z "$remote" ]; then
|
||||
remote="${argv[$i]}"
|
||||
elif [ -z "$refspec" ]; then
|
||||
refspec="${argv[$i]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
[ -n "$remote" ] || remote=origin
|
||||
|
||||
# --push, because that is the URL the push will use. Without it this reads
|
||||
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
|
||||
# so a pushurl naming any other host is checked against the fetch URL and
|
||||
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
|
||||
# push origin branch` sets one for a single command, and .git/config is in the
|
||||
# read-write project mount, so a session can also just write one down.
|
||||
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
|
||||
# A remote this cannot resolve is refused rather than passed through. It is
|
||||
# usually a typo, which git would reject in a moment anyway - but it is also
|
||||
# what a disagreement between this parser and git's looks like, and the
|
||||
# failure of that disagreement has to be a refusal rather than a push nobody
|
||||
# checked.
|
||||
if [ -z "$remote_url" ]; then
|
||||
echo "Blocked by dejima's git guard: cannot resolve the remote '$remote' to a URL," >&2
|
||||
echo "so there is nothing to check this push against. Name a remote that exists." >&2
|
||||
if ! dejima_push_verdict "$PWD" "$@"; then
|
||||
echo "Blocked by dejima's git guard: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
host="$(printf '%s' "$remote_url" \
|
||||
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
|
||||
|
||||
host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
|
||||
done
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
echo "Blocked by dejima's git guard: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$refspec" ]; then
|
||||
branch="${refspec##*:}"
|
||||
else
|
||||
branch="$("$REAL_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)"
|
||||
fi
|
||||
branch="${branch#refs/heads/}"
|
||||
|
||||
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
if [ "$branch" = "$blocked_branch" ]; then
|
||||
echo "Blocked by dejima's git guard: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
exec "$REAL_GIT" "$@"
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
#!/bin/bash
|
||||
# The push and merge policy, once, for the two layers that apply it.
|
||||
#
|
||||
# Each rule used to exist twice: as a PATH shadow over the command
|
||||
# (docker/git-guard.sh, docker/forge-guard.sh) and as a PreToolUse hook over
|
||||
# the command string a harness reports (hooks/git-push-guard.sh,
|
||||
# hooks/merge-guard.sh). The two overlap deliberately - a hook sees
|
||||
# `/usr/bin/git push`, which walks past the shadow, and a shadow sees a push
|
||||
# from an editor integration or a script, which the harness never reports - so
|
||||
# neither can be dropped. What they cannot do is disagree, and they had already
|
||||
# drifted: `git push -o x origin main` was fine to one and a hard refusal from
|
||||
# the other, for the same invocation in the same session.
|
||||
#
|
||||
# So the decision lives here and both call it. A bypass is then fixed once.
|
||||
#
|
||||
# Every function reads argv, never a command string. A hook holds a string, so
|
||||
# it splits it into segments and words first (dejima_guard_segments) and hands
|
||||
# the words over - which is also why the hooks inherit the shadows' indifference
|
||||
# to spacing, quoting and pipelines.
|
||||
#
|
||||
# None of this is a boundary, and the distinction is the one docs/security.md
|
||||
# draws: a session that means to get round these can call /usr/bin/git or the
|
||||
# forge API directly. What stops a push reaching somewhere it should not is the
|
||||
# firewall, and what stops it being accepted is the forge's token scope. These
|
||||
# stop an eager agent doing the obvious thing by accident.
|
||||
#
|
||||
# Sourced, so nothing here exits: a refusal is a return of 1 with the reason in
|
||||
# DEJIMA_GUARD_REASON. The caller supplies its own prefix, because an operator
|
||||
# reading a refusal needs to know which layer spoke.
|
||||
|
||||
# The real git, never the shadow. The shadow passes a non-push through, so
|
||||
# calling it would work - but it would also mean every question this asks git
|
||||
# goes through a script that is asking git questions.
|
||||
DEJIMA_GUARD_GIT="${DEJIMA_GUARD_GIT:-/usr/bin/git}"
|
||||
|
||||
DEJIMA_GUARD_REASON=""
|
||||
|
||||
# --- splitting a command string into git/forge invocations -------------------
|
||||
|
||||
# dejima_guard_segments <command string>
|
||||
#
|
||||
# One segment per line. A guarded command can be the second half of a compound
|
||||
# one, and only a segment whose first word is the command in question is that
|
||||
# command - `echo push` is not a push.
|
||||
#
|
||||
# Word-splitting rather than a full shell parse, so quoting edge cases are not
|
||||
# handled. That is the same pragmatic tradeoff as the sibling
|
||||
# deny-dangerous-bash.sh denylist, and the reason every rule below refuses
|
||||
# rather than guesses when it cannot read what it is looking at.
|
||||
# The trailing newline is load-bearing: without it `read` returns false on the
|
||||
# last segment even though it read one, so a command with no separators in it -
|
||||
# every ordinary push - was never looked at.
|
||||
dejima_guard_segments() {
|
||||
printf '%s\n' "$1" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
|
||||
}
|
||||
|
||||
# --- git push ---------------------------------------------------------------
|
||||
|
||||
# Where a push will go and which branches it will write, read out of argv the
|
||||
# way git reads it. Sets DEJIMA_GUARD_PUSH=1 when the argv is a push at all.
|
||||
#
|
||||
# git's own global options come before the subcommand and several take a
|
||||
# separate value, so those are skipped in pairs rather than assumed attached.
|
||||
#
|
||||
# `-c key=value` is kept rather than skipped: git resolves where a push goes
|
||||
# from the config the push runs under, so asking without these answers for a
|
||||
# different configuration than the one about to be used. A one-command
|
||||
# `-c remote.origin.pushurl=...` is exactly that case.
|
||||
#
|
||||
# --git-dir and --work-tree are REFUSED rather than skipped or honoured. Both
|
||||
# used to be consumed and ignored, so `git --git-dir /tmp/x/.git push origin
|
||||
# main` resolved `origin` in the mounted project, found it allowed, and then
|
||||
# ran against a repository whose origin points somewhere else. Honouring them
|
||||
# would mean reimplementing how they combine with -C and with each other;
|
||||
# refusing is the direction the unresolvable-remote case below already takes,
|
||||
# and a session with a second repository to push from can cd into it or use -C.
|
||||
dejima_push_verdict() { # <repo-dir> <git argv...>
|
||||
local repo_dir="$1"; shift
|
||||
local argv=("$@") i=0 subcommand="" branch
|
||||
local -a config_overrides=() refspecs=()
|
||||
local remote="" repo_flag="" push_all=0 push_mirror=0 repo_override=0
|
||||
DEJIMA_GUARD_REASON=""
|
||||
DEJIMA_GUARD_PUSH=0
|
||||
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
-C)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
|
||||
;;
|
||||
-c)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(-c "${argv[$i]}")
|
||||
;;
|
||||
--git-dir|--work-tree)
|
||||
repo_override=1
|
||||
i=$((i + 1))
|
||||
;;
|
||||
--git-dir=*|--work-tree=*)
|
||||
repo_override=1
|
||||
;;
|
||||
# `-c` with the value read from the environment instead of argv,
|
||||
# so it sets a pushurl the same way and has to be carried into the
|
||||
# resolution below the same way. In the separated spelling it also
|
||||
# takes the next word, which an unrecognised option would leave to
|
||||
# be read as the subcommand - and a subcommand that is not `push`
|
||||
# ends this function having checked nothing.
|
||||
--config-env)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(--config-env "${argv[$i]}")
|
||||
;;
|
||||
--config-env=*)
|
||||
config_overrides+=("${argv[$i]}")
|
||||
;;
|
||||
--exec-path|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
subcommand="${argv[$i]}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
[ "$subcommand" = push ] || return 0
|
||||
DEJIMA_GUARD_PUSH=1
|
||||
|
||||
# Noted while parsing and refused here, so that only a push is refused:
|
||||
# these say nothing about any other git command, and a guard that refused
|
||||
# `git --git-dir X status` would be refusing the flag rather than the push.
|
||||
if [ "$repo_override" = 1 ]; then
|
||||
DEJIMA_GUARD_REASON="a push carrying --git-dir or --work-tree cannot be checked, because the repository it writes from is not the one this would resolve the remote in. Run it from that repository, or name it with -C."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# What comes after `push`. --repo names the remote in git's own legacy form
|
||||
# and is read rather than treated as an opaque flag, because skipping it
|
||||
# would leave `git push --repo=origin main` looking like a bare push and
|
||||
# resolve a remote the command never named.
|
||||
#
|
||||
# It LOSES to a positional, which is what git documents: "this option is
|
||||
# equivalent to the <repository> argument. If both are specified, the
|
||||
# command-line argument takes precedence." So in `git push --repo=origin
|
||||
# other-remote feature` the remote is other-remote, and checking origin
|
||||
# instead would approve a host this push never goes to.
|
||||
#
|
||||
# Every positional after the remote is a refspec, not just the first.
|
||||
# `git push origin feature main` checked `feature` alone and let `main`
|
||||
# through beside it.
|
||||
i=$((i + 1))
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
--repo=*) repo_flag="${argv[$i]#--repo=}" ;;
|
||||
--repo)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_flag="${argv[$i]}"
|
||||
;;
|
||||
--all) push_all=1 ;;
|
||||
--mirror) push_mirror=1 ;;
|
||||
-o|--push-option|--receive-pack|--exec)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [ -z "$remote" ]; then
|
||||
remote="${argv[$i]}"
|
||||
else
|
||||
refspecs+=("${argv[$i]}")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
[ -n "$remote" ] || remote="$repo_flag"
|
||||
[ -n "$remote" ] || remote=origin
|
||||
|
||||
# --push, because that is the URL the push will use. Without it this reads
|
||||
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is
|
||||
# set, so a pushurl naming any other host is checked against the fetch URL
|
||||
# and allowed. It needs no evasion to reach: `git -c
|
||||
# remote.origin.pushurl=... push origin branch` sets one for a single
|
||||
# command, and .git/config is in the read-write project mount, so a session
|
||||
# can also just write one down.
|
||||
local remote_url host
|
||||
remote_url="$("$DEJIMA_GUARD_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
|
||||
if [ -z "$remote_url" ]; then
|
||||
# A remote this cannot resolve is refused rather than passed through.
|
||||
# It is usually a typo, which git would reject in a moment anyway - but
|
||||
# it is also what a disagreement between this parser and git's looks
|
||||
# like, and the failure of that disagreement has to be a refusal rather
|
||||
# than a push nobody checked.
|
||||
DEJIMA_GUARD_REASON="cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists."
|
||||
return 1
|
||||
fi
|
||||
|
||||
host="$(printf '%s' "$remote_url" \
|
||||
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
|
||||
|
||||
local allowed_host host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
|
||||
done
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
DEJIMA_GUARD_REASON="this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --mirror puts every ref under refs/ on the remote AND deletes the ones
|
||||
# that are gone locally, so what it does to a protected branch cannot be
|
||||
# read off the command: a mirror from a checkout that has no `main` deletes
|
||||
# the remote's. Refused outright rather than resolved, because there is no
|
||||
# branch list that makes it safe.
|
||||
if [ "$push_mirror" = 1 ]; then
|
||||
DEJIMA_GUARD_REASON="--mirror writes every ref and deletes the remote refs this checkout does not have, so it cannot be checked against a branch policy. Push the branch you mean."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --all pushes every local branch, so every local branch is what the policy
|
||||
# has to be applied to. The refspec list is empty here - git refuses --all
|
||||
# with one - and reading only the current branch is what let `git push
|
||||
# --all origin` from a feature branch push `main` beside it.
|
||||
if [ "$push_all" = 1 ]; then
|
||||
local local_branch
|
||||
while IFS= read -r local_branch; do
|
||||
[ -n "$local_branch" ] || continue
|
||||
if _dejima_branch_blocked "$local_branch"; then
|
||||
DEJIMA_GUARD_REASON="--all pushes every local branch, and '$local_branch' is one of them. Direct pushes to '$local_branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
|
||||
return 1
|
||||
fi
|
||||
done < <("$DEJIMA_GUARD_GIT" -C "$repo_dir" for-each-ref \
|
||||
--format='%(refname:short)' refs/heads/ 2>/dev/null)
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Each refspec's destination. `src:dst` names it outright, a bare name is
|
||||
# both, and `HEAD` or no refspec at all means the current branch - `git
|
||||
# push origin` and `git push origin HEAD` both push it.
|
||||
if [ "${#refspecs[@]}" -eq 0 ]; then
|
||||
refspecs=(HEAD)
|
||||
fi
|
||||
local refspec
|
||||
for refspec in "${refspecs[@]}"; do
|
||||
# One leading `+` is the force marker and belongs to the refspec rather
|
||||
# than to the branch, so it comes off before the destination is read -
|
||||
# `git push origin +main` names main, and the force spelling is the
|
||||
# destructive one to be wrong about.
|
||||
refspec="${refspec#+}"
|
||||
case "$refspec" in
|
||||
*:*) branch="${refspec##*:}" ;;
|
||||
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
|
||||
*) branch="$refspec" ;;
|
||||
esac
|
||||
# A destination that does not start with refs/ is matched against the
|
||||
# remote's refs by git's rev-parse rules, so `heads/main` and `main`
|
||||
# both name refs/heads/main. Stripping only the full prefix left
|
||||
# `feature:heads/main` naming a branch no policy entry matched.
|
||||
branch="${branch#refs/}"
|
||||
branch="${branch#heads/}"
|
||||
if _dejima_branch_blocked "$branch"; then
|
||||
DEJIMA_GUARD_REASON="direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected, which
|
||||
# is correct for a workflow that genuinely has no branch policy. Unlike
|
||||
# DEJIMA_PUSH_HOSTS above, which fails CLOSED - empty means no host is allowed,
|
||||
# so every push is refused, and that is how a no-forge or non-pushing workflow
|
||||
# becomes enforced rather than merely described.
|
||||
_dejima_branch_blocked() {
|
||||
local blocked
|
||||
[ -n "${1:-}" ] || return 1
|
||||
for blocked in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
[ "$1" = "$blocked" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- the forge CLI ----------------------------------------------------------
|
||||
|
||||
# dejima_argv_has <word>... - true when those words appear consecutively among
|
||||
# the non-flag arguments of the command being checked. _dejima_plain must be
|
||||
# set first, by dejima_forge_verdict.
|
||||
#
|
||||
# Anywhere in that run rather than at the start of it, because a flag's value
|
||||
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
|
||||
# the first non-flag argument is `o/r`, and requiring the match to begin there
|
||||
# let the most ordinary way to name a PR from outside its checkout walk
|
||||
# straight past the block. Knowing which flags take a value would mean knowing
|
||||
# each CLI, which is the coupling this guard exists to avoid.
|
||||
#
|
||||
# The cost is a command that happens to pass the words as consecutive values -
|
||||
# `--title pr --body merge` - matching. For the merge block that is a loud
|
||||
# refusal against a merge going through unnoticed; for a disclosure it is a
|
||||
# line of text on something an agent did write anyway. Both are the direction
|
||||
# to be wrong in.
|
||||
dejima_argv_has() {
|
||||
local want=("$@") n="${#_dejima_plain[@]}" m="$#" i=0 k
|
||||
[ "$m" -gt 0 ] || return 1
|
||||
while [ "$((i + m))" -le "$n" ]; do
|
||||
k=0
|
||||
while [ "$k" -lt "$m" ] && [ "${_dejima_plain[$((i + k))]}" = "${want[$k]}" ]; do
|
||||
k=$((k + 1))
|
||||
done
|
||||
[ "$k" -lt "$m" ] || return 0
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# dejima_forge_plain <argv...> - fills _dejima_plain with the non-flag
|
||||
# arguments, which is what every rule below compares against.
|
||||
dejima_forge_plain() {
|
||||
local arg
|
||||
_dejima_plain=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
_dejima_plain+=("$arg")
|
||||
done
|
||||
}
|
||||
|
||||
# dejima_forge_commands - every CLI this policy applies to, one per line: the
|
||||
# one whose merge a workflow blocks, and every one that writes a body the
|
||||
# session authored. More than one, because a forge can have a CLI per artifact
|
||||
# rather than one with subcommands, and because a forge with nothing blocked
|
||||
# can still have something to disclose.
|
||||
#
|
||||
# docker/entrypoint.sh links the shadow over exactly this set, and
|
||||
# hooks/merge-guard.sh checks a command against it before applying anything -
|
||||
# without that the hook would apply gh's --repo rule to any command carrying
|
||||
# that flag, which the shadow cannot do because it only exists for these.
|
||||
dejima_forge_commands() {
|
||||
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
|
||||
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
|
||||
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
|
||||
while read -r _ _cli _; do
|
||||
[ -z "$_cli" ] || printf '%s\n' "$_cli"
|
||||
done
|
||||
}
|
||||
|
||||
# dejima_forge_verdict <command name> <argv...>
|
||||
#
|
||||
# The command name is argv[0] as the caller saw it - the shadow's own basename,
|
||||
# or the first word of a hook's segment. Every policy here is keyed to it,
|
||||
# because a block naming a different command than the one being run is not this
|
||||
# invocation's business.
|
||||
dejima_forge_verdict() {
|
||||
local self="$1"; shift
|
||||
DEJIMA_GUARD_REASON=""
|
||||
dejima_forge_plain "$@"
|
||||
|
||||
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
local -a blocked
|
||||
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
|
||||
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
|
||||
if [ "${#blocked[@]}" -eq 1 ]; then
|
||||
# The whole command is blocked, not one of its subcommands.
|
||||
DEJIMA_GUARD_REASON="'$self' is not available in the sandbox."
|
||||
return 1
|
||||
fi
|
||||
if dejima_argv_has "${blocked[@]:1}"; then
|
||||
DEJIMA_GUARD_REASON="merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
_dejima_forge_repo_verdict "$@" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# The repository the session was launched against.
|
||||
#
|
||||
# A forge whose CLI can be aimed elsewhere declares how (repo_flags, repo_env,
|
||||
# repo_paths). The gitea CLIs declare none, because they resolve the repo from
|
||||
# origin and refuse another host - which leaves nothing here to compare, not
|
||||
# that they are confined to one repository: origin is session-writable. `gh`
|
||||
# declares all three, so on that path the
|
||||
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||
# file an issue on a repository the session has nothing to do with (#187).
|
||||
#
|
||||
# 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.
|
||||
_dejima_forge_repo_verdict() {
|
||||
[ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] \
|
||||
|| [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] || return 0
|
||||
|
||||
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
local env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||
if [ -n "$env_value" ] && ! _dejima_same_repo "$env_value"; then
|
||||
_dejima_refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||
local -a repo_flags args_left
|
||||
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||
args_left=("$@")
|
||||
local arg flag value letter rest
|
||||
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||
arg="${args_left[0]}"
|
||||
args_left=("${args_left[@]:1}")
|
||||
for flag in "${repo_flags[@]}"; do
|
||||
value=""
|
||||
case "$arg" in
|
||||
"$flag")
|
||||
# The value is the next argument, and its absence is
|
||||
# the CLI's error to report rather than this one's.
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# One-dash flags only; a two-dash flag carries its
|
||||
# value with `=` or as the next argument, both above.
|
||||
case "$flag" in
|
||||
-?) ;;
|
||||
*) 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
|
||||
if ! _dejima_same_repo "$value"; then
|
||||
_dejima_refuse_repo "$flag" "$value"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
_dejima_forge_path_verdict "$@" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# 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.
|
||||
#
|
||||
# Under that subcommand the rule is inverted: rather than looking for a path
|
||||
# naming the wrong repository, it requires one naming the right one. `gh api`
|
||||
# reaches every endpoint the token does, and an endpoint that is not under the
|
||||
# declared segment - `user/repos`, `/orgs/<org>/repos`, `graphql` - names no
|
||||
# repository for this to compare, so there was nothing to refuse and nothing
|
||||
# was (#365). Telling an endpoint apart from a header value or a field value
|
||||
# would mean knowing which of the CLI's flags take a separate value, which is
|
||||
# the coupling this guard exists to avoid - so the question asked is whether a
|
||||
# target it CAN read is present, which needs no such knowledge.
|
||||
#
|
||||
# graphql counts as one, because dejima's own github-pr reads review threads
|
||||
# through it and the API has no other route to them. A mutation does not:
|
||||
# `gh api graphql -f query='mutation{mergePullRequest(...)}'` is what an agent
|
||||
# reaches for once `gh pr merge` refuses, and no repository scoping can be read
|
||||
# out of a GraphQL document. Queries through it can only read, which the
|
||||
# read-only token could do anyway.
|
||||
_dejima_forge_path_verdict() {
|
||||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ] || return 0
|
||||
|
||||
local -a repo_paths
|
||||
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
|
||||
local spec segment arg path owner name scopable
|
||||
for spec in "${repo_paths[@]}"; do
|
||||
[ "$1" = "${spec%%:*}" ] || continue
|
||||
segment="${spec#*:}"
|
||||
scopable=0
|
||||
for arg in "$@"; do
|
||||
# A GraphQL document whose operation is a mutation. Matched
|
||||
# anywhere in the argument, because the document arrives as the
|
||||
# value of a field flag - `-f query=mutation{...}` - rather than as
|
||||
# an argument of its own. Matched before the brace test below,
|
||||
# which a document would otherwise satisfy.
|
||||
#
|
||||
# Over EVERY argument, including the dash-prefixed ones, because a
|
||||
# flag can carry its value attached: `--field=query=mutation{...}`
|
||||
# and `-Fquery=mutation{...}` put the document inside one word that
|
||||
# the scoping loop below skips. This check needs no flag table to
|
||||
# read it, which is what lets it look at all of them - unlike the
|
||||
# repository scoping, which does.
|
||||
#
|
||||
# Both boundaries are anything that cannot continue an
|
||||
# identifier, rather than whitespace and a brace. GraphQL treats
|
||||
# commas and comments as ignored tokens, so `mutation,{...}` and
|
||||
# `mutation#c` followed by the body are valid documents, and a
|
||||
# document carrying more than one operation puts `}` immediately
|
||||
# before the second one. Each of those sat just outside a narrower
|
||||
# boundary. The class excludes `_` on both sides, so a field called
|
||||
# foo_mutation is not a mutation.
|
||||
if [[ "$arg" =~ (^|[^A-Za-z0-9_])mutation([^A-Za-z0-9_]|$) ]]; then
|
||||
DEJIMA_GUARD_REASON="a GraphQL mutation reaches any repository and any write the token allows, and no repository scoping can be read out of it. Use the subcommands, and leave a merge to the operator."
|
||||
return 1
|
||||
fi
|
||||
# A body this cannot read is a body it cannot tell a query from a
|
||||
# mutation in. Two spellings put one out of reach: a field value
|
||||
# read from a file, and --input, which takes the whole request body
|
||||
# from a file or from stdin. Refused under this subcommand only;
|
||||
# the document is one argument, so passing it inline is the fix.
|
||||
case "$arg" in
|
||||
*=@*|--input|--input=*)
|
||||
DEJIMA_GUARD_REASON="'$1' takes its body from a file or from stdin, so what it sends is not in the command for this to read. Pass the document inline."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
# Everything from here needs to know which argument is the
|
||||
# endpoint, so the flags go.
|
||||
case "$arg" in -*) continue ;; esac
|
||||
[ "$arg" = graphql ] && { scopable=1; continue; }
|
||||
path="${arg#/}"
|
||||
# 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. Those two tokens only, and
|
||||
# only in an endpoint under the segment: any other brace says
|
||||
# nothing about which repository the call reaches, and a value
|
||||
# carrying one - `-f name={x}`, `--template '{{.login}}'` - would
|
||||
# otherwise be enough to assert the whole command is scoped.
|
||||
case "$path" in
|
||||
"$segment"/*'{owner}'*|"$segment"/*'{repo}'*) scopable=1; continue ;;
|
||||
esac
|
||||
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
|
||||
path="${path#"$segment"/}"
|
||||
owner="${path%%/*}"
|
||||
path="${path#*/}"
|
||||
name="${path%%/*}"
|
||||
[ -n "$owner" ] && [ -n "$name" ] || continue
|
||||
if ! _dejima_same_repo "$owner/$name"; then
|
||||
_dejima_refuse_repo "the path '$arg'" "$owner/$name"
|
||||
return 1
|
||||
fi
|
||||
scopable=1
|
||||
done
|
||||
if [ "$scopable" = 0 ]; then
|
||||
DEJIMA_GUARD_REASON="'$1' names no endpoint under '$segment/', so there is nothing here saying which repository this reaches. Name the endpoint as '$segment/${DEJIMA_FORGE_REPO:-<owner>/<name>}/...', or use the subcommands."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_dejima_refuse_repo() {
|
||||
DEJIMA_GUARD_REASON="this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts."
|
||||
}
|
||||
|
||||
# Case-insensitively: forges treat an owner and a repository name as
|
||||
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||
# would be a false one.
|
||||
_dejima_same_repo() {
|
||||
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||
}
|
||||
+18
-4
@@ -464,6 +464,16 @@ prefer credentials that cannot do anything else: a token that raises an
|
||||
anonymous API rate limit should be a fine-grained token with no permissions
|
||||
and no repository access, not a general-purpose one.
|
||||
|
||||
**A per-run fetch, not a per-container one.** The privileged job starts a
|
||||
container pair per test, and each filter container asks the forge for its
|
||||
published address ranges before it brings the policy up. Done once per
|
||||
container that is a burst, and GitHub answers a burst on `/meta` with a `403`
|
||||
from its secondary rate limit - which is not the hourly ceiling a token raises,
|
||||
so a valid credential does not prevent it (#357). The job fetches once into the
|
||||
directory it already shares with those containers, and the suite points every
|
||||
filter at the result; a container falls back to fetching for itself when the
|
||||
file is absent, which is what happens outside CI.
|
||||
|
||||
**The cache server.** `act_runner` enables it by default, and it binds on the
|
||||
runner host specifically so that job containers can reach it. That is an
|
||||
inbound service exposed to untrusted code. Turn it off where it is not
|
||||
@@ -523,7 +533,11 @@ can still add a workflow of its own.
|
||||
daemon, the answer is a host you are willing to lose, not a smaller grant.
|
||||
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
|
||||
bounds where it can write; it does not take the token away.
|
||||
- **None of this is checked by anything.** The rest of dejima's security
|
||||
properties have tests behind them. A runner's configuration lives on a
|
||||
machine this repository has no access to, so it is on you to re-check it
|
||||
after upgrades and rebuilds.
|
||||
- **Only part of this is checked, and only from inside a job.**
|
||||
`test/ci-runner.bats` runs on both tiers and asserts what a job can see from
|
||||
where it stands: which tier it landed on, that the confined one has no docker
|
||||
socket and the privileged one does, and that the egress rules are in front of
|
||||
the confined job. Everything outside a job - the runner's own configuration
|
||||
file, its labels, what else shares the machine - lives somewhere this
|
||||
repository has no access to, so it is on you to re-check that after upgrades
|
||||
and rebuilds.
|
||||
|
||||
+179
-24
@@ -55,6 +55,19 @@ Every tool takes `--help`, which describes its own arguments and what they do.
|
||||
That is the fastest answer to "what does this flag mean"; this page is for the
|
||||
config file behind them.
|
||||
|
||||
**A launch flag overrides the file for that launch only, and every `--no-*`
|
||||
flag only ever narrows.** None of those turns a setting back on: a config that
|
||||
says `off` is a decision somebody made in a file they can reread, and a flag
|
||||
that could undo one in passing would make the file's answer the weaker of the
|
||||
two. `--isolated` is the far end of the same idea, narrowing several at once:
|
||||
see [Isolated sessions](#isolated-sessions).
|
||||
|
||||
The rule is about the `--no-*` family, not about every flag. `--ro`, `--rw`,
|
||||
`--include`, `--allow-github-push` and a `--forge` naming a forge the config
|
||||
did not each widen what one launch can reach, which is what they are for - and
|
||||
each is visible in the command that used it, where a config file's answer is
|
||||
not.
|
||||
|
||||
### `[core]` - what this sandbox runs
|
||||
|
||||
The three selections come first because everything below depends on them: the
|
||||
@@ -137,7 +150,7 @@ required: an identity supplies what it names and no more.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ssh_key` | unset | the SSH key this identity pushes with |
|
||||
| `ssh_key` | unset | the SSH key this identity pushes with. An empty file counts as no key at all rather than a refusal, so pointing this at one is how a launch keeps the setting and forwards your agent instead |
|
||||
| `user_name` | unset | `user.name` for commits made in the session |
|
||||
| `user_email` | unset | `user.email` for commits made in the session |
|
||||
| `token_file` | *(the forge's own default)* | this identity's Gitea/Forgejo API token, overriding `[forge.gitea] token_file`. Read only when `[core] forge = gitea` |
|
||||
@@ -157,7 +170,7 @@ you selected, the second is push access when GitHub *is* your forge.
|
||||
| `[forge.gitea] api` | **required** for `forge = gitea` | `https://<gitea host>/api/v1` |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file`; see "GitHub push" in [docs/security.md](security.md) |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`. Read whichever forge is selected, and it should stay read-only |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`, and it should stay read-only. Read for every session EXCEPT when `[core] forge = github` and `push_token_file` is set: both fill `GH_TOKEN`, the forge's wins, and the launch says so. Which token can push is the half that matters |
|
||||
|
||||
### `[mounts]` - extra host paths
|
||||
|
||||
@@ -254,8 +267,16 @@ name and its subdomains, with no exact-name form to use instead - so a bare
|
||||
label would grant its whole suffix, and `org` would ask for every `.org` name.
|
||||
The same floor applies to every key whose value becomes a permitted
|
||||
destination: `[core] internal_domains`, a forge's `ssh_host` and `api`, a
|
||||
backend's `base_url`, and `[logging] loki_url`. A single-label value is refused
|
||||
when the entry is declared and again at launch, naming the key it came from.
|
||||
backend's `base_url`, and `[logging] loki_url`. A value that is not a host name of at least two labels is
|
||||
refused when the entry is declared and again at launch, naming the key it came
|
||||
from - which includes a value that reached the launcher through a URL and lost
|
||||
its host on the way, such as a `loki_url` carrying userinfo.
|
||||
|
||||
An approval granted before this rule existed is still read rather than called
|
||||
corrupt, so an upgrade does not present as tampering; the launch stops instead,
|
||||
naming the entry. There is no `remove` verb, so clearing one means editing the
|
||||
store by hand - `~/.config/dejima/allowed-domains` for a global approval, or
|
||||
`~/.config/dejima/projects/<slug>/allowed-domains` for a project's.
|
||||
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a session could write these files; they are read only to
|
||||
@@ -269,6 +290,7 @@ 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-allow --container <id> dl.example.com files.example.org # several
|
||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
dejima-dep add --global cargo:cargo-nextest
|
||||
```
|
||||
@@ -276,8 +298,22 @@ dejima-dep add --global cargo:cargo-nextest
|
||||
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
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`.
|
||||
|
||||
`dejima-allow` takes up to twenty domains at once, which is what a toolchain
|
||||
fetching from several hosts actually needs. One goes through as a single
|
||||
decision you already made by typing it; more than one is printed back to you
|
||||
one per line and confirmed before anything is added, defaulting to no. The cap
|
||||
and the confirmation are the same point: a list is where an unwanted name rides
|
||||
along unread, the agent that suggested the command is what might have put it
|
||||
there, and neither mechanism helps if the list is too long to finish reading.
|
||||
Every name is validated before any of them is shown, so what you are confirming
|
||||
is what will be added and no entry can carry an escape sequence that rewrites
|
||||
the lines above it. Confirming needs a terminal, so a script does them one at a
|
||||
time.
|
||||
|
||||
`dejima-allow --project` and `dejima-dep add --project` 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.
|
||||
|
||||
@@ -796,8 +832,15 @@ 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 it means
|
||||
a live forwarded agent is no longer required at all.
|
||||
[security notes](security.md) for the full mount rationale).
|
||||
|
||||
Setting it means a forwarded agent is not merely unnecessary but **not
|
||||
mounted**: your own `SSH_AUTH_SOCK` is not passed to the container at all, so
|
||||
nothing in a session can sign with your personal identities by naming the
|
||||
socket directly. That is the point of choosing a dedicated key, and it is worth
|
||||
knowing before you choose one - a session that also needed the forwarded agent
|
||||
for something else (a second remote, a private submodule over ssh) no longer
|
||||
has it. Leave this unset to keep the agent.
|
||||
|
||||
## Using a different editor/application
|
||||
|
||||
@@ -947,11 +990,18 @@ 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).
|
||||
Suspending is not pausing, and the difference is worth knowing before you reach
|
||||
for it. What stops is the host side: `dejima` itself, the container run, the
|
||||
credential proxy and the conversation shipper, which share one process group so
|
||||
the kernel stops and continues them together. The container is not in that
|
||||
group. It keeps running under the container runtime, with its mounts, its
|
||||
firewall grants and its forge token, and keeps making requests while the
|
||||
terminal looks stopped - see
|
||||
[what dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
|
||||
|
||||
Suspending is not detaching either: the job belongs to the terminal you started
|
||||
it from. For a session you want to pick up from somewhere else, run dejima
|
||||
inside tmux and use [`dejima-attach`](remote-access.md).
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
@@ -1019,6 +1069,100 @@ 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.
|
||||
|
||||
## Isolated sessions
|
||||
|
||||
`dejima --isolated` runs a session with nothing before it and nothing after it.
|
||||
No project is mounted, no memory or transcript crosses either boundary, and the
|
||||
one thing that outlives it is a `~/shared` of its own.
|
||||
|
||||
```sh
|
||||
dejima --isolated # from anywhere - the directory you are in is not used
|
||||
```
|
||||
|
||||
Naming a directory is **refused**, not ignored: `dejima --isolated ~/proj`
|
||||
stops rather than starting a session that looks like it is working on a
|
||||
project it cannot see. Harness arguments after `--` still pass through.
|
||||
|
||||
| | ordinary launch | `--isolated` |
|
||||
|---|---|---|
|
||||
| working directory | the project, read-write at its own path | `/dejima/work`, container-local |
|
||||
| `~/shared` | this project's, shared by every session against it | made fresh, never reused |
|
||||
| memory and transcripts | `~/.claude/projects/<slug>`, mounted | none |
|
||||
| shell history, nvim state | volumes that outlive the container | this run's own, removed with it |
|
||||
| domains, dependencies, tools | the global set plus this project's | the global set only |
|
||||
| `[mounts]` lists | applied | dropped, and named at launch |
|
||||
| forge, workflow | as configured | `none` and `local-only`, unless named |
|
||||
| forge token, `[github] token_file` | both cross | neither, unless a forge is named |
|
||||
| forwarded ssh agent | mounted when the workflow pushes | not mounted, unless you name a pushing workflow and have no key |
|
||||
| `[identity.<name>] ssh_key` | mounted | mounted |
|
||||
| conversation log, epitaph | as configured | off |
|
||||
|
||||
Every shipped harness supports it. A harness whose model configuration has to
|
||||
be a file inside its own state directory gets it from a staging directory
|
||||
that goes away with the launch, rather than from the state mount this mode
|
||||
does not have.
|
||||
|
||||
Your own `~/.claude` hooks, skills, commands, agents and output styles are
|
||||
still mounted read-only, as is `[mounts] nvim_config`. Those are yours, no
|
||||
session can write them, and a session without them is a session without the
|
||||
tools you built for it.
|
||||
|
||||
**The directory you launch from decides nothing.** An approval store is found
|
||||
by walking up from the launch directory, so a home directory that was once
|
||||
approved as a project would otherwise hand an anonymous session that project's
|
||||
allowed domains and its dependency layer - which is the whole reason to be able
|
||||
to run this from a fresh terminal in `$HOME` without thinking about it.
|
||||
|
||||
**There is no config key, in any spelling.** A mode this strong is a decision
|
||||
to make in the command, where it is visible, rather than a line in a file that
|
||||
every later launch inherits. For the same reason `--ro` and `--rw` are refused
|
||||
rather than dropped: a path typed into this command cannot be quietly ignored,
|
||||
where a `[mounts]` list written months ago can be - with a line saying which.
|
||||
|
||||
**What it gives up.** The conversation log and the epitaph both read a
|
||||
transcript on the host, and this mode mounts none, so both are off and the
|
||||
launch says so rather than leaving you to find an empty record later. The
|
||||
exit-time audit is skipped for the same reason: its subject is the project's
|
||||
own files. If you want either back in this mode, that is
|
||||
[#388](https://gitbox.branderson.io/dejima/dejima/issues/388) - both are your
|
||||
record rather than the agent's, and neither ever reaches a future session.
|
||||
|
||||
**No forge, unless you name one on the same command.** `--isolated` sets
|
||||
`[core] forge` to `none` and `[core] workflow` to `local-only` for that launch,
|
||||
and withholds `[github] token_file` with them. Naming either is how you ask for
|
||||
a session that clones and opens a pull request, which is a real use of this
|
||||
mode:
|
||||
|
||||
```sh
|
||||
dejima --isolated # no forge, pushes nothing
|
||||
dejima --isolated --forge gitea --workflow worktree-pr # clones, pushes, opens a PR
|
||||
```
|
||||
|
||||
Order does not matter: `--forge gitea --isolated` means the same thing, and
|
||||
naming a forge brings the credentials back with it.
|
||||
|
||||
Two rows surprise people. `[github] token_file` is withheld even though it is
|
||||
not a forge setting. And an `[identity.<name>] ssh_key` is mounted even though
|
||||
the forwarded agent is not, so a session with no forge still holds a key that
|
||||
can reach a host over ssh - withholding that key too, for any launch whose
|
||||
workflow never pushes, is
|
||||
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
|
||||
|
||||
The agent row has a condition worth reading twice: it is withdrawn when the
|
||||
workflow does not push **or** a usable key is configured. So the second
|
||||
command above - a pushing workflow, on a host with no `ssh_key` - does forward
|
||||
your agent into an isolated session.
|
||||
|
||||
The table is what this mode changes, not everything that crosses. `LOKI_URL`
|
||||
still reaches the session, and the model credential follows the rules in
|
||||
[docs/security.md](security.md#the-model-api-credential-never-enters-the-container)
|
||||
whatever the mode.
|
||||
|
||||
**Why each of those is the way it is**, and why this is a narrower blast
|
||||
radius rather than a stronger sandbox, is in
|
||||
[docs/security.md](security.md#a-session-with-no-project-mounted). Worth
|
||||
reading before relying on this mode.
|
||||
|
||||
## Reading what a session left behind
|
||||
|
||||
When a session compacts, the summary it writes is the only durable account of
|
||||
@@ -1140,22 +1284,33 @@ without the reason it is normally off (see
|
||||
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
|
||||
so the reason is supplied where it will be read.
|
||||
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
|
||||
any `git push` whose resolved remote isn't the Gitea host, and any push to
|
||||
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
|
||||
against an overeager agent, not a hard security boundary - a session has full
|
||||
shell access and could in principle edit `.git/config` to route around it.
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks any `git
|
||||
push` whose resolved remote is not one of the configured forge's hosts, and any
|
||||
push straight to a branch the workflow protects. Neither list is named here:
|
||||
both are resolved host-side from the selected forge and workflow, so this is
|
||||
the same rule whichever forge is in use. Like the other hooks, this is a
|
||||
fast-fail guard against an overeager agent, not a hard security boundary - a
|
||||
session has full shell access and could in principle edit `.git/config` to
|
||||
route around it.
|
||||
GitHub push is blocked by default too (see
|
||||
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
|
||||
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
|
||||
against pushes to main/master, use branch protection on the remote itself.
|
||||
|
||||
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
|
||||
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
|
||||
above: a merge puts a branch on `main` without ever performing a push, so
|
||||
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
|
||||
conversation, so it can't tell an instructed merge from an unprompted one -
|
||||
which is why it's scoped to the sandbox rather than applied everywhere. On the
|
||||
Both this hook and the PATH shadow beside it read the decision out of
|
||||
`docker/guard-lib.sh`, which is the only copy of the rules. A push that the
|
||||
library cannot read is refused rather than passed through: an unresolvable
|
||||
remote, `--mirror`, and `--git-dir` or `--work-tree`, each of which names a
|
||||
repository other than the one the remote would be resolved in.
|
||||
|
||||
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) applies the configured
|
||||
forge's policy inside the sandbox: the blocked merge command, and a command
|
||||
aimed at a repository the session was not launched against. It's the API-side
|
||||
counterpart to the rule above: a merge puts a branch on `main` without ever
|
||||
performing a push, so `git-push-guard.sh` never sees it. A hook is handed a
|
||||
command rather than a conversation, so it can't tell an instructed merge from
|
||||
an unprompted one - which is why it's scoped to the sandbox rather than
|
||||
applied everywhere. On the
|
||||
host it does nothing, and you can still ask an agent to merge. The same
|
||||
fast-fail caveat applies with more force than usual here: the session has the
|
||||
Gitea token in its environment and could call the merge endpoint with `curl`
|
||||
|
||||
+44
-35
@@ -25,9 +25,10 @@ run them.
|
||||
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
|
||||
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches. Turns the reported command into argv and asks `docker/guard-lib.sh` |
|
||||
| `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/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), the agent-written disclosure, and a command aimed at another repository - to a reported command |
|
||||
| `docker/guard-lib.sh` | The push and merge policy itself, read out of argv. The two hooks above and the two PATH shadows all call it, so a bypass is fixed once rather than twice |
|
||||
| `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 |
|
||||
@@ -49,8 +50,8 @@ run them.
|
||||
| `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 |
|
||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-version-lib.sh` | Which release a checkout is on, from `git describe` rather than from any file in the tree - so it cannot claim a release a checkout has been pulled past. Sourced by `bin/dejima` (`--version`, the banner) and `bin/dejima-update-check` (what the remote has that this does not). Not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
@@ -58,6 +59,7 @@ run them.
|
||||
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
|
||||
| `docs/research-review.md` | When to check dejima's reasoning against the field's, the standing questions that are the agenda, and the queue of bypasses not yet run against this implementation |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
| `CHANGELOG.md` | What each release changed, for one question: should I update. A release is a date and the tag of that name on `main` is the only record of it - see `docs/release.md` §6 |
|
||||
|
||||
`bin/dejima` is the entry point: it resolves the project path,
|
||||
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
|
||||
@@ -106,27 +108,29 @@ notification path carries no sandbox-controlled byte, and that no marker
|
||||
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
|
||||
`bin/dejima-audit`'s own diffing.
|
||||
|
||||
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
|
||||
need no Docker either: `config.bats` covers `~/.config/dejima/config`
|
||||
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
|
||||
since the fast CI job's container deliberately has no daemon), `providers.bats`
|
||||
covers the forge/workflow/harness manifest contract documented in
|
||||
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
|
||||
proxy's own request handling. `conversation.bats` runs
|
||||
`bin/dejima-conversation-ship` against a real transcript and a real HTTP
|
||||
endpoint standing in for Loki, and asserts on what arrives: that a session's
|
||||
lines ship, that a torn line waits until it is whole, that a sibling project
|
||||
whose directory name merely extends this one is not swept up with it, and
|
||||
that neither the in-memory batch nor the unread tail is lost to the kill that
|
||||
ends a session.
|
||||
`config.bats`, `providers.bats`, `proxy.bats`, `launch.bats` and `share.bats`
|
||||
round out the suites that need no Docker either: `config.bats` covers
|
||||
`~/.config/dejima/config` parsing and `install.sh`'s legacy-variable warnings
|
||||
(stubbing `docker` itself, since the fast CI job's container deliberately has
|
||||
no daemon), `providers.bats` covers the forge/workflow/harness manifest
|
||||
contract documented in [docs/providers.md](providers.md), `proxy.bats` covers
|
||||
the credential proxy's own request handling, `launch.bats` the command line a
|
||||
launch builds, and `share.bats` the shared scratch directory's own layout.
|
||||
`conversation.bats` runs `bin/dejima-conversation-ship` against a real
|
||||
transcript and a real HTTP endpoint standing in for Loki, and asserts on what
|
||||
arrives: that a session's lines ship, that a torn line waits until it is whole,
|
||||
that a sibling project whose directory name merely extends this one is not
|
||||
swept up with it, and that neither the in-memory batch nor the unread tail is
|
||||
lost to the kill that ends a session.
|
||||
|
||||
One assertion is worth calling out because it can only run for real on a
|
||||
Docker host: `runtime.bats` proves that a service bound to the host's
|
||||
loopback is unreachable from inside a container while one bound to the
|
||||
bridge gateway *is* reachable. The second half is a positive control - it is
|
||||
what stops the first from passing in an environment where the container
|
||||
reaches nothing at all. That pair is the ttyd front door's whole containment
|
||||
argument, so run it before trusting a front door you just set up.
|
||||
Docker host: `runtime.bats` proves that a service on the host is unreachable
|
||||
from inside a container - bound to loopback, and bound to the bridge gateway
|
||||
on a port the policy was not told to open. A second container, given that one
|
||||
port, reaches it; that is the positive control, and it is what stops the
|
||||
others from passing in an environment where the container reaches nothing at
|
||||
all. That set is the ttyd front door's whole containment argument, so run it
|
||||
before trusting a front door you just set up.
|
||||
|
||||
Every mutation in `runtime.bats` (firewall rules, address-set entries) happens
|
||||
inside the test container's own network namespace, same as any other
|
||||
@@ -178,10 +182,12 @@ 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`,
|
||||
`conversation.bats`, `project.bats`, `providers.bats`, `proxy.bats`,
|
||||
`remote.bats` and `static.bats`, runs anywhere a shell does, including inside
|
||||
a dejima sandbox itself, which has no Docker daemon by design.
|
||||
`ci-runner.bats` needs something rarer - a real CI runner of either tier - and
|
||||
skips itself anywhere else. Everything in between runs anywhere a shell does,
|
||||
including inside a dejima sandbox itself, which has no Docker daemon by design.
|
||||
`test/run-confined.sh` discovers that set rather than naming it, so a suite
|
||||
added under `test/` is in the lane the day it lands - which is also why no list
|
||||
of them is kept here.
|
||||
|
||||
`.gitea/workflows/tests.yml` also runs `shellcheck` over every shell script in
|
||||
the repo, as its own job. `test/shellcheck.sh` holds the file list, discovered
|
||||
@@ -199,9 +205,10 @@ obscurely partway through; the file says why each is needed.
|
||||
## Adding a new harness
|
||||
|
||||
`[core] harness` picks a directory under `harnesses/`, and that harness's
|
||||
manifest declares eleven keys (see [providers.md](providers.md)). The
|
||||
inventory below is everything dejima does that is shaped by *which* harness is
|
||||
selected: which parts are the manifest's, which are the plugin's, and
|
||||
manifest declares what dejima needs to know about it - every key is in
|
||||
[providers.md](providers.md). The inventory below is everything dejima does
|
||||
that is shaped by *which* harness is selected: which parts are the manifest's,
|
||||
which are the plugin's, and
|
||||
which are still written against Claude Code by name. Taken from a pass over
|
||||
the tree, so each row names the file rather than describing a sketch - and a
|
||||
row that says "in the plugin" is one a second harness supplies rather than one
|
||||
@@ -245,12 +252,14 @@ R2 was a hard gate until the guards stopped needing it. Two of the three it
|
||||
hosted - the push guard and the merge guard - are now PATH shadows over `git`
|
||||
and the forge CLI (`docker/git-guard.sh`, `docker/forge-guard.sh`), which hold
|
||||
whatever the harness can or cannot intercept, and read argv rather than
|
||||
regex-matching a reconstructed command string. That left one guard depending
|
||||
on interception, and it is the one this repository already describes as "a
|
||||
fast-fail guard against an overeager agent doing something obviously
|
||||
destructive, not a boundary against one that's actively trying to get around
|
||||
them" (docs/security.md). A gate that costs a harness its support should be
|
||||
guarding more than that.
|
||||
regex-matching a reconstructed command string. The hooks beside them read argv
|
||||
too: they split the reported command into words and ask the same library
|
||||
(`docker/guard-lib.sh`), so the overlap cannot become a disagreement. That
|
||||
left one guard depending on interception, and it is the one this repository
|
||||
already describes as "a fast-fail guard against an overeager agent doing
|
||||
something obviously destructive, not a boundary against one that's actively
|
||||
trying to get around them" (docs/security.md). A gate that costs a harness its
|
||||
support should be guarding more than that.
|
||||
|
||||
Declaring one absent is a real decision with a visible cost, not a
|
||||
formality. It is there so a harness can be supported honestly at a smaller
|
||||
|
||||
+31
-12
@@ -127,8 +127,8 @@ operator can set, resolved the same way as any other config key
|
||||
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
|
||||
| `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 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. |
|
||||
| `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 reported command for the harnesses that can, through the same library, so the two cannot disagree. 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 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. **Enforced only for a CLI that also appears in `merge_block` or `disclose`:** those two are what name the commands the shadow is linked over and the hook checks against, so a forge declaring this and neither of them would get the rule applied to nothing. |
|
||||
| `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. |
|
||||
@@ -234,10 +234,11 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
|
||||
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
|
||||
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
|
||||
| `assumed_backend` | `?provider:backend` | (empty) | Which backend this harness talked to before `[harness.<name>] backend` existed. Read only when that key is unset, and it writes into the operator's config rather than being consulted at launch - the one key here that does. |
|
||||
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
|
||||
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
|
||||
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `no_confirm_args` | `?args` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?args` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
|
||||
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. Substitution is not recursive and the `template` type has no `=`, so a resume command that needs a second placeholder or an environment variable goes in the harness's briefing fragment instead, where both resolve. |
|
||||
| `transcript_update` | `enum:append\|rewrite` | `append` | How this harness's transcript files change, which decides how they can be read incrementally. `append` means the file only grows, one record per line, so the conversation shipper reads forward from a byte offset and consumes only whole lines. `rewrite` means the file is replaced wholesale, where an offset would land in the middle of a document - it is re-read when it changes and the records already shipped are skipped by count. Declared rather than sniffed, because append semantics against a rewritten file ship garbage. |
|
||||
@@ -437,12 +438,21 @@ Every `{{placeholder}}` in the assembled text must be on a fixed whitelist
|
||||
(`pr_command`, `issue_command`, `forge_name`, `forge_ssh_host`,
|
||||
`blocked_branches`, `container_id`, `project_dir`, `workspace_dir`,
|
||||
`session_id`, `resume_command`, `writable_mounts`, `network_allowlist`,
|
||||
`no_confirm_args`, `harness_state_dir`) - an unrecognized one is a
|
||||
`no_confirm_args`, `harness_state_dir`, `workspace_posture`,
|
||||
`project_posture`) - an unrecognized one is a
|
||||
hard refusal, not a literal pass-through. `{{session_id}}` and
|
||||
`{{container_id}}` are deliberately left as `@@SESSION_ID@@`/
|
||||
`@@CONTAINER_ID@@` markers instead of being resolved here, since neither is
|
||||
known until the container is actually running.
|
||||
|
||||
`workspace_posture` and `project_posture` are what
|
||||
[`--isolated`](configuration.md#isolated-sessions) changes. The preamble's
|
||||
claims about the mount, about `~/shared` being shared with every session
|
||||
against this project, and about the transcript outliving the container are
|
||||
substituted rather than written inline, because an isolated launch makes all
|
||||
three false - and a session that believes them leaves work for a resume that
|
||||
cannot happen.
|
||||
|
||||
### How the briefing reaches the session
|
||||
|
||||
**A session-start hook is a size-limited channel, which is why it is not the
|
||||
@@ -508,9 +518,10 @@ itself:
|
||||
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
|
||||
|
||||
`hooks/git-push-guard.sh` reads `DEJIMA_PUSH_HOSTS` and
|
||||
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as a
|
||||
regex-escaped literal command prefix. Two different failure directions,
|
||||
both deliberate:
|
||||
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as
|
||||
the merge subcommand a workflow blocks, matched against the parsed argv rather
|
||||
than against the command string. Two different failure directions, both
|
||||
deliberate:
|
||||
|
||||
- `DEJIMA_PUSH_HOSTS` fails **closed** - empty means no host is allowed, so
|
||||
every push is refused. A no-forge or non-pushing workflow becomes
|
||||
@@ -518,8 +529,11 @@ both deliberate:
|
||||
- `DEJIMA_BLOCKED_BRANCHES` fails **open** - empty means no branch is
|
||||
protected, which is correct for a workflow with genuinely no branch
|
||||
policy.
|
||||
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
|
||||
`merge-guard.sh` inert rather than conditionally installed.
|
||||
- `DEJIMA_MERGE_BLOCK` fails **open** for the merge it names - empty blocks no
|
||||
merge, which is correct for a workflow with no merge policy. The guard
|
||||
itself stays active either way: it also adds the agent-written disclosure
|
||||
and holds a command to the repository the session is for, and neither of
|
||||
those is the workflow's to turn off.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -675,8 +689,13 @@ knowing about because they are what a second forge costs:
|
||||
as such, and a count of what was hidden. Everything else `gitea-pr` does has
|
||||
a `gh` subcommand already.
|
||||
- **`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`
|
||||
gitea CLIs resolve the repository from `origin` and refuse an origin that is
|
||||
not on the configured host, so there is no flag or variable to compare and
|
||||
nothing for this guard to declare. That is a narrower confinement than it
|
||||
sounds: `origin` lives in `.git/config`, inside the read-write project mount,
|
||||
and the gitea token is account-wide - so a session that rewrites `origin` to
|
||||
another repository **on the same host** reaches it. What bounds that is the
|
||||
token's scope, as it is for `gh`. `gh` takes `--repo`
|
||||
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
|
||||
|
||||
+71
-1
@@ -101,7 +101,7 @@ why each is not a test:
|
||||
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
|
||||
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
|
||||
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
|
||||
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
|
||||
| `web/` | `cd web && node test/browser/sess-button.mjs`, then load it on a phone; nothing here can see a soft keyboard |
|
||||
| `harnesses/` | Launch the harness against a real model |
|
||||
|
||||
## 5. The release pull request
|
||||
@@ -118,3 +118,73 @@ looks like the safety net is not one. Name the keys. A queued privileged job, a
|
||||
manual check that was skipped, a forge that has still never been launched -
|
||||
those are the things a reader of `main` is entitled to know, and the release
|
||||
PR is where they are still true and still remembered.
|
||||
|
||||
Worth its own line too: **what an existing config stops being able to do.** A
|
||||
release that tightens a boundary can refuse an arrangement that worked, with
|
||||
nothing in the operator's config changed - and the ones that fail closed are
|
||||
the ones to name, because failing closed is quiet. An observability path is
|
||||
the worst case: a log shipper that can no longer reach its endpoint stops
|
||||
shipping and nothing else happens. Name the arrangement, what it now needs,
|
||||
and where the refusal shows up.
|
||||
|
||||
## 6. The version, and the entry in the changelog
|
||||
|
||||
A release is a date, and the tag of that name on `main` is the only record of
|
||||
it. Two steps, both at merge time, both easy to forget because nothing fails
|
||||
without them:
|
||||
|
||||
```sh
|
||||
git tag 2026.10.12 <the merge commit on main>
|
||||
git push origin 2026.10.12
|
||||
```
|
||||
|
||||
and in the same pull request, rename `CHANGELOG.md`'s `## Unreleased` heading
|
||||
to that date and open a fresh empty one above it. The date is the day it
|
||||
merges, so it cannot be written before the merge is made - which is why the
|
||||
heading waits rather than being guessed.
|
||||
|
||||
**There is nothing else to bump.** `dejima --version` reads `git describe`, so
|
||||
the tag is the version: no file records it, and none can disagree with it. A
|
||||
checkout pulled past a release reports `2026.10.12+37`, which is the honest
|
||||
answer for a checkout on no release.
|
||||
|
||||
**A second release on the same day gets a letter** - `2026.10.12b`, then `c`.
|
||||
Rare, and the format otherwise has no answer: the date is the whole name.
|
||||
`sort -V` orders it after the bare date, which is what the update notice
|
||||
compares with, so nothing else has to know about the shape. A letter rather
|
||||
than a fourth dotted number, because four dot-separated numbers beginning
|
||||
with a year and a month read as a private IPv4 address.
|
||||
|
||||
What an entry is for is one question - should I update, and what will I be
|
||||
getting - so it names what an operator would notice rather than what changed.
|
||||
The pull requests carry the detail and are linked from it. A tag is a record
|
||||
of what shipped, not a version anyone may stay on; `CHANGELOG.md` says so at
|
||||
the top, and `SECURITY.md` is where that is binding.
|
||||
|
||||
## 7. The GitHub mirror
|
||||
|
||||
`mirror/github-mirror.sh` publishes `main` and the tags on it, for this
|
||||
repository and every other one in the organisation, to
|
||||
[github.com/dejima-org](https://github.com/dejima-org) from the operator's
|
||||
host, hourly, so a release needs no step here. Issues, pull requests and
|
||||
Actions are off there; all of that stays on the forge. A new repository is
|
||||
mirrored once it is added to `MIRROR_REPOS` in the script.
|
||||
|
||||
Each mirror's history is the forge's `main` with AI co-author trailers taken
|
||||
out of the commit messages, so GitHub credits no tool as a contributor, and
|
||||
with the values in the operator's scrub file replaced throughout. That file
|
||||
stays on the host, because the list is the thing it hides. The commits keep
|
||||
their authors and dates but not their SHAs, so a forge SHA does not resolve on
|
||||
GitHub. On top sits one commit that points repository links and clone URLs at
|
||||
GitHub, lets the README wordmark follow the reader's
|
||||
theme, and says at the top of the README that it is a mirror. A README change
|
||||
that breaks one of those rewrites stops that repository's mirror rather than
|
||||
publishing the forge's version. Here, `test/mirror.bats` fails on it first; in
|
||||
the other repositories, the hourly run fails and names it.
|
||||
|
||||
Setup is in the header of `mirror/dejima-github-mirror.service`. Once the
|
||||
GitHub repositories exist, `github-mirror.sh --settings` turns their features
|
||||
off, and `--dry-run` builds what a sync would publish without pushing it. A
|
||||
sync refuses a mirror whose `main` is not built on the history it would
|
||||
publish, which happens when the rewrite rules change or the scrub file gains a
|
||||
value; `--reset` force-pushes over it.
|
||||
|
||||
+171
-17
@@ -52,11 +52,16 @@ running.
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
`docker/egress-policy` deliberately permits traffic to the session'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 with the fact above
|
||||
(a tmux client is a host shell) and the consequence is direct: **a front door
|
||||
the sandbox can reach is a compromised session's own escape hatch.**
|
||||
The session's default-route address - the Docker bridge gateway - is your
|
||||
host, and it is the one host address a sandbox can route to at all. Combine
|
||||
that with the fact above (a tmux client is a host shell) and the consequence
|
||||
is direct: **a front door the sandbox can reach is a compromised session's own
|
||||
escape hatch.**
|
||||
|
||||
`docker/egress-policy` opens no port on that address unless told to, so a
|
||||
front door bound to it is refused by the packet filter as well. Do not spend
|
||||
that: the clamp is the second layer, and binding the proxy correctly is the
|
||||
first.
|
||||
|
||||
So:
|
||||
|
||||
@@ -900,10 +905,11 @@ invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
|
||||
|
||||
**The marker directory is a write channel into your runtime tmpfs**, and
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
|
||||
physical memory, so a session filling `/run/dejima-notify` degrades your
|
||||
whole login session - D-Bus, the systemd user manager - not just dejima. A
|
||||
session can already fill the workspace mount, so this is an increment rather
|
||||
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
|
||||
physical memory, so a session filling `${XDG_RUNTIME_DIR}/dejima/notify`
|
||||
degrades your whole login session - D-Bus, the systemd user manager - not just
|
||||
dejima. A session can already fill the workspace mount, so this is an increment
|
||||
rather 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.
|
||||
|
||||
@@ -1007,30 +1013,178 @@ debounce instead.
|
||||
than arrow keys, because a phone soft keyboard handles those far better.
|
||||
`--list` prints one tab-separated row per live session for scripting, and a
|
||||
run-id argument attaches directly, which is what the deep link and `ttyd`'s
|
||||
`--url-arg` produce. Rows carry the run id, the tmux label, the workspace
|
||||
name, the session's age, its state, and the container id.
|
||||
`--url-arg` produce. Oldest first in both: the numbers are how a session is
|
||||
picked, so the order holds still rather than following the state.
|
||||
|
||||
**The picker shows three columns - name, state, age** - and the name comes
|
||||
first, against the index, because those two are what you match up. A `--list`
|
||||
row carries everything: the run id, the session name, the tmux label, the
|
||||
workspace name, the age in minutes, the state, and the container id. The label
|
||||
and the container id are deliberately not in the picker. Carrying all five made
|
||||
a row about eighty characters wide, which on a phone wrapped onto a second line
|
||||
and put the state and the age underneath the *next* session's number; and
|
||||
neither is something to act on - picking a session goes to its window for you,
|
||||
and every container id shares a prefix with every other.
|
||||
|
||||
The name column is whatever the terminal has room for after the rest of the
|
||||
row, so a desktop shows every name whole and a phone shows as much as it can.
|
||||
A name that did not fit is cut with a `~`, because two projects can differ only
|
||||
past the cut and a row that said nothing about it would be worse than a short
|
||||
column. `--list` always carries the whole name.
|
||||
|
||||
**Every session has a name, and `dejima --name <text>` is how you choose it.**
|
||||
Without the flag it is the project directory's name, which is what both front
|
||||
doors showed before and is the right answer for the usual one-session case.
|
||||
|
||||
**A name already in use gets the first free `-N`**, so a second session
|
||||
against the same project is `myproject-2` rather than a second row reading
|
||||
`myproject` - and rather than a launch that refuses to start, which would be a
|
||||
worse answer than an ambiguous row. The first *free* suffix: a number a
|
||||
finished session gives back is used again, instead of the count climbing for
|
||||
the life of the host. Collisions are read from the live registry and from the
|
||||
tmux window list, because each is blind to a case the other sees. A name you
|
||||
passed explicitly is taken verbatim, collision or not; you typed it for this
|
||||
launch.
|
||||
|
||||
Nothing reserves a name, so two launches in the same instant can still land on
|
||||
one. That costs a pair of rows that read alike, which is where this started
|
||||
rather than a failure, and the alternative is a lock held across a cold image
|
||||
build.
|
||||
|
||||
A derived name is cut down where an explicit one is refused: control
|
||||
characters are stripped and the whole thing is truncated to 32 characters,
|
||||
leaving room for the suffix. A directory was not named for this purpose, so a
|
||||
launch that stopped because a path was long would be refusing over something
|
||||
it can fix itself.
|
||||
|
||||
The name is also the **tmux window name**, which is the part worth having even
|
||||
with remote access off: `choose-window`, the status line and the window list
|
||||
all show that name, and for a dozen sandboxes they otherwise show a dozen
|
||||
copies of whatever `automatic-rename` makes of the container command. A window
|
||||
you renamed yourself is left alone unless the launch passed `--name`.
|
||||
|
||||
The picker is coloured (gruvbox) when stdout is a terminal that says it can
|
||||
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
|
||||
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
|
||||
parses those rows itself and the README calls them scriptable.
|
||||
parses those rows itself and `dejima-attach --help` calls them scriptable.
|
||||
|
||||
Three states, from a registry `dejima` writes on launch and removes on
|
||||
exit:
|
||||
The state column answers two questions with one word, because they do not
|
||||
overlap: a session that cannot be attached to has nothing useful to say about
|
||||
whether it wants attention, and for one that can, "attachable" is the answer
|
||||
to a question nobody asked.
|
||||
|
||||
Reachability, from a registry `dejima` writes on launch and removes on exit:
|
||||
|
||||
- `attachable` - a live tmux pane to join.
|
||||
- `stale` - registered, but its pane is gone. Listed rather than hidden,
|
||||
since the registry is a hint and not truth: the exit trap doesn't run
|
||||
under `SIGKILL` or a host crash.
|
||||
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
|
||||
attach to. Still listed, deliberately, rather than silently omitted.
|
||||
|
||||
And for a session with a pane to join, what it last said about itself:
|
||||
|
||||
- `waiting` - it finished a turn, or asked for something, and has not been
|
||||
given anything since. Both of the harness events the hook reports,
|
||||
[`done` and `waiting`](#notifications), land here: they are one thing to an
|
||||
operator, who does the same about either.
|
||||
- `working` - it was given a prompt and has not stopped.
|
||||
- `starting` - its image is still being built, so there is no session in there
|
||||
to attach to yet. In the picker only: `--list` says this in its container
|
||||
column, which the picker does not carry.
|
||||
- `attachable` - it has said nothing yet. A harness that reports no state at
|
||||
all stays here for its whole life, and so does a session between launching
|
||||
and its first prompt.
|
||||
|
||||
That reporting is the notification hook writing one file into a directory of
|
||||
its own, overwritten rather than consumed: the markers the
|
||||
[notification watcher](#notifications) consumes are gone the moment it pushes
|
||||
them, and with no watcher configured nothing reads them at all. So the states
|
||||
above work whether or not you use notifications, which is the point - they are
|
||||
the answer to "which sessions need me" for an operator who is at the machine.
|
||||
|
||||
It is the session reporting on itself, so it is best-effort by construction. A
|
||||
harness killed mid-turn leaves `working` behind, and the age column is the
|
||||
thing to read next to it.
|
||||
|
||||
Entries whose container has gone away are pruned as they're listed. When
|
||||
Docker can't be asked at all, nothing is pruned and the container column
|
||||
reads `?` - an unanswerable question is never treated as a "no".
|
||||
|
||||
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
|
||||
joining yours. Grouped sessions share windows but track their own current
|
||||
### Watching all of them, and getting to one
|
||||
|
||||
`dejima-attach --watch` is the same picker redrawn every five seconds. One
|
||||
window showing which sessions are working and which are waiting, with the same
|
||||
numbers to go to one. A redraw is a full listing, which is two questions to
|
||||
Docker and one to tmux however many sessions are running.
|
||||
|
||||
`dejima-attach --count-waiting` reduces it to a number, for a tmux status line:
|
||||
|
||||
```tmux
|
||||
# ~/.tmux.conf - how many sessions are waiting for you, in the status bar
|
||||
set -g status-interval 10
|
||||
set -ag status-right ' waiting: #(dejima-attach --count-waiting)'
|
||||
```
|
||||
|
||||
That path reads the registry, the pane list and the reported states and asks
|
||||
Docker nothing, because the point of the number is not having to open
|
||||
anything. What it gives up is noticing a session whose container died without
|
||||
taking its launcher with it - every listing shows that one as gone - and
|
||||
telling a session whose image is still building from one that is simply quiet;
|
||||
both read as `attachable`.
|
||||
|
||||
`--counts` answers for every state in the same single pass, because two
|
||||
numbers on a status bar would otherwise cost two invocations of something that
|
||||
runs on a timer:
|
||||
|
||||
```console
|
||||
$ dejima-attach --counts
|
||||
waiting=3 working=5 attachable=8 stale=0 no-tmux=0 total=16
|
||||
```
|
||||
|
||||
The words are the picker's, so a number on a status bar and a row on a screen
|
||||
say the same thing about the same session. Formatting is yours; `cut` is
|
||||
enough for the usual case, and nothing here decides how your status bar looks:
|
||||
|
||||
```tmux
|
||||
set -ag status-right ' #(dejima-attach --counts | cut -d" " -f1,2)'
|
||||
```
|
||||
|
||||
`--state-dir` prints where the registry and the reported states live, for
|
||||
anything on this host that would rather read the files than run this. It is
|
||||
under `$XDG_RUNTIME_DIR` when there is one and under `/tmp` when there is not,
|
||||
so it is not a path to hard-code:
|
||||
|
||||
```console
|
||||
$ ls "$(dejima-attach --state-dir)"/notify/*/level
|
||||
```
|
||||
|
||||
The filename is the state, and it is the harness event rather than the word
|
||||
the picker shows: `done` or `waiting` is a session waiting for you, `running`
|
||||
is one working.
|
||||
|
||||
**The SESS button does not reload the page.** ttyd takes the run id as a
|
||||
WebSocket argument rather than as part of the page, so pressing it closes the
|
||||
socket and opens another without that argument, which is `dejima-attach` with
|
||||
no argument, which is the picker. The half a megabyte of inlined terminal
|
||||
stays loaded and stays running; on a phone, fetching and re-running it was
|
||||
most of the wait. Closing the socket is also what ends the session you are
|
||||
leaving, exactly as navigating away used to: ttyd kills the command when its
|
||||
connection goes.
|
||||
|
||||
The terminal is reset on the way, and the mode tracker with it. What the
|
||||
socket carries away is the process, not the state it left behind in the
|
||||
terminal - an alternate screen, mouse reporting, a cursor key mode - and the
|
||||
picker would otherwise open into whatever the session had switched on.
|
||||
|
||||
**Inside tmux, the picker switches your client to the session's own window**
|
||||
rather than attaching. tmux refuses to nest a session inside one of its own
|
||||
panes, so on your own machine the picker would otherwise list every session
|
||||
and reach none of them - and switching is what makes it the window switcher
|
||||
that a dozen parallel sessions actually need. `prefix` + a window number stops
|
||||
at nine; this does not.
|
||||
|
||||
Attaching, on a client with no tmux of its own, creates a **grouped** tmux
|
||||
session (`dejima-<run-id>`) rather than joining yours. Grouped sessions share windows but track their own current
|
||||
window, so the phone can sit on one sandbox while the desktop sits on
|
||||
another, and the throwaway carries `destroy-unattached`, so it disposes of
|
||||
itself when the phone detaches or the connection drops.
|
||||
|
||||
+162
-12
@@ -34,7 +34,9 @@ than the thing inferred from silence.
|
||||
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
|
||||
Run on a laptop with no VM layer, the row below the container is the
|
||||
operator's own machine, and dejima has nothing to say about it. See
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes),
|
||||
and [Using dejima on its own](threat-model.md#using-dejima-on-its-own) for
|
||||
what still holds there and what to do about the rest.
|
||||
- **Anything finer than a session.** The unit of isolation is one container for
|
||||
one session. An MCP server, a hook, or a tool the session runs inherits the
|
||||
session's whole reach - dejima reports that a session *added* an MCP server
|
||||
@@ -87,7 +89,7 @@ last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
@@ -176,8 +178,8 @@ the sandbox at it instead of at you:
|
||||
- `[forge.gitea] token_file`: that account's token, rather than one issued
|
||||
under yours.
|
||||
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
|
||||
agent. See "Headless git push without agent forwarding" in
|
||||
[docs/configuration.md](configuration.md).
|
||||
agent - which is then not mounted at all. See "Headless git push without
|
||||
agent forwarding" in [docs/configuration.md](configuration.md).
|
||||
|
||||
With the two identities separated, the same settings stop being decorative:
|
||||
|
||||
@@ -202,8 +204,7 @@ collaborator on the repositories it works on. Write is the ceiling here, not
|
||||
a floor: an account that owns a repository, or holds admin on it, can merge
|
||||
its own work and edit the settings in the table above, so every row of that
|
||||
table goes back to being decorative. `write:repository` and `write:issue` are
|
||||
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
|
||||
about that changes for a collaborator.
|
||||
the scopes its token needs, and nothing about that changes for a collaborator.
|
||||
|
||||
One consequence presents as a misconfiguration, and the obvious fix is the
|
||||
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
|
||||
@@ -279,7 +280,9 @@ layout it is unpacked with, all consumed by the image build - so a session
|
||||
that edits one chooses what the next launch downloads and installs as root.
|
||||
`backends/*/backend.conf` names the upstream every request is forwarded to and
|
||||
the host path the credential is read from, so an edit there redirects where a
|
||||
real credential is sent.
|
||||
real credential is sent. `mirror/github-mirror.sh` runs on the host from a
|
||||
timer with a key that can push to every repository in the GitHub organisation,
|
||||
which is why its unit installs a copy rather than linking into the checkout.
|
||||
|
||||
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`
|
||||
@@ -322,6 +325,52 @@ can make; it is one the audit reports. See
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
|
||||
### A session with no project mounted
|
||||
|
||||
`dejima --isolated` removes this whole class rather than bounding it: there is
|
||||
no mounted project to be written, no `.git` to plant a hook in, and no
|
||||
harness state directory on the host for a session to leave anything in. Its
|
||||
working directory is container-local and its `~/shared` is made fresh for that
|
||||
session alone.
|
||||
|
||||
**It narrows the forge credential too**, which is the widest thing a session
|
||||
holds: a Gitea token is account-wide (see
|
||||
[To your forge, the sandbox is you](#to-your-forge-the-sandbox-is-you)), so an
|
||||
ordinary session can read and write every repository its operator can.
|
||||
`--isolated` sets `forge` to `none` and `workflow` to `local-only` for that
|
||||
launch, and blanks the session's copy of `[github] token_file` - core's
|
||||
read-only GitHub credential, which crosses for every ordinary session
|
||||
whatever forge is selected, and which is read-only across the operator's
|
||||
*private* repositories rather than scoped to one. So no forge credential of
|
||||
either kind reaches an isolated session, and nothing tells it to push.
|
||||
|
||||
Naming a forge on the same command line brings that back: it is the operator
|
||||
saying this session is meant to reach one, and nothing is narrowed from there.
|
||||
The filter container keeps the GitHub token either way, because that is what
|
||||
authenticates dejima's own fetch of GitHub's address ranges - a request about
|
||||
the allowlist, made outside the session's reach.
|
||||
|
||||
**The forwarded agent is usually not mounted; a configured key always is.**
|
||||
The agent is withdrawn when the workflow does not push **or** a usable key is
|
||||
configured, and `--isolated` defaults to a workflow that does not push - so
|
||||
the ordinary isolated launch forwards no agent. Name a pushing workflow on a
|
||||
host with no `[identity.<name>] ssh_key`, which the example above does, and it
|
||||
is forwarded like any other pushing launch. The flag does not decide this; the
|
||||
workflow and the key do.
|
||||
|
||||
A configured key is mounted and loaded whatever the workflow, so an isolated
|
||||
session with no forge still holds a key that can reach a host over ssh.
|
||||
Withholding that for any launch whose workflow never pushes is
|
||||
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
|
||||
|
||||
So: a narrower blast radius on the host and a narrower credential, not a
|
||||
stronger sandbox. The firewall, the credential boundary and the capability
|
||||
drops are unchanged, and a container escape is exactly as serious. The cost is
|
||||
that the conversation log, the epitaph and the exit-time audit are all off,
|
||||
because each reads host state this mode does not mount. See
|
||||
[Isolated sessions](configuration.md#isolated-sessions).
|
||||
|
||||
## The exit-time audit
|
||||
|
||||
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
|
||||
@@ -508,6 +557,13 @@ record, so there is no slice to take - dejima gives each workspace a data
|
||||
directory of its own instead. Both reach the same place: a session sees its
|
||||
own project's history and no other's.
|
||||
|
||||
That is about the per-project state each harness keeps. It is not true of
|
||||
`~/.claude/backups`, `tasks`, `plans` and `sessions`, which are mounted
|
||||
read-write and shared by every project's sessions - what a session writes
|
||||
there, another project's session can read. See
|
||||
[#380](https://gitbox.branderson.io/dejima/dejima/issues/380), which has what
|
||||
was observed and why only the first of the four was free to fix.
|
||||
|
||||
### Claude Code: a slice of your own tree
|
||||
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
@@ -726,6 +782,17 @@ reads it host-side per request instead. Without it the proxy would
|
||||
withhold one file while dejima copied another, on every launch and whatever
|
||||
backend was selected.
|
||||
|
||||
**The same staging narrows the `projects` map to the launching project.** That
|
||||
map keys per-project state by absolute path, so copying all of it told a
|
||||
session the name of every project on the host, a sample of each one's
|
||||
filenames, and what each had cost - from inside a container whose claim is
|
||||
that it cannot learn what you do elsewhere. The launching project's path and
|
||||
anything under it are kept, because a session's working directory is routinely
|
||||
a worktree inside it. Both halves run on the one path that stages this file,
|
||||
including the re-seed for a backend whose credential IS this file and whose
|
||||
proxy is off: that one keeps the credential and narrows the map exactly as the
|
||||
other does.
|
||||
|
||||
**The residual the first two share is use-while-running.** A session that has
|
||||
been turned can spend your quota, ask the model anything, and read the answers
|
||||
- through the proxy, without ever holding the credential. That is inherent to
|
||||
@@ -782,7 +849,22 @@ reach the model - which reads as a dejima fault rather than a missing
|
||||
prerequisite. Sign in on the host first; see the README's
|
||||
[Sign in on the host first](../README.md#sign-in-on-the-host-first).
|
||||
Refreshing an expired credential is the same: on the host, not from inside a
|
||||
session, where the credential deliberately never goes.
|
||||
session, where the credential deliberately never goes. A session that outlives
|
||||
its credential - one left open overnight is the ordinary way - meets a 502 from
|
||||
the proxy naming that as the remedy, and the same line is the last thing the
|
||||
proxy logs, so the exit audit repeats it on the host terminal.
|
||||
|
||||
**What the proxy may tell a session about a credential failure.** Anything it
|
||||
learned from the provider stays on the host: a response body, a status line, an
|
||||
exception. What crosses into the container is text this repository wrote, and
|
||||
only when it names something the operator has to do - "log in again on the
|
||||
host" is useful to an agent that would otherwise report itself broken, and
|
||||
discloses nothing the session did not already know. The distinction is the
|
||||
credential proxy's `CredentialNeedsOperator`, which carries the two separately
|
||||
so neither can be sent by accident. The proxy's own log is bound by the same
|
||||
rule - it keeps provider statuses and error-body prefixes for the exit audit -
|
||||
so it lives outside the socket directory, which is the one proxy path the
|
||||
session mounts.
|
||||
|
||||
### The refreshing OAuth path, in detail
|
||||
|
||||
@@ -967,8 +1049,10 @@ reasoning below is what both are built on.
|
||||
failing cleanly (confirmed against a real read-only copy of this file -
|
||||
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
|
||||
Instead, `dejima` generates a throwaway copy of the file at
|
||||
launch (seeded from the real file, same "generate a sandbox-specific
|
||||
file on the host" pattern as the generated settings override layer)
|
||||
launch (seeded from the real file with the credential dropped and the
|
||||
`projects` map narrowed to this project - see "staged, not mounted" above -
|
||||
the same "generate a sandbox-specific file on the host" pattern as the
|
||||
generated settings override layer)
|
||||
and mounts *that* read-write in its place; the copy is discarded,
|
||||
unmodified in the real file, when the session ends. A compromised
|
||||
session can add whatever it wants to `mcpServers` (or anything else in
|
||||
@@ -999,6 +1083,9 @@ reasoning below is what both are built on.
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
Setting it also withdraws the forwarded agent: `SSH_AUTH_SOCK` is not passed
|
||||
to the container, so the two paths are alternatives rather than both being
|
||||
open at once.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `[core] ssh_key` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
@@ -1219,7 +1306,9 @@ On start, the filter container establishes an allowlist-only egress policy
|
||||
everything the session may reach expressed as named sets. Allowed by default:
|
||||
|
||||
- DNS resolution via a local proxy (see "DNS is allowlisted too")
|
||||
- the Docker host itself (not its whole subnet)
|
||||
- nothing on the Docker host. Its address is a named destination so that
|
||||
reaching for it is counted and refused rather than falling off the end of the
|
||||
chain, but no port on it is open (see "The host entry" below)
|
||||
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
|
||||
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
|
||||
(`platform.claude.com` for Claude Code; Cline declares none, so a session
|
||||
@@ -1286,6 +1375,16 @@ of it loads. A tool that will not run and a family that is not there are
|
||||
different conditions, and reporting the second on evidence of the first is how
|
||||
a boundary comes to be described rather than held.
|
||||
|
||||
The startup self-check also resolves every permitted name and reads each answer
|
||||
back out of the address set its class names, refusing to start if one is
|
||||
missing. A name that resolves and whose address is not where the ports are
|
||||
keyed to would be a session that reaches a configured destination only by
|
||||
accident, or not at all - and the class an entry carries, the resolver line
|
||||
built from it, and the set the rules use are three separate statements that can
|
||||
each be right about a different one. A name that returns no address is reported
|
||||
rather than fatal: that is the world being unavailable, not the policy
|
||||
disagreeing with itself.
|
||||
|
||||
The startup self-check asserts the shape of the ruleset, not a connection that
|
||||
fails: a v6 connection fails identically on a host with no v6 route, so a probe
|
||||
alone would pass whether the family is refused or merely absent.
|
||||
@@ -1293,6 +1392,56 @@ alone would pass whether the family is refused or merely absent.
|
||||
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
||||
Docker host itself can resolve it: containers inherit the host's resolver.
|
||||
|
||||
### The host entry
|
||||
|
||||
Every other destination in the policy is there because something declared it.
|
||||
The host entry is not: it is the gateway of the container's default route,
|
||||
derived from the routing table. Docker publishes container ports on that
|
||||
address, so permitting it on every port would reach the host's sshd, anything
|
||||
bound to `0.0.0.0`, and every published port of every other container on the
|
||||
machine - including dejima's own remote-access front door, which is a host
|
||||
shell behind a tmux prefix key.
|
||||
|
||||
So it opens no ports. The address is still named in the ruleset, with its own
|
||||
refusal rather than a fall-through to the end of the chain, so that a session
|
||||
reaching for the host shows up as a counted refusal instead of an anonymous
|
||||
one.
|
||||
|
||||
Nothing in a launch needs it open. A host-resident forge, a host-resident Loki
|
||||
and a self-served model endpoint are each reached through their own entry - by
|
||||
name, or as a literal address - and each lands in a set of its own with its own
|
||||
rules. Configuring one of those is how a host service becomes reachable, and
|
||||
it is that service that becomes reachable rather than the host: the clamp is
|
||||
matched **before** the wider class rules, so an address that is both the
|
||||
host's and an open-class entry's is still held to these ports.
|
||||
|
||||
`EGRESS_HOST_PORTS`, set in the environment you launch from, takes TCP ports
|
||||
and low-high ranges, comma or space separated. `EGRESS_HOST_PORTS=any`
|
||||
restores the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none`
|
||||
removes the entry altogether. A value that is not a port stops the container
|
||||
rather than becoming a rule nobody meant.
|
||||
|
||||
**A destination you pointed at the host's own address gets its port added for
|
||||
you.** The two processes split that decision because neither knows both
|
||||
halves. The launcher knows which destinations this launch configured - a
|
||||
`[logging] loki_url`, a backend's `base_url` - and offers the ones it is sure
|
||||
of: an explicit `:port` on a literal address. The filter, which works the
|
||||
host's address out from its default route, keeps the port of each candidate
|
||||
whose address is the host's. Neither resolves a name.
|
||||
|
||||
What that leaves out is deliberate in both directions. A scheme default is
|
||||
not an explicit port, because a remote `https://loki.example.com` has none
|
||||
and adding 443 for it would open the host's own 443 - which on a box
|
||||
following
|
||||
[Behind another reverse proxy](remote-access.md#behind-another-reverse-proxy)
|
||||
is the proxy in front of the web terminal. And **a host-resident service
|
||||
configured by name needs its port set here by hand**, because a name is a
|
||||
destination neither process will resolve to decide what to open.
|
||||
|
||||
The policy's summary line is where a missing port shows up: `host <addr> on
|
||||
ports none` is why nothing can reach it - the session, and the log shipper
|
||||
that runs beside it.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
Resolution itself is restricted, not just the IP connections made
|
||||
@@ -1494,7 +1643,8 @@ execution, and the audit reports the config change it took.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
||||
If `[logging] loki_url` is set (see [docs/configuration.md](configuration.md)),
|
||||
every DNS query and every new
|
||||
outbound connection attempt - allowed or rejected - is shipped to that Loki
|
||||
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
|
||||
|
||||
|
||||
+78
-1
@@ -110,7 +110,7 @@ if nothing is arranged against it, the last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
@@ -244,6 +244,8 @@ and no answer at all where it does not.
|
||||
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
|
||||
On a laptop, dejima is a hardened container and the honest description of what
|
||||
is under it is "your home directory, and dejima has nothing to say about it".
|
||||
[Using dejima on its own](#using-dejima-on-its-own) is what that leaves you
|
||||
with, and what to do about it.
|
||||
|
||||
**Load-bearing for what, exactly**, because the field's tiering invites a
|
||||
reading that project does not accept and has never claimed. A per-workload VM
|
||||
@@ -263,6 +265,81 @@ repository's behalf.
|
||||
**What this repository can do about that is say so**, which is the whole of
|
||||
this section. Nothing in dejima can bound its own host.
|
||||
|
||||
## Using dejima on its own
|
||||
|
||||
Most installs are this: dejima on a laptop or a workstation, and none of the
|
||||
sister projects. Everything above about the rows below the container is true,
|
||||
and it is about one threat, the least likely one on the list.
|
||||
|
||||
### What holds
|
||||
|
||||
The container row, in full. Nothing in it depends on another project.
|
||||
|
||||
- **Disk:** the workspace, the mounts you declare, and this project's own slice
|
||||
of harness state. Not your home directory, not `~/.ssh`, not your other
|
||||
projects ([security.md](security.md#one-projects-session-cannot-reach-anothers)).
|
||||
- **Network:** deny-by-default egress. No port on the host is open unless you
|
||||
named one, and a permitted name that answers with a private address is
|
||||
refused unless it is a destination you configured, so the LAN is out of
|
||||
reach unless you put it there
|
||||
([security.md](security.md#network-egress-firewall)).
|
||||
- **The model credential** stays on the host behind the credential proxy
|
||||
([security.md](security.md#the-model-api-credential-never-enters-the-container)).
|
||||
- **The forge:** whatever the token and key you hand the session can reach.
|
||||
- **Afterwards:** the exit-time audit reports changes to the host-side files a
|
||||
session could use to run code later, before your next `git` in that repo
|
||||
([security.md](security.md#the-exit-time-audit)).
|
||||
|
||||
Against [the ranked list](#ranked-by-what-is-actually-likely): items 1, 2 and 4
|
||||
are the container row's, and are handled the same with or without the stack.
|
||||
Item 3 depends on where your CI runs, not on your machine. Item 5 is a person
|
||||
deciding, with or without the stack. Item 6, escape, is the one the
|
||||
devbox-side projects exist for.
|
||||
|
||||
### What does not
|
||||
|
||||
- **An escape lands on your machine, as you.** Your home directory, your
|
||||
network position, your credentials. Nothing here bounds that.
|
||||
- **Pushing is code execution wherever your CI runs**, if the project has CI
|
||||
([security.md](security.md#ci-push-access-is-code-execution-elsewhere)).
|
||||
- **Nothing watches the machine between sessions.** The audit covers the
|
||||
surfaces it lists, for the window the session ran.
|
||||
|
||||
### Measured caution
|
||||
|
||||
None of these needs another project.
|
||||
|
||||
1. **Keep the kernel and the container runtime patched.** An escape needs an
|
||||
unpatched host or a working zero-day, and the first is the one you control.
|
||||
2. **Point repos at dejima that you would run yourself.** That is
|
||||
[an assumption](#what-this-tool-assumes) the rest rests on.
|
||||
3. **Give the sandbox its own identity**, with tokens scoped to the
|
||||
repositories it works on, so the forge's reach is what you granted rather
|
||||
than all of yours ([security.md](security.md#give-the-sandbox-its-own-identity)).
|
||||
4. **Decide about CI before granting push.** Hosted CI, a runner you are
|
||||
willing to lose, or no push at all (`[core] forge = none` with
|
||||
`[core] workflow = local-only`). Not a self-hosted runner with your LAN
|
||||
behind it ([ci.md](ci.md)).
|
||||
5. **Treat what a session hands you as its writing:** a command it asks you to
|
||||
run, a domain or dependency it asks you to approve, and every file in
|
||||
`share_dir` or a read-write mount. See
|
||||
[what mounting it costs you](remote-access.md#what-mounting-it-costs-you)
|
||||
before syncing those anywhere a desktop will open them.
|
||||
6. **Read the audit before your next `git` in that repo.** `[audit] strict`
|
||||
refuses a headless launch while CRITICAL findings are unacknowledged.
|
||||
7. **Leave the credential proxy on.** `proxy = off` is the one arrangement that
|
||||
puts the real model credential in the container.
|
||||
|
||||
### When the rest is worth adding
|
||||
|
||||
- **dejima-devbox-hardener**, when your machine sits on a network with things
|
||||
you would mind an escape reaching. It enforces from outside a VM, so the
|
||||
first step is moving dejima into one.
|
||||
- **dejima-ci-hardener, or hosted CI**, as soon as sessions push to a project
|
||||
whose CI you run yourself.
|
||||
- **dejima-guard**, when sessions run unattended long enough that "would I
|
||||
notice?" matters, and you have a second machine to run it from.
|
||||
|
||||
## Why a container, and not a microVM
|
||||
|
||||
The most frequent question about this project.
|
||||
|
||||
@@ -35,9 +35,12 @@ api_domains =
|
||||
|
||||
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
|
||||
# nowhere to live, and it has no session lifecycle signal dejima can turn into
|
||||
# a notification. push-guard and merge-guard are not listed because no harness
|
||||
# hosts them any more - they are PATH shadows and hold regardless. Launch
|
||||
# names what this costs; see harness_report_guards.
|
||||
# a notification.
|
||||
#
|
||||
# push-guard and merge-guard are not listed because this harness cannot host
|
||||
# them, not because nothing does - claude-code declares both, and the PATH
|
||||
# shadows cover a harness that does not. Declaring none here costs the hook
|
||||
# layer, not the guard. Launch names the cost; see harness_report_guards.
|
||||
guards =
|
||||
|
||||
# Both, which is what makes this harness the backend-agnostic one: it has a
|
||||
|
||||
@@ -221,7 +221,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
|
||||
Tell the user to run, outside the sandbox:
|
||||
dejima-project approve \"$project_dir\"
|
||||
(or, for one domain right now:
|
||||
dejima-allow --container $container_id <domain>)"
|
||||
dejima-allow --container $container_id <domain>...)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+22
-2
@@ -20,7 +20,7 @@ set -euo pipefail
|
||||
# Sanitizing an arbitrary name into a marker would make the file name a
|
||||
# channel the sandbox controls, which is the one thing this hook must not be.
|
||||
case "${1:-}" in
|
||||
waiting|done) ;;
|
||||
waiting|done|running) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
@@ -32,4 +32,24 @@ esac
|
||||
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
|
||||
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
|
||||
|
||||
: > "$DEJIMA_NOTIFY_DIR/$1"
|
||||
# The notification edge, for dejima-notify-watch to consume and push. Two
|
||||
# names, because that is the set the watcher iterates: a marker it never looks
|
||||
# for is a file nothing ever removes.
|
||||
case "$1" in
|
||||
waiting|done) : > "$DEJIMA_NOTIFY_DIR/$1" ;;
|
||||
esac
|
||||
|
||||
# The level: which of those the session is in NOW, rather than that it just
|
||||
# changed. Nothing consumes it, so it answers "which sessions need me" for a
|
||||
# picker or a status line, and it does that whether or not a watcher is
|
||||
# running - the edge above is gone the moment one is. Still a name and no
|
||||
# content, and still only ever about this session: the directory is this run's
|
||||
# own, so the worst a compromised session can do here is misreport itself.
|
||||
#
|
||||
# Siblings first, then the new one: a reader between the two calls sees no
|
||||
# state rather than two, and "unknown" is a thing the readers already render.
|
||||
[ -d "$DEJIMA_NOTIFY_DIR/level" ] || exit 0
|
||||
for _other in waiting 'done' running; do
|
||||
[ "$_other" = "$1" ] || rm -f "$DEJIMA_NOTIFY_DIR/level/$_other"
|
||||
done
|
||||
: > "$DEJIMA_NOTIFY_DIR/level/$1"
|
||||
|
||||
+35
-155
@@ -3,19 +3,19 @@
|
||||
# DEJIMA_PUSH_HOSTS, and never directly to a branch listed in
|
||||
# DEJIMA_BLOCKED_BRANCHES - both resolved host-side by bin/dejima from the
|
||||
# configured forge and workflow (see dejima-provider-lib.sh), not hardcoded
|
||||
# to any one forge. This is a fast-fail guard against an overeager agent,
|
||||
# not a hard security boundary - a sandbox session has full shell access
|
||||
# and could in principle edit .git/config or re-export these variables to
|
||||
# route around it; that is true today and is not a regression, and the
|
||||
# firewall (see docker/egress-policy) is the actual boundary. No-op outside the
|
||||
# sandbox (DEJIMA unset), since this restriction doesn't apply to normal
|
||||
# host usage.
|
||||
# to any one forge.
|
||||
#
|
||||
# DEJIMA_PUSH_HOSTS fails CLOSED: empty means no host is allowed, so every
|
||||
# push is refused. That is deliberate - it is how a no-forge or
|
||||
# non-pushing workflow becomes enforced rather than merely described.
|
||||
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected,
|
||||
# which is correct for a workflow that genuinely has no branch policy.
|
||||
# The rules themselves are docker/guard-lib.sh, which docker/git-guard.sh
|
||||
# applies to the same push as a PATH shadow. This file's whole job is to turn
|
||||
# the command string a harness reports into the argv that library reads, so
|
||||
# that the two cannot reach different verdicts - which they had already started
|
||||
# to do.
|
||||
#
|
||||
# A fast-fail guard against an overeager agent, not a hard security boundary: a
|
||||
# sandbox session has full shell access and could in principle edit .git/config
|
||||
# or re-export these variables to route around it, and the firewall (see
|
||||
# docker/egress-policy) is the actual boundary. No-op outside the sandbox
|
||||
# (DEJIMA unset), since this restriction doesn't apply to normal host usage.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
@@ -25,151 +25,31 @@ input=$(cat)
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
cwd=$(echo "$input" | jq -r '.cwd // empty')
|
||||
|
||||
# Only care about `git push` invocations - found by walking tokens rather than
|
||||
# by matching a shape.
|
||||
# shellcheck source=../docker/guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# A push can be the second half of a compound command, and only a segment whose
|
||||
# first word is git is a git invocation. Every segment that is one gets the same
|
||||
# verdict function the shadow calls, on the words after `git`, so git's own
|
||||
# options do not need anticipating here: they are argv either way.
|
||||
#
|
||||
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
|
||||
# nothing else, so every other spelling of git's own options - `git -c k=v
|
||||
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
|
||||
# far as this hook was concerned, and it exited 0 before any check ran. The
|
||||
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
|
||||
# which is why this was a quiet gap in the earlier warning rather than an open
|
||||
# one.
|
||||
#
|
||||
# Split on the shell separators first, because a push can be the second half
|
||||
# of a compound command, and only the segment that STARTS with git is a git
|
||||
# invocation - "echo push" is not.
|
||||
push_seen=0
|
||||
segment=""
|
||||
IFS=$'\n' read -rd '' -a _segments < <(
|
||||
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
|
||||
printf '\0')
|
||||
for seg in ${_segments[@]+"${_segments[@]}"}; do
|
||||
read -ra _t <<< "$seg"
|
||||
[ "${#_t[@]}" -gt 0 ] || continue
|
||||
[ "${_t[0]}" = git ] || continue
|
||||
_i=1
|
||||
while [ "$_i" -lt "${#_t[@]}" ]; do
|
||||
case "${_t[$_i]}" in
|
||||
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
_i=$((_i + 1)) ;;
|
||||
-*) ;;
|
||||
push) push_seen=1; segment="$seg"; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
_i=$((_i + 1))
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] && break
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] || exit 0
|
||||
# Everything below reads the git invocation, not the whole command line.
|
||||
command="$segment"
|
||||
|
||||
repo_dir="$cwd"
|
||||
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
|
||||
repo_dir="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# One pass over the whole command, the way the shadow walks argv: skip git's
|
||||
# own options until the first token that IS the subcommand, then take the
|
||||
# first non-flag token as the remote and the second as the refspec.
|
||||
#
|
||||
# Splitting on the first occurrence of the substring "push" is what this
|
||||
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
|
||||
# push-fix, and anything else carrying the word. What came out was a nonsense
|
||||
# remote, which then took the unresolvable path below.
|
||||
#
|
||||
# Word-splitting rather than a full shell parse, so quoting edge cases are not
|
||||
# handled - the same pragmatic tradeoff as the sibling
|
||||
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
|
||||
# refuses rather than guesses.
|
||||
#
|
||||
# `-c key=value` is kept rather than dropped: git resolves where a push goes
|
||||
# from the config the push runs under, so asking without these answers for a
|
||||
# different configuration. `git -c remote.origin.pushurl=... push origin
|
||||
# branch` is that case.
|
||||
config_overrides=()
|
||||
positional=()
|
||||
read -ra tokens <<< "$command"
|
||||
t=0
|
||||
seen_push=0
|
||||
while [ "$t" -lt "${#tokens[@]}" ]; do
|
||||
tok="${tokens[$t]}"
|
||||
if [ "$seen_push" -eq 0 ]; then
|
||||
case "$tok" in
|
||||
-c) t=$((t + 1))
|
||||
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
|
||||
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
t=$((t + 1)) ;;
|
||||
push) seen_push=1 ;;
|
||||
*) ;;
|
||||
esac
|
||||
else
|
||||
case "$tok" in
|
||||
-*) ;;
|
||||
*) positional+=("$tok") ;;
|
||||
esac
|
||||
fi
|
||||
t=$((t + 1))
|
||||
done
|
||||
|
||||
remote="origin"
|
||||
refspec=""
|
||||
if [ "${#positional[@]}" -ge 1 ]; then
|
||||
remote="${positional[0]}"
|
||||
fi
|
||||
if [ "${#positional[@]}" -ge 2 ]; then
|
||||
refspec="${positional[1]}"
|
||||
fi
|
||||
|
||||
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
|
||||
# remote.<name>.url when it is set, and reading the latter checks a host the
|
||||
# push is not going to. See docker/git-guard.sh, which asks the same question.
|
||||
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
|
||||
if [ -z "$remote_url" ]; then
|
||||
# Refused, not passed through. A remote that does not resolve is usually a
|
||||
# typo git would reject in a moment anyway - but it is also what a
|
||||
# disagreement between this parser and git's own looks like, and the
|
||||
# failure of that disagreement has to be a refusal rather than a push
|
||||
# nobody checked. docker/git-guard.sh takes the same direction; the two
|
||||
# answering differently was the bug.
|
||||
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
|
||||
|
||||
host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
if [ "$host" = "$allowed_host" ]; then
|
||||
host_allowed=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
echo "Blocked by git-push-guard.sh: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
|
||||
# (if no refspec was given at all, or it's HEAD) the current branch - `git
|
||||
# push origin` and `git push origin HEAD` both push the current branch.
|
||||
if [[ "$refspec" == *:* ]]; then
|
||||
branch="${refspec#*:}"
|
||||
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
|
||||
branch="$refspec"
|
||||
else
|
||||
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
|
||||
fi
|
||||
branch="${branch#refs/heads/}"
|
||||
|
||||
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
if [ "$branch" = "$blocked_branch" ]; then
|
||||
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
|
||||
# What a segment's FIRST word has to be is still git. A `FOO=bar git push`
|
||||
# prefix, or `env git push`, is a git invocation this does not see - the PATH
|
||||
# shadow does, which is the division of labour the pair exists for rather than
|
||||
# a gap in it.
|
||||
# Basenamed, like hooks/merge-guard.sh and like the shadow, which reads its own
|
||||
# $0. `/usr/bin/git push origin main` is the one spelling that reaches neither
|
||||
# layer otherwise - the absolute path steps over the PATH shadow, and comparing
|
||||
# the whole word meant this hook did not see a git invocation at all. It is
|
||||
# also the form the library's own header names as the reason both layers exist.
|
||||
while IFS= read -r segment; do
|
||||
read -ra tokens <<< "$segment"
|
||||
[ "${#tokens[@]}" -gt 0 ] || continue
|
||||
[ "$(basename -- "${tokens[0]}")" = git ] || continue
|
||||
if ! dejima_push_verdict "$cwd" "${tokens[@]:1}"; then
|
||||
echo "Blocked by git-push-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
done < <(dejima_guard_segments "$command")
|
||||
|
||||
exit 0
|
||||
|
||||
+51
-22
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside dejima, block a merge command matching
|
||||
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
|
||||
# bin/dejima from the configured forge and workflow, never hardcoded to one
|
||||
# forge. What lands on the default branch is an operator decision, as it
|
||||
# already is for `git push`; this is the API-side counterpart to
|
||||
# git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
# PreToolUse hook: inside dejima, apply the forge policy to a forge CLI call -
|
||||
# the merge a workflow blocks, and a command aimed at a repository the session
|
||||
# was not launched against. What lands on the default branch is an operator
|
||||
# decision, as it already is for `git push`; this is the API-side counterpart
|
||||
# to git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
#
|
||||
# The rules are docker/guard-lib.sh, which docker/forge-guard.sh applies to the
|
||||
# same command as a PATH shadow. This file's job is to turn the command string
|
||||
# a harness reports into the argv that library reads, so the two cannot reach
|
||||
# different verdicts. Named for the merge block because that is the rule it was
|
||||
# created for; the rest came with the shared policy.
|
||||
#
|
||||
# Not conditional on intent: a hook is handed a command, not a conversation,
|
||||
# so it cannot tell an instructed merge from an unprompted one. The line is
|
||||
@@ -16,32 +21,56 @@
|
||||
# shell access and the forge token, so it could call the merge endpoint with
|
||||
# curl. It stops the mistake, not the attack.
|
||||
#
|
||||
# Fails OPEN - empty means nothing is blocked, so a workflow with no
|
||||
# merge-block policy genuinely has none rather than the hook guessing.
|
||||
# Fails OPEN - a forge that declares no policy has none blocked, rather than
|
||||
# the hook guessing.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command=$(jq -r '.tool_input.command // empty')
|
||||
|
||||
# DEJIMA_MERGE_BLOCK is a literal command prefix from the forge manifest,
|
||||
# not a regex - escape its regex metacharacters before matching so a value
|
||||
# like ".*" matches only that literal text, never "everything".
|
||||
escaped_block=$(printf '%s' "$DEJIMA_MERGE_BLOCK" | sed -E 's/[][(){}.*+?^$\\|]/\\&/g')
|
||||
# shellcheck source=../docker/guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# The leading class includes '/' so a path-qualified call is caught too -
|
||||
# the Dockerfile installs each forge's CLI under /usr/local/bin, and that's
|
||||
# what an agent reaching past a shadowed PATH would type. Word-splitting
|
||||
# rather than a full shell parse, the same pragmatic tradeoff the sibling
|
||||
# hook makes.
|
||||
if [[ "$command" =~ (^|[[:space:]&\|\;/])$escaped_block([[:space:]]|$) ]]; then
|
||||
echo "Blocked by merge-guard.sh: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
|
||||
exit 2
|
||||
fi
|
||||
forge_commands="$(dejima_forge_commands | sort -u)"
|
||||
[ -n "$forge_commands" ] || exit 0
|
||||
|
||||
# Basenamed, so a path-qualified call is checked too - the Dockerfile installs
|
||||
# each forge's CLI under /usr/local/bin, and the real one sits behind the
|
||||
# shadow at a fixed path, which is what an agent reaching past a shadowed PATH
|
||||
# would type. It is also what the shadow itself compares, since that reads its
|
||||
# own basename.
|
||||
#
|
||||
# A later word counts only when it names a path, which is what divides the two
|
||||
# layers: a bare `gh` anywhere in a segment is the shadow's, because reaching
|
||||
# it means executing what PATH resolves, while a path names the real binary
|
||||
# directly and the shadow never runs. `env /usr/local/lib/dejima/forge/gh pr
|
||||
# merge 7` is both at once - a prefix word hiding the first position, and a
|
||||
# path stepping over the shadow - so without the path case it belonged to
|
||||
# neither. `echo gh pr merge 3` stays allowed, and must: it names the CLI
|
||||
# without running it, and the shadow it would run is not being bypassed.
|
||||
while IFS= read -r segment; do
|
||||
read -ra tokens <<< "$segment"
|
||||
for i in "${!tokens[@]}"; do
|
||||
case "$i:${tokens[$i]}" in
|
||||
0:*) ;;
|
||||
*:*/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
self="$(basename -- "${tokens[$i]}")"
|
||||
grep -qxF -- "$self" <<< "$forge_commands" || continue
|
||||
if ! dejima_forge_verdict "$self" "${tokens[@]:$((i + 1))}"; then
|
||||
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
done < <(dejima_guard_segments "$command")
|
||||
|
||||
exit 0
|
||||
|
||||
+3
-1
@@ -140,7 +140,9 @@ else
|
||||
# only what the operator has actually decided.
|
||||
{
|
||||
printf '# dejima configuration. See README.md.\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n'
|
||||
printf '# This file holds what you have decided, not every key there is.\n'
|
||||
printf '# defaults.conf beside it is the complete reference, rewritten by\n'
|
||||
printf '# every ./install.sh - do not edit that one.\n'
|
||||
printf '#\n'
|
||||
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
|
||||
printf '# this file states what it selects rather than inheriting a future\n'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# User unit for the GitHub mirror (#209), on a host that can push to GitHub.
|
||||
# Never in a dejima session: a sandbox may push only to its own forge.
|
||||
#
|
||||
# install -m 755 mirror/github-mirror.sh ~/.local/bin/dejima-github-mirror
|
||||
# cp mirror/dejima-github-mirror.service mirror/dejima-github-mirror.timer ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now dejima-github-mirror.timer
|
||||
#
|
||||
# A copy, not a link into the checkout: the timer holds a key that can push to
|
||||
# every repository in the organisation, and a session working in this
|
||||
# checkout can edit the script. Reinstall it after reviewing an update.
|
||||
#
|
||||
# ~/.config/dejima/github-mirror.env sets MIRROR_SOURCE, the forge
|
||||
# organisation each repository name is appended to, and whatever else
|
||||
# mirror/github-mirror.sh documents. The GitHub key belongs behind an ssh Host
|
||||
# alias named in MIRROR_TARGET, so it is not also offered to the forge. A
|
||||
# deploy key covers one repository, so this one is an account's key with push
|
||||
# access to every repository in the organisation:
|
||||
#
|
||||
# MIRROR_SOURCE=git@forge.example.com:dejima
|
||||
# MIRROR_TARGET=git@github-mirror:dejima-org
|
||||
#
|
||||
# A sync opens several connections per repository. One multiplexed connection
|
||||
# to GitHub's port 443 endpoint keeps a network that throttles bursts of
|
||||
# outbound ssh from timing out every repository after the first:
|
||||
#
|
||||
# Host github-mirror
|
||||
# HostName ssh.github.com
|
||||
# Port 443
|
||||
# User git
|
||||
# IdentityFile ~/.ssh/id_ed25519
|
||||
# IdentitiesOnly yes
|
||||
# ControlMaster auto
|
||||
# ControlPath ~/.ssh/cm-%C
|
||||
# ControlPersist 60
|
||||
#
|
||||
# ~/.config/dejima/github-mirror.scrub lists values replaced throughout the
|
||||
# published history, one "value replacement" pair per line. Adding one changes
|
||||
# history already published, so the next run refuses until it is given
|
||||
# --reset, once, by hand:
|
||||
#
|
||||
# gitea.internal.example gitea.example.com
|
||||
# 10.9.8. 192.0.2.
|
||||
[Unit]
|
||||
Description=Mirror the dejima repositories' main and release tags to GitHub
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=%h/.config/dejima/github-mirror.env
|
||||
ExecStart=%h/.local/bin/dejima-github-mirror
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Mirror the dejima repositories' main and release tags to GitHub, hourly
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publishes main and its release tags of every dejima repository to a
|
||||
# read-only mirror on GitHub (#209).
|
||||
#
|
||||
# MIRROR_SOURCE=<forge org URL> mirror/github-mirror.sh sync every repository
|
||||
# ... mirror/github-mirror.sh --dry-run build and print, push nothing
|
||||
# ... mirror/github-mirror.sh --reset replace a mirror built on other history
|
||||
# mirror/github-mirror.sh --settings configure the GitHub repos (needs gh)
|
||||
# mirror/github-mirror.sh --transform DIR rewrite a checkout in place
|
||||
#
|
||||
# Runs on the host, never in a session: a sandbox may push only to its own
|
||||
# forge, and this needs push access to GitHub.
|
||||
#
|
||||
# The GitHub copy is not byte-for-byte. Its history is the forge's main with
|
||||
# AI co-author trailers taken out of the commit messages, so no tool is
|
||||
# credited as a contributor, and the MIRROR_SCRUB values replaced in every
|
||||
# file and message (rewrite_history). That changes the SHA of every commit
|
||||
# from the first one touched on, but the same input always makes the same
|
||||
# commits. On top sits one extra commit, whose tree is the forge's with the
|
||||
# rewrites in transform() applied, and whose parents are the rewritten commit
|
||||
# and the previous mirror commit, so each sync is a fast-forward for anyone
|
||||
# tracking it. A tag on main is published as a lightweight tag on the
|
||||
# rewritten commit it maps to.
|
||||
#
|
||||
# A mirror whose main is not built on that history - an earlier rule, or a
|
||||
# forge main that was rewound - is refused. --reset force-pushes over it.
|
||||
#
|
||||
# Environment:
|
||||
# MIRROR_SOURCE forge URL each repository name is appended to, as
|
||||
# $MIRROR_SOURCE/<name> (required to sync)
|
||||
# MIRROR_ORG GitHub organisation, default dejima-org
|
||||
# MIRROR_TARGET push URL prefix, default git@github.com:$MIRROR_ORG
|
||||
# MIRROR_REPOS repositories, by their name on both sides
|
||||
# MIRROR_STATE working bare repositories, default ~/.cache/dejima-github-mirror
|
||||
# MIRROR_SCRUB values to replace throughout the published history, one
|
||||
# "value replacement" pair per line, default
|
||||
# ~/.config/dejima/github-mirror.scrub. Kept on the host
|
||||
# because the list itself is what it hides.
|
||||
set -euo pipefail
|
||||
|
||||
MIRROR_ORG="${MIRROR_ORG:-dejima-org}"
|
||||
MIRROR_TARGET="${MIRROR_TARGET:-git@github.com:$MIRROR_ORG}"
|
||||
MIRROR_REPOS="${MIRROR_REPOS:-dejima dejima-badge dejima-ci-hardener dejima-devbox-hardener dejima-guard .github}"
|
||||
MIRROR_STATE="${MIRROR_STATE:-${XDG_CACHE_HOME:-$HOME/.cache}/dejima-github-mirror}"
|
||||
MIRROR_SCRUB="${MIRROR_SCRUB:-${XDG_CONFIG_HOME:-$HOME/.config}/dejima/github-mirror.scrub}"
|
||||
export MIRROR_ORG MIRROR_SCRUB
|
||||
|
||||
NAME=""
|
||||
say() { echo "github-mirror: ${NAME:+$NAME: }$*"; }
|
||||
die() { say "$*" >&2; exit 1; }
|
||||
|
||||
# Rewrites a fast-export stream. Commit messages lose Co-Authored-By trailers
|
||||
# crediting Claude or anything else at anthropic.com, and every data block -
|
||||
# file contents and messages - has the MIRROR_SCRUB values replaced, matched
|
||||
# only where the value does not continue a longer name or number. Data
|
||||
# needing neither change passes through byte for byte, so its commit keeps
|
||||
# its SHA.
|
||||
rewrite_history() {
|
||||
perl -e '
|
||||
binmode STDIN; binmode STDOUT;
|
||||
my @pairs;
|
||||
if (open my $fh, "<", $ENV{MIRROR_SCRUB}) {
|
||||
while (<$fh>) {
|
||||
next if /^\s*(?:#|$)/;
|
||||
my ($from, $to) = split " ";
|
||||
defined $to or die "MIRROR_SCRUB: no replacement on line $.\n";
|
||||
push @pairs, [qr/(?<![\w.-])\Q$from\E/, $to];
|
||||
}
|
||||
}
|
||||
my $in_commit = 0;
|
||||
while (my $line = <STDIN>) {
|
||||
if ($line =~ /^commit /) {
|
||||
$in_commit = 1;
|
||||
} elsif ($line =~ /^data (\d+)$/) {
|
||||
my $len = $1;
|
||||
read(STDIN, my $data, $len) == $len or die "short data block\n";
|
||||
if ($in_commit
|
||||
&& $data =~ s/^Co-Authored-By:[^\n]*(?:claude|anthropic\.com)[^\n]*(?:\n|\z)//mgi) {
|
||||
$data =~ s/\s+\z/\n/;
|
||||
}
|
||||
$data =~ s/$_->[0]/$_->[1]/g for @pairs;
|
||||
$in_commit = 0;
|
||||
print "data ", length($data), "\n", $data;
|
||||
next;
|
||||
}
|
||||
print $line;
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# Fails if a MIRROR_SCRUB value survives in a tree about to be published.
|
||||
check_scrubbed() {
|
||||
[ -f "$MIRROR_SCRUB" ] || return 0
|
||||
local from to
|
||||
while read -r from to; do
|
||||
case "$from" in ''|'#'*) continue ;; esac
|
||||
if git -C "$1" grep -qIF -- "$from"; then
|
||||
die "a MIRROR_SCRUB value is still in the tree to publish"
|
||||
fi
|
||||
done < "$MIRROR_SCRUB"
|
||||
}
|
||||
|
||||
# The rewritten commit a forge commit became, from the two runs' marks.
|
||||
rewritten() {
|
||||
local mark
|
||||
mark="$(awk -v c="$1" '$2 == c { print $1; exit }' "$STATE/source.marks")"
|
||||
[ -n "$mark" ] || die "no rewritten commit for $1"
|
||||
awk -v m="$mark" '$1 == m { print $2; exit }' "$STATE/clean.marks"
|
||||
}
|
||||
|
||||
WORK=""
|
||||
STATE=""
|
||||
cleanup() {
|
||||
[ -n "$WORK" ] || return 0
|
||||
git -C "$STATE" worktree remove --force "$WORK" 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
|
||||
# Rewrites a checkout for GitHub. Each rewrite is checked, so a README that
|
||||
# drifted away from what this expects stops the mirror instead of publishing
|
||||
# the forge's version. Running it twice changes nothing, so a clone of the
|
||||
# mirror still passes test/mirror.bats.
|
||||
transform() {
|
||||
local dir="$1"
|
||||
local readme="$dir/README.md"
|
||||
# perl -i would write a symlink's target back as the README, publishing it.
|
||||
[ -f "$readme" ] && [ ! -L "$readme" ] || die "README.md is missing or a symlink in $dir"
|
||||
|
||||
# Repository links and clone URLs move to the org on GitHub, in code as
|
||||
# well as docs; issue and pull request links stay on the forge, which is
|
||||
# the only place those exist. Gitea's /src/branch/ is GitHub's /blob/, and
|
||||
# its /raw/branch/ GitHub's /raw/.
|
||||
# Exit 1 from git grep is no match, which a repository may well have. The
|
||||
# paths come from the forge, so -- keeps one from being read as a perl option.
|
||||
(cd "$dir" && { git grep -lzIF 'gitbox.branderson.io/dejima/' || [ $? -eq 1 ]; } | xargs -0r perl -pi -e '
|
||||
s{https://gitbox\.branderson\.io/dejima/([\w.-]++)(?!/(?:issues|pulls)/)}{https://github.com/$ENV{MIRROR_ORG}/$1}g;
|
||||
s{(https://github\.com/\Q$ENV{MIRROR_ORG}\E/[\w.-]+)/src/branch/}{$1/blob/}g;
|
||||
s{(https://github\.com/\Q$ENV{MIRROR_ORG}\E/[\w.-]+)/raw/branch/}{$1/raw/}g;
|
||||
' --)
|
||||
|
||||
# GitHub follows the reader's light/dark setting through <picture>, which
|
||||
# the forge cannot; see the comment above the tag in README.md.
|
||||
if [ -f "$dir/assets/dejima-wordmark-lightmode.png" ]; then
|
||||
perl -0pi -e '
|
||||
s{<img src="assets/dejima-wordmark-darkmode\.png" alt="dejima" height="72">}
|
||||
{<picture><source media="(prefers-color-scheme: dark)" srcset="assets/dejima-wordmark-darkmode.png"><img src="assets/dejima-wordmark-lightmode.png" alt="dejima" height="72"></picture>}
|
||||
' "$readme"
|
||||
grep -qF 'darkmode.png"><img src="assets/dejima-wordmark-lightmode.png"' "$readme" \
|
||||
|| die "README.md: the wordmark tag was not found"
|
||||
fi
|
||||
|
||||
# The first thing a visitor to a mirror does is open a pull request. Under
|
||||
# the rule after the wordmark, or under the title where there is none.
|
||||
grep -q '^> \*\*This is a read-only mirror' "$readme" || perl -0pi -e '
|
||||
s{^(---|# [^\n]*)\n}{$1\n\n> **This is a read-only mirror.** dejima is developed on a private forge,\n> and issues and pull requests are not accepted here. See\n> [CONTRIBUTING.md](https://github.com/$ENV{MIRROR_ORG}/dejima/blob/main/CONTRIBUTING.md).\n}m
|
||||
' "$readme"
|
||||
grep -q '^> \*\*This is a read-only mirror' "$readme" \
|
||||
|| die "README.md: no --- line or title to put the mirror notice under"
|
||||
}
|
||||
|
||||
# Issues, pull requests and Actions off. Settings rather than content, so a
|
||||
# sync does not reapply them; run once after creating the repositories.
|
||||
settings() {
|
||||
command -v gh >/dev/null || die "--settings needs the gh CLI"
|
||||
local repo
|
||||
for repo in $MIRROR_REPOS; do
|
||||
gh api -X PATCH "repos/$MIRROR_ORG/$repo" --silent \
|
||||
-F has_issues=false -F has_pull_requests=false -F has_projects=false \
|
||||
-F has_wiki=false -F has_discussions=false
|
||||
gh api -X PUT "repos/$MIRROR_ORG/$repo/actions/permissions" --silent -F enabled=false
|
||||
done
|
||||
}
|
||||
|
||||
sync_repo() {
|
||||
NAME="$1"
|
||||
STATE="$MIRROR_STATE/$NAME.git"
|
||||
local source="$MIRROR_SOURCE/$NAME" target="$MIRROR_TARGET/$NAME"
|
||||
local git=(git -C "$STATE")
|
||||
trap cleanup EXIT
|
||||
[ -d "$STATE" ] || git init -q --bare "$STATE"
|
||||
|
||||
"${git[@]}" fetch -q --no-tags --prune "$source" \
|
||||
'+refs/heads/main:refs/source/main' '+refs/tags/*:refs/source/tags/*'
|
||||
local src clean prev=""
|
||||
src="$("${git[@]}" rev-parse refs/source/main)"
|
||||
|
||||
# Rebuilt from the whole history each run.
|
||||
"${git[@]}" fast-export --signed-tags=strip --reencode=yes \
|
||||
--export-marks="$STATE/source.marks" \
|
||||
--refspec refs/source/main:refs/clean/main refs/source/main \
|
||||
| rewrite_history \
|
||||
| "${git[@]}" fast-import --quiet --force --export-marks="$STATE/clean.marks"
|
||||
clean="$("${git[@]}" rev-parse refs/clean/main)"
|
||||
|
||||
# An absent main (exit 2) is a first run; anything else is a real failure.
|
||||
local rc=0
|
||||
git ls-remote -q --exit-code "$target" refs/heads/main >/dev/null || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
"${git[@]}" fetch -q --no-tags "$target" '+refs/heads/main:refs/mirror/main'
|
||||
prev="$("${git[@]}" rev-parse refs/mirror/main)"
|
||||
elif [ "$rc" -ne 2 ]; then
|
||||
die "cannot read $target"
|
||||
fi
|
||||
local force=""
|
||||
if [ -n "$prev" ] && ! "${git[@]}" merge-base --is-ancestor "$prev^1" "$clean"; then
|
||||
[ -n "$RESET" ] \
|
||||
|| die "GitHub's main is not built on the history this run publishes; --reset force-pushes over it"
|
||||
prev=""
|
||||
force=+
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
"${git[@]}" worktree add -q --detach "$WORK" "$clean"
|
||||
transform "$WORK"
|
||||
check_scrubbed "$WORK"
|
||||
git -C "$WORK" add -A
|
||||
local tree
|
||||
tree="$(git -C "$WORK" write-tree)"
|
||||
|
||||
if [ -n "$prev" ] && [ "$("${git[@]}" rev-parse "$prev^1")" = "$clean" ] \
|
||||
&& [ "$("${git[@]}" rev-parse "$prev^{tree}")" = "$tree" ]; then
|
||||
say "already at ${src:0:12}"
|
||||
else
|
||||
# The forge commit's committer and date, so the same input always
|
||||
# makes the same commit.
|
||||
local name email date commit
|
||||
IFS=$'\t' read -r name email date < <("${git[@]}" log -1 --format='%cn%x09%ce%x09%cI' "$src")
|
||||
commit="$(GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" GIT_AUTHOR_DATE="$date" \
|
||||
GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" GIT_COMMITTER_DATE="$date" \
|
||||
"${git[@]}" commit-tree "$tree" -p "$clean" ${prev:+-p "$prev"} \
|
||||
-m "Mirror ${src:0:12} for GitHub" \
|
||||
-m "Forge links, the README header and commit trailers rewritten by dejima's mirror/github-mirror.sh.")"
|
||||
"${git[@]}" update-ref refs/mirror/next "$commit"
|
||||
[ -n "$DRY_RUN" ] || "${git[@]}" push -q "$target" "$force$commit:refs/heads/main"
|
||||
say "main is ${src:0:12} as ${commit:0:12}"
|
||||
fi
|
||||
|
||||
# Only tags on main: one on another branch would publish unreleased work.
|
||||
local ref
|
||||
local refspecs=()
|
||||
while IFS= read -r ref; do
|
||||
if "${git[@]}" merge-base --is-ancestor "$ref" "$src"; then
|
||||
local at
|
||||
at="$(rewritten "$("${git[@]}" rev-parse "$ref^{commit}")")"
|
||||
refspecs+=("$force$at:refs/tags/${ref#refs/source/tags/}")
|
||||
say "tag ${ref#refs/source/tags/} at ${at:0:12}"
|
||||
fi
|
||||
done < <("${git[@]}" for-each-ref --format='%(refname)' refs/source/tags/)
|
||||
[ "${#refspecs[@]}" -eq 0 ] || [ -n "$DRY_RUN" ] \
|
||||
|| "${git[@]}" push -q "$target" "${refspecs[@]}"
|
||||
}
|
||||
|
||||
# Each repository in a subshell of its own, so one that fails does not keep
|
||||
# the rest from syncing. Not `( ... ) || ...`, which turns off set -e inside.
|
||||
sync() {
|
||||
[ -n "${MIRROR_SOURCE:-}" ] || die "MIRROR_SOURCE is not set"
|
||||
local repo rc failed=""
|
||||
for repo in $MIRROR_REPOS; do
|
||||
set +e
|
||||
(set -e; sync_repo "$repo")
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" -eq 0 ] || failed="$failed $repo"
|
||||
done
|
||||
[ -z "$failed" ] || die "failed:$failed"
|
||||
}
|
||||
|
||||
DRY_RUN=""
|
||||
RESET=""
|
||||
case "${1:-}" in
|
||||
--settings) settings; exit ;;
|
||||
--transform) [ -n "${2:-}" ] || die "--transform needs a directory"; transform "$2"; exit ;;
|
||||
esac
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--reset) RESET=1 ;;
|
||||
*) sed -n '5,9p' "$0" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
sync
|
||||
+4
-10
@@ -77,16 +77,10 @@ PY
|
||||
[ -e /var/run/docker.sock ]
|
||||
}
|
||||
|
||||
@test "the two runners are not the same runner" {
|
||||
# Whatever else is true, exactly one of them gets the daemon. If both jobs
|
||||
# report the same answer here, the labels are not routing to two different
|
||||
# runners and the tiering exists only on paper.
|
||||
if [ "$DEJIMA_CI_TIER" = confined ]; then
|
||||
[ ! -e /var/run/docker.sock ]
|
||||
else
|
||||
[ -e /var/run/docker.sock ]
|
||||
fi
|
||||
}
|
||||
# The two directions above are each one job's answer: the confined runner must
|
||||
# not have the docker socket and the privileged one must. That the two jobs land
|
||||
# on DIFFERENT runners does not follow from either, and a suite cannot see it -
|
||||
# it runs in one job. Comparing the two jobs' results is the workflow's to do.
|
||||
|
||||
# --- egress, for the tier the rules actually apply to ------------------------
|
||||
#
|
||||
|
||||
+425
-20
@@ -569,6 +569,54 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
[[ "$output" == *"nonesuch"* ]]
|
||||
}
|
||||
|
||||
@test "the isolated mode has no config key, in any plausible spelling" {
|
||||
# --isolated is a flag and nothing else, on purpose: it changes what the
|
||||
# session IS rather than how much it may do, and a file that decided it
|
||||
# would make the next launch's answer to "where is my work" depend on a
|
||||
# line nobody reread. The schema is what holds that - an unknown key is
|
||||
# fatal, so every spelling someone might reach for has to stay unknown.
|
||||
local key
|
||||
for key in isolated isolate ephemeral no_workspace; do
|
||||
write_config '[core]' "$key = on"
|
||||
run lib config_validate
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "[core] $key was accepted, so the mode is settable from a file"; return 1; }
|
||||
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "the session name has no config key either, and refuses what would break a row" {
|
||||
# Same reasoning as the mode above, from the other direction: a name every
|
||||
# launch of a project shares is the ambiguity --name exists to remove, so
|
||||
# a file is the one place it cannot usefully live.
|
||||
local key
|
||||
for key in name session_name label; do
|
||||
write_config '[core]' "$key = whatever"
|
||||
run lib config_validate
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "[core] $key was accepted"; return 1; }
|
||||
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
|
||||
# The value lands in a flat key=value registry entry that dejima-attach
|
||||
# parses last-match-wins, and in a tab-separated listing. A newline or a
|
||||
# tab is refused rather than sanitized, like the workspace path beside it.
|
||||
local bad
|
||||
for bad in $'two\nlines' $'two\tcolumns' $'bell\a'; do
|
||||
run "$REPO_ROOT/bin/dejima" --name "$bad" .
|
||||
[ "$status" -ne 0 ] || { echo "accepted a control character"; return 1; }
|
||||
[[ "$output" == *"control character"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..33})" .
|
||||
[ "$status" -ne 0 ] || { echo "accepted 33 characters"; return 1; }
|
||||
[[ "$output" == *"32 is the most"* ]] || { echo "$output"; return 1; }
|
||||
# And the boundary itself is accepted, or the cap is off by one: this gets
|
||||
# past the name check and fails on something else further in.
|
||||
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..32})" .
|
||||
[[ "$output" != *"32 is the most"* ]] || { echo "32 characters was refused"; return 1; }
|
||||
}
|
||||
|
||||
@test "an invalid value is fatal and names the key" {
|
||||
write_config '[audit]' 'keep = 0'
|
||||
run lib config_validate
|
||||
@@ -791,7 +839,7 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
|
||||
_identity_fixture() {
|
||||
ID_DIR="$(mktemp -d)"
|
||||
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
|
||||
printf "k\n" | tee "$ID_DIR/default-key" "$ID_DIR/work-key" > "$ID_DIR/oss-key"
|
||||
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
|
||||
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
@@ -1009,7 +1057,7 @@ _identity_fixture() {
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
# forge = none, workflow = local-only: provider_validate_selection runs
|
||||
# ahead of this check, and the default gitea/worktree-pr pair is refused
|
||||
@@ -1285,7 +1333,7 @@ _identity_fixture() {
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
|
||||
workspace="$(mktemp -d)"
|
||||
@@ -1315,7 +1363,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
|
||||
@@ -1385,12 +1433,14 @@ EOF
|
||||
|
||||
@test "the github forge with no push_token_file leaves the read-only GH_TOKEN alone at launch" {
|
||||
# forge_env_exports must not emit `GH_TOKEN=` (empty) when
|
||||
# push_token_file is unset: `-e GH_TOKEN=` on `docker
|
||||
# compose run` overrides docker-compose.yml's own `environment: GH_TOKEN:
|
||||
# ${GH_TOKEN:-}` entry - the one carrying the independent read-only
|
||||
# [github] token_file value every session is supposed to get regardless
|
||||
# of forge. Asserted on the actual argument list handed to `docker`, not
|
||||
# on a warning, so this catches the container-facing symptom directly.
|
||||
# push_token_file is unset: `-e GH_TOKEN=` on `docker compose run`
|
||||
# overrides docker-compose.yml's own `environment: GH_TOKEN:` entry - the
|
||||
# one carrying the independent read-only [github] token_file value every
|
||||
# session is supposed to get regardless of forge. (That entry reads
|
||||
# bin/dejima's SANDBOX_GH_TOKEN, which is the same value except for an
|
||||
# isolated launch; the override is what matters here either way.)
|
||||
# Asserted on the actual argument list handed to `docker`, not on a
|
||||
# warning, so this catches the container-facing symptom directly.
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
@@ -1449,7 +1499,7 @@ EOF
|
||||
# dedicated key file is the headless form of that, and keeps this test
|
||||
# independent of whether the host running it has an agent.
|
||||
local key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
|
||||
"ssh_key = $key" \
|
||||
@@ -1506,7 +1556,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
local key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
|
||||
"ssh_key = $key" \
|
||||
@@ -1530,7 +1580,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
|
||||
'[logging]' 'loki_url = https://loki.example.invalid'
|
||||
@@ -1707,7 +1757,7 @@ _rw_setup() {
|
||||
mkdir -p "$RW_HOME/.claude"
|
||||
echo '{}' > "$RW_HOME/.claude/settings.json"
|
||||
RW_KEY="$RW_HOME/sandbox-key"
|
||||
: > "$RW_KEY"
|
||||
printf "not-a-real-key\n" > "$RW_KEY"
|
||||
chmod 600 "$RW_KEY"
|
||||
}
|
||||
|
||||
@@ -1831,6 +1881,139 @@ _kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null;
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# A launch that really reaches `docker compose`, with a pushing workflow, so the
|
||||
# rule that blanks the agent socket for a workflow that never pushes is not the
|
||||
# one doing the work. Its own fixture rather than _rw_setup's: this needs a
|
||||
# forge configured, and stdin closed because a full launch opens /dev/tty,
|
||||
# which under bats is not the terminal it expects.
|
||||
#
|
||||
# The stub records its own environment rather than its arguments, because the
|
||||
# volume line resolves ${SSH_AUTH_SOCK:-/dev/null} inside docker-compose - so
|
||||
# the environment handed to it IS whether the agent is mounted. Into a file,
|
||||
# because the launch prints plenty of its own and the assertion is about one
|
||||
# value.
|
||||
#
|
||||
# Leaves AGENT_LOG and AGENT_OUT for the caller to read on failure.
|
||||
_agent_launch() { # <ssh_key or "">
|
||||
local key="$1" h c s w
|
||||
# The suite exports DEJIMA_CONFIG_DIR, which is where a launch under it
|
||||
# reads its config from - a config written under $HOME would not be found.
|
||||
h="$(mktemp -d)"; c="$CONFIG_DIR"
|
||||
printf 'tok\n' > "$c/token"
|
||||
{
|
||||
printf '[core]\nharness = claude-code\nfirewall = on\nupdate_check = off\n'
|
||||
printf 'forge = gitea\nworkflow = worktree-pr\n'
|
||||
printf '[harness.claude-code]\nbackend = anthropic\n'
|
||||
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example\n'
|
||||
# The forge credential moves under the identity when there is one: an
|
||||
# identity that leaves one behind is a session acting under whichever
|
||||
# account owns it, which the launch refuses outright.
|
||||
if [ -n "$key" ]; then
|
||||
printf '[identity.default]\nssh_key = %s\ntoken_file = %s/token\n' "$key" "$c"
|
||||
else
|
||||
printf 'token_file = %s/token\n' "$c"
|
||||
fi
|
||||
} > "$c/config"
|
||||
AGENT_LOG="$(mktemp)"
|
||||
s="$(mktemp -d)"
|
||||
cat > "$s/docker" <<'STUB'
|
||||
#!/bin/sh
|
||||
printf 'SSH_AUTH_SOCK=[%s]\n' "${SSH_AUTH_SOCK-unset}" >> "$AGENT_SOCK_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$s/docker"
|
||||
w="$(mktemp -d)"; git -C "$w" init -q
|
||||
# `|| true` because a launch here ends however the stubbed session ended,
|
||||
# and this is not a test about its exit status.
|
||||
AGENT_OUT="$(HOME="$h" PATH="$s:$PATH" SSH_AUTH_SOCK="$AGENT_SOCK" \
|
||||
AGENT_SOCK_LOG="$AGENT_LOG" \
|
||||
bash "$REPO_ROOT/bin/dejima" "$w" </dev/null 2>&1 || true)"
|
||||
rm -rf "$h" "$s" "$w"
|
||||
}
|
||||
|
||||
# A real agent, because bin/dejima tests the socket with `[ -S ]` and refuses a
|
||||
# path that is not one.
|
||||
_real_agent() {
|
||||
eval "$(ssh-agent -s)" >/dev/null
|
||||
AGENT_SOCK="$SSH_AUTH_SOCK"
|
||||
AGENT_PID="$SSH_AGENT_PID"
|
||||
}
|
||||
|
||||
@test "a configured ssh_key means the operator's agent is not in the container at all" {
|
||||
# entrypoint.sh loads that key into an agent of its own and points
|
||||
# SSH_AUTH_SOCK at it, so the forwarded one was already unused - but it
|
||||
# stayed mounted at the path docker-compose.yml names, where
|
||||
# `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with the operator's personal
|
||||
# identities. Choosing a dedicated key has to mean the personal agent is
|
||||
# not in the container, not that it is merely not the default.
|
||||
local key
|
||||
_real_agent
|
||||
key="$(mktemp)"; printf 'not-a-real-key\n' > "$key"; chmod 600 "$key"
|
||||
|
||||
_agent_launch "$key"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
rm -f "$key"
|
||||
[ -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
|
||||
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[]" ] \
|
||||
|| { echo "the forwarded agent still reached compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
@test "without a configured ssh_key the forwarded agent still reaches the container" {
|
||||
# The positive control for the test above, and the reason it means
|
||||
# anything: without it, a launch that simply never set the variable would
|
||||
# satisfy that assertion too.
|
||||
_real_agent
|
||||
|
||||
_agent_launch ""
|
||||
local want="SSH_AUTH_SOCK=[$AGENT_SOCK]"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
[ -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
|
||||
[ "$(sort -u "$AGENT_LOG")" = "$want" ] \
|
||||
|| { echo "the forwarded agent did not reach compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
@test "an empty ssh_key file is not a key, and a pushing launch says so" {
|
||||
# It passes the is-a-file check and loads nothing in the container, so it
|
||||
# cannot be what stops the forwarded agent being mounted. Treated as no key
|
||||
# at all: harmless for a workflow that never pushes - which is what most of
|
||||
# this suite's fixtures are, with an empty file standing in for a path -
|
||||
# and named for one that does.
|
||||
# A pushing workflow with an empty key and no agent: the one case where it
|
||||
# costs anything, and the message names the file rather than only saying no
|
||||
# key is set. The with-an-agent direction is its own test above.
|
||||
AGENT_SOCK=""
|
||||
local key; key="$(mktemp)"; chmod 600 "$key"
|
||||
_agent_launch "$key"
|
||||
rm -f "$key"
|
||||
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|
||||
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
|
||||
[ ! -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch reached docker with no credential at all"; return 1; }
|
||||
}
|
||||
|
||||
@test "an empty ssh_key file with an agent running says the agent is still signing" {
|
||||
# The configuration nothing else would tell an operator about: the key is
|
||||
# configured, the launch starts, and pushes work - because the personal
|
||||
# agent is doing them, which is what a dedicated key is chosen to avoid.
|
||||
# No key is loaded, so the withdrawal above does not fire.
|
||||
local key
|
||||
_real_agent
|
||||
key="$(mktemp)"; chmod 600 "$key"
|
||||
|
||||
_agent_launch "$key"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
rm -f "$key"
|
||||
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|
||||
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
|
||||
[[ "$AGENT_OUT" == *"will sign for this session"* ]] \
|
||||
|| { echo "the consequence went unnamed:"; echo "$AGENT_OUT" | tail -12; return 1; }
|
||||
# And it is not a false alarm: the agent really does reach compose here.
|
||||
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[$AGENT_SOCK]" ] \
|
||||
|| { echo "the warning does not match what reached compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
# The positive control for the test above: with no key configured the
|
||||
# forwarded agent really is the only way to push, and an empty one is worth
|
||||
# saying so before it fails deep into a session.
|
||||
@@ -3469,7 +3652,7 @@ EOF
|
||||
# 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"
|
||||
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$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" \
|
||||
@@ -3484,7 +3667,7 @@ EOF
|
||||
@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"
|
||||
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$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
|
||||
@@ -3497,7 +3680,7 @@ EOF
|
||||
@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"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$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; }
|
||||
@@ -3508,7 +3691,7 @@ EOF
|
||||
}
|
||||
|
||||
@test "a named identity still beats [identity.default]" {
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/default-key"; printf "not-a-real-key\n" > "$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"
|
||||
@@ -3523,7 +3706,7 @@ EOF
|
||||
# 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"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"; printf "not-a-real-key\n" > "$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
|
||||
@@ -3544,7 +3727,7 @@ EOF
|
||||
# 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"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$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
|
||||
@@ -3783,6 +3966,188 @@ EOF
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "the staged ~/.claude.json carries the launching project's entry and no other" {
|
||||
# The projects map keys per-project state by absolute path, so a verbatim
|
||||
# copy tells a session the name of every project on the host, a sample of
|
||||
# each one's filenames (exampleFiles) and what each has cost. Narrowed for
|
||||
# the reason #104 narrowed the projects MOUNT: the whole map is every
|
||||
# project's (#380).
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# A worktree under the project, because a session's cwd routinely is one and
|
||||
# Claude Code keys that path separately - and a sibling whose path merely
|
||||
# begins with the project's, which is the comparison a bare prefix test
|
||||
# gets wrong.
|
||||
#
|
||||
# The other project's path is deliberately dull. Calling it anything
|
||||
# credential-shaped - "secret" was the first attempt - makes the staged
|
||||
# copy trip dejima's own credential scan, so the launch refuses and this
|
||||
# test passes on a refusal rather than on the narrowing it is named for.
|
||||
mkdir -p "$workspace/.worktrees/feature"
|
||||
cat > "$RW_HOME/.claude.json" <<JSON
|
||||
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
|
||||
"projects":{
|
||||
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
|
||||
"$workspace/.worktrees/feature":{"exampleFiles":["branch.py"]},
|
||||
"${workspace}-sibling":{"exampleFiles":["sibling.py"]},
|
||||
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"],"lastCost":99}},
|
||||
"numStartups":7}
|
||||
JSON
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
|
||||
|
||||
local stub log staged
|
||||
stub="$(mktemp -d)"; log="$(mktemp)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
|
||||
|
||||
# The project and anything under it.
|
||||
[ "$(jq -r --arg p "$workspace" '.projects[$p].lastCost' "$staged")" = 1 ] \
|
||||
|| { echo "the launching project's own entry is gone"; return 1; }
|
||||
[ "$(jq -r --arg p "$workspace/.worktrees/feature" '.projects[$p].exampleFiles[0]' "$staged")" = branch.py ] \
|
||||
|| { echo "a worktree inside the project was dropped"; return 1; }
|
||||
|
||||
# And nothing else, including the sibling a bare prefix test would keep.
|
||||
[ "$(jq -r --arg p "${workspace}-sibling" '.projects | has($p)' "$staged")" = false ] \
|
||||
|| { echo "a sibling path sharing the project's prefix was kept"; return 1; }
|
||||
[ "$(jq -r '.projects | has("/home/someone/else/their-project")' "$staged")" = false ] \
|
||||
|| { echo "another project reached the container"; return 1; }
|
||||
[ "$(jq -r '.projects | length' "$staged")" = 2 ] \
|
||||
|| { echo "wrong number of entries: $(jq -c '.projects | keys' "$staged")"; return 1; }
|
||||
grep -q 'theirs.py' "$staged" \
|
||||
&& { echo "another project's filenames reached the container"; return 1; }
|
||||
|
||||
# The rest of the file is why the copy exists, and the credential still goes.
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
|
||||
[ "$(jq -r '.primaryApiKey // "absent"' "$staged")" = absent ] \
|
||||
|| { echo "the console API key reached the container"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$log" "$staged"
|
||||
}
|
||||
|
||||
@test "the proxy-off re-seed narrows the projects map too" {
|
||||
# With the proxy off for a backend whose credential IS ~/.claude.json,
|
||||
# the staged copy is re-seeded so the key survives - and that was a plain
|
||||
# `cp`, which put every other project's entry back. Worse, it ran after
|
||||
# the credential scan had read the narrowed copy, so nothing ever looked
|
||||
# at what came back.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
cat > "$RW_HOME/.claude.json" <<JSON
|
||||
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
|
||||
"projects":{
|
||||
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
|
||||
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"]}},
|
||||
"numStartups":7}
|
||||
JSON
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' \
|
||||
'backend = anthropic-api-key' '[backend.anthropic-api-key]' \
|
||||
"credential_file = $RW_HOME/.claude.json" 'proxy = off'
|
||||
|
||||
local stub staged
|
||||
stub="$(mktemp -d)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
|
||||
|
||||
# The credential is the point of this path, so it stays.
|
||||
[ "$(jq -r '.primaryApiKey' "$staged")" = sk-ant-api03-REAL-KEY-FROM-THE-HOST ] \
|
||||
|| { echo "proxy = off did not pass the credential through"; return 1; }
|
||||
# And nothing else came back with it.
|
||||
[ "$(jq -r '.projects | length' "$staged")" = 1 ] \
|
||||
|| { echo "the re-seed restored other projects: $(jq -c '.projects|keys' "$staged")"
|
||||
return 1; }
|
||||
grep -q 'theirs.py' "$staged" \
|
||||
&& { echo "another project's filenames reached the container"; return 1; }
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$staged"
|
||||
}
|
||||
|
||||
@test "a backend needing no credential mounts no credential" {
|
||||
# P1, driven rather than grepped. The mount sat under
|
||||
# `elif [ -f ~/.claude/.credentials.json ]`, which fired for anything the
|
||||
# proxy branch did not take - including a backend whose credential kind
|
||||
# is none, the self-served arrangement docs/security.md calls the one with
|
||||
# nothing in the container to steal. The real subscription login was
|
||||
# mounted read-write into a session that had no use for it.
|
||||
#
|
||||
# The grep over the branch's conditions is in static.bats and would still
|
||||
# pass on a restructuring that tested the same words differently, which is
|
||||
# why this one reads the argv a launch actually produced.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# A real-looking credential on the host, which is what there is to leak.
|
||||
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-REAL","refreshToken":"r","expiresAt":1}}\n' \
|
||||
> "$RW_HOME/.claude/.credentials.json"
|
||||
chmod 600 "$RW_HOME/.claude/.credentials.json"
|
||||
# cline rather than claude-code: a credential kind of none means a backend
|
||||
# the harness reaches directly, and claude-code speaks no API that has
|
||||
# one. This is the pairing the release pass reproduced it with.
|
||||
local cline_state; cline_state="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = cline' '[harness.cline]' \
|
||||
"state_dir = $cline_state" 'model = m' 'backend = self-hosted' \
|
||||
'[backend.self-hosted]' 'base_url = http://127.0.0.1:11434'
|
||||
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
|
||||
"$docker_log" \
|
||||
&& { echo "a self-served backend was handed the real credential"; return 1; }
|
||||
rm -rf "$cline_state"
|
||||
|
||||
# And the arrangement the mount IS for still gets it: this backend's
|
||||
# credential is that file, and the proxy is off for it.
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' \
|
||||
'backend = anthropic' '[backend.anthropic]' 'proxy = off'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
|
||||
"$docker_log" \
|
||||
|| { echo "proxy = off no longer mounts the credential it is for"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a projects key that is not a map does not refuse the launch" {
|
||||
# Narrowing a `projects` that holds a string would error, and the staging
|
||||
# treats a jq failure as fatal - so an odd but parseable file would become
|
||||
# a launch that will not start, over a key with nothing in it to narrow.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
printf '{"projects":"not-a-map","numStartups":3}\n' > "$RW_HOME/.claude.json"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
|
||||
local stub staged
|
||||
stub="$(mktemp -d)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "an odd projects key refused the launch: $output"; return 1; }
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 3 ] || { echo "the copy lost its state"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$staged"
|
||||
}
|
||||
|
||||
@test "a console API key in ~/.claude.json does not reach the container" {
|
||||
# Found on a real desktop launch. A Claude Code signed in with a console
|
||||
# API token keeps the key as a top-level primaryApiKey in ~/.claude.json,
|
||||
@@ -4109,6 +4474,46 @@ _scan_staged_has_token() {
|
||||
rm -rf "$workspace" "$SCAN_STUB" "$SCAN_STAGED"
|
||||
}
|
||||
|
||||
@test "a scrub finds the value again whatever the key is spelled like" {
|
||||
# Deleting by document order made each later index stale: the second secret
|
||||
# survived and an innocent value went instead. The staged file is mounted
|
||||
# into the container, so the operator answers "keep it out" and it goes in.
|
||||
#
|
||||
# Driven against the program's own functions rather than a reimplementation
|
||||
# of the ordering, which is the thing under test.
|
||||
local scan="$REPO_ROOT/bin/dejima-credential-scan"
|
||||
run python3 - "$scan" <<'PYEOF'
|
||||
import json, sys
|
||||
mod = {}
|
||||
exec(compile(open(sys.argv[1]).read(), "scan", "exec"), mod)
|
||||
doc = {"mcpServers": {"x": {"args": ["sk-SECRET-ONE-aaaaaaaaaaaaaaaaaaaa",
|
||||
"sk-SECRET-TWO-bbbbbbbbbbbbbbbbbbbb",
|
||||
"--harmless-flag"]}}}
|
||||
def scrub(doc, want):
|
||||
pairs = list(mod["walk_json"](json.loads(json.dumps(doc))))
|
||||
drop = [parts for p, v, parts in pairs if p in want]
|
||||
out = json.loads(json.dumps(doc))
|
||||
mod["delete_json_components"](out, drop)
|
||||
return out
|
||||
|
||||
print(json.dumps(scrub(doc, {"mcpServers.x.args[0]",
|
||||
"mcpServers.x.args[1]"})["mcpServers"]["x"]["args"]))
|
||||
# A key that spells an index, and a key carrying a dot: both used to be
|
||||
# re-parsed as traversals, deleting a bystander or raising out of the scrub.
|
||||
print(json.dumps(scrub({"x[0]": "sk-SECRET-key", "x": ["innocent"]}, {"x[0]"})))
|
||||
print(json.dumps(scrub({"a.b": "sk-SECRET-dotted"}, {"a.b"})))
|
||||
PYEOF
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *SECRET* ]] \
|
||||
|| { echo "a secret survived the scrub: $output"; return 1; }
|
||||
[[ "$output" == *harmless-flag* ]] \
|
||||
|| { echo "the scrub removed a value that was not a secret: $output"; return 1; }
|
||||
# No line raised: a KeyError here aborts the scrub and bin/dejima then
|
||||
# copies the file in unscrubbed.
|
||||
[ "${#lines[@]}" -eq 3 ] \
|
||||
|| { echo "the scrub did not complete for every shape: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a standing scrub written by hand is honoured, and holds no value" {
|
||||
# Not offered at the prompt, but available to someone who has read what it
|
||||
# does - and it is the only way a scrubbed field survives an unattended
|
||||
|
||||
Vendored
+9
-3
@@ -21,6 +21,9 @@ import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import loadscale # noqa: E402
|
||||
|
||||
# The default is the credential scan's prompt. --marker takes another, so a
|
||||
# test for a different prompt does not need a second copy of this.
|
||||
MARKER = "[s/a/o/r]"
|
||||
@@ -33,9 +36,12 @@ AMBIENT = "Press Enter to continue"
|
||||
# An IDLE timeout: how long to wait with no output before giving up. Generous
|
||||
# because the thing it guards against is a command that never finishes, and
|
||||
# any finite bound catches that - while a tight one just times the machine.
|
||||
# Sixty seconds was enough on an idle box and not enough under the full gate,
|
||||
# where every suite runs at once (#295).
|
||||
TIMEOUT = 240.0
|
||||
#
|
||||
# Multiplied by how oversubscribed the run asked the machine to be, rather than
|
||||
# raised again the next time it is not enough: this one went from sixty seconds
|
||||
# to this because sixty was ample on an idle box and expired under the full
|
||||
# gate, which is the shape of guess loadscale exists to stop (#295).
|
||||
TIMEOUT = loadscale.deadline(240.0)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Vendored
+23
-2
@@ -16,8 +16,10 @@ import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False}
|
||||
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False,
|
||||
"oauth_reject_say": None}
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -50,11 +52,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
}).encode())
|
||||
|
||||
def _handle(self):
|
||||
if self.path == "/v1/oauth/token":
|
||||
if self.path.startswith("/oauth-reject"):
|
||||
# Make the next refresh fail the way a finished refresh token does.
|
||||
# Set rather than always-on, so the ordinary refresh tests keep the
|
||||
# same endpoint. ?say= replaces the error_description, for tests
|
||||
# about upstream-chosen text landing in the proxy's log.
|
||||
with LOCK:
|
||||
STATE["oauth_reject"] = True
|
||||
STATE["oauth_reject_say"] = \
|
||||
parse_qs(urlsplit(self.path).query).get("say", [None])[0]
|
||||
self._send(200, b"ok", "text/plain")
|
||||
elif self.path == "/v1/oauth/token":
|
||||
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
||||
with LOCK:
|
||||
STATE["oauth_hits"] += 1
|
||||
STATE["oauth_last"] = req
|
||||
reject = STATE.get("oauth_reject")
|
||||
say = STATE.get("oauth_reject_say")
|
||||
if reject:
|
||||
# Verbatim from a real overnight expiry.
|
||||
self._send(400, json.dumps({
|
||||
"error": "invalid_grant",
|
||||
"error_description": say or "Refresh token expired",
|
||||
}).encode())
|
||||
return
|
||||
self._send(200, json.dumps({
|
||||
"access_token": "refreshed-access-token",
|
||||
"refresh_token": "rotated-refresh-token",
|
||||
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
"""How much longer a bounded wait should be given on a loaded machine.
|
||||
|
||||
The suites here wait on things that take as long as the machine makes them take
|
||||
- a pty reaching raw mode, a shipper polling, a prompt being answered - and
|
||||
each wait has a deadline. A deadline that is right on an idle box is wrong
|
||||
under the gate, which runs every suite at once, and lengthening it until both
|
||||
work means guessing a number that is generous in one place and useless in the
|
||||
other (#295).
|
||||
|
||||
So the runner says how much concurrency it asked for, and the deadline is
|
||||
multiplied by how oversubscribed that makes the machine. Nothing gets shorter
|
||||
than it is today: the multiplier floors at 1, so an idle run and a run with no
|
||||
variable set behave exactly as before.
|
||||
|
||||
Capped, because this is a multiplier on a timeout: a variable set wrongly, or a
|
||||
machine reporting one core, would otherwise turn a test that should fail in
|
||||
seconds into one that hangs for the length of the CI job.
|
||||
|
||||
DEJIMA_TEST_CONCURRENCY is how many tests the runner intends to have in flight
|
||||
at once. test/run-confined.sh sets it to the budget it divided among the
|
||||
suites; the CI workflow sets it to what it passes bats. Neither is a guess
|
||||
about load - it is what that runner asked the machine for.
|
||||
"""
|
||||
import os
|
||||
|
||||
MAX_SCALE = 4.0
|
||||
|
||||
|
||||
def scale():
|
||||
"""The multiplier, >= 1.0 and <= MAX_SCALE."""
|
||||
try:
|
||||
want = int(os.environ.get("DEJIMA_TEST_CONCURRENCY", "0"))
|
||||
except ValueError:
|
||||
return 1.0
|
||||
if want <= 0:
|
||||
return 1.0
|
||||
cores = os.cpu_count() or 1
|
||||
return max(1.0, min(MAX_SCALE, want / float(cores)))
|
||||
|
||||
|
||||
def deadline(seconds):
|
||||
"""A bounded wait, lengthened for the concurrency this run asked for."""
|
||||
return seconds * scale()
|
||||
Vendored
+11
-3
@@ -25,6 +25,9 @@ import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from loadscale import deadline # noqa: E402
|
||||
|
||||
HELPER = os.path.abspath(sys.argv[1])
|
||||
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
|
||||
# Required, not defaulted. The fallback was "/tmp", and the three paths below
|
||||
@@ -200,7 +203,7 @@ def pump(seconds):
|
||||
|
||||
def expect(text, timeout=8.0):
|
||||
"""Wait for text to appear in output since the last call to forget()."""
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if text in "".join(seen):
|
||||
return True
|
||||
@@ -238,7 +241,7 @@ def reclaimed():
|
||||
|
||||
|
||||
def await_reclaim(timeout=10.0):
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if reclaimed():
|
||||
return True
|
||||
@@ -259,8 +262,13 @@ def await_raw(timeout=15.0):
|
||||
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.
|
||||
|
||||
Every bound in this file is multiplied by how oversubscribed the run asked
|
||||
the machine to be (loadscale), because lengthening them until both an idle
|
||||
box and the full gate pass means picking a number that is wrong in one of
|
||||
the two. They are unchanged when nothing says otherwise.
|
||||
"""
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if raw_restored():
|
||||
return True
|
||||
|
||||
+22
-3
@@ -51,7 +51,7 @@ esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
@@ -466,14 +466,33 @@ EOF
|
||||
[ "$status" -ne 0 ] || { echo "the launch proceeded with a dotless host"; return 1; }
|
||||
[[ "$output" == *"loki_url"* ]] \
|
||||
|| { echo "the error does not name the key it came from: $output"; return 1; }
|
||||
[[ "$output" == *"single label"* ]] \
|
||||
|| { echo "the error does not say what is wrong: $output"; return 1; }
|
||||
[[ "$output" == *"at least two"* && "$output" == *labels* ]] \
|
||||
|| { echo "the error does not say what the rule is: $output"; return 1; }
|
||||
|
||||
# And nothing was started, rather than started and then torn down.
|
||||
[ ! -s "$LAUNCH_DIR/docker-log" ] || {
|
||||
echo "docker was invoked before the check refused:"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
}
|
||||
|
||||
@test "a derived host that is not a host name is refused, not only a dotless one" {
|
||||
# The check exists for a derivation that went wrong, and a URL slip does not
|
||||
# always leave a bare label: userinfo and a trailing dot both survive into
|
||||
# the derived name, and both have a dot in them. Counting dots would pass
|
||||
# them here and the container would refuse them at boot instead.
|
||||
stub_launch
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[logging]\nloki_url = http://user@loki.example.com:3100\n' >> "$conf"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -ne 0 ] || { echo "a userinfo URL launched"; return 1; }
|
||||
[[ "$output" == *"user@loki.example.com"* ]] \
|
||||
|| { echo "the error does not show the derived host: $output"; return 1; }
|
||||
[ ! -s "$LAUNCH_DIR/docker-log" ] || {
|
||||
echo "docker was invoked before the check refused"; return 1; }
|
||||
}
|
||||
|
||||
@test "a fully qualified host in the same key launches" {
|
||||
# The control for the test above: same key, same shape, one dot more.
|
||||
stub_launch
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bats
|
||||
# mirror/github-mirror.sh: what the GitHub copies are rewritten to, and how
|
||||
# their main is built. Nothing here pushes; --dry-run stops before the push, which
|
||||
# is the one line these tests leave to the operator's first sync.
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
MIRROR="$REPO_ROOT/mirror/github-mirror.sh"
|
||||
T="$(mktemp -d)"
|
||||
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
|
||||
export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com \
|
||||
GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com
|
||||
|
||||
# The forge: this tree's docs, as one commit on main.
|
||||
F="$T/forge/dejima"
|
||||
git init -q -b main "$F"
|
||||
cp -r "$REPO_ROOT"/*.md "$REPO_ROOT/docs" "$REPO_ROOT/assets" "$F/"
|
||||
git -C "$F" add -A
|
||||
git -C "$F" commit -qm base
|
||||
mkdir "$T/github"
|
||||
git init -q --bare "$T/github/dejima"
|
||||
export MIRROR_SOURCE="$T/forge" MIRROR_TARGET="$T/github" MIRROR_STATE="$T/state" \
|
||||
MIRROR_REPOS=dejima MIRROR_SCRUB="$T/scrub"
|
||||
unset MIRROR_ORG
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$T"
|
||||
}
|
||||
|
||||
mirrored() {
|
||||
git -C "$MIRROR_STATE/dejima.git" rev-parse refs/mirror/next
|
||||
}
|
||||
|
||||
@test "the mirror's README follows the reader's colour scheme" {
|
||||
"$MIRROR" --transform "$F"
|
||||
local readme="$F/README.md"
|
||||
grep -qF '<source media="(prefers-color-scheme: dark)" srcset="assets/dejima-wordmark-darkmode.png">' "$readme"
|
||||
grep -qF '<img src="assets/dejima-wordmark-lightmode.png" alt="dejima" height="72"></picture>' "$readme"
|
||||
}
|
||||
|
||||
@test "the mirror's README says it takes no issues or pull requests, above the fold" {
|
||||
"$MIRROR" --transform "$F"
|
||||
local notice heading
|
||||
notice="$(grep -n 'This is a read-only mirror' "$F/README.md" | cut -d: -f1)"
|
||||
heading="$(grep -n '^## ' "$F/README.md" | head -1 | cut -d: -f1)"
|
||||
[ -n "$notice" ] && [ "$notice" -lt "$heading" ]
|
||||
grep -q 'issues and pull requests are not accepted here' "$F/README.md"
|
||||
}
|
||||
|
||||
@test "repository links move to GitHub and issue links stay on the forge" {
|
||||
grep -q 'gitbox.branderson.io/dejima/dejima/\(issues\|pulls\)/' "$F/CHANGELOG.md"
|
||||
"$MIRROR" --transform "$F"
|
||||
cd "$F"
|
||||
run grep -rhoE 'https://gitbox\.branderson\.io/dejima/[^)]*' --include='*.md' .
|
||||
local link
|
||||
for link in "${lines[@]}"; do
|
||||
[[ "$link" =~ ^https://gitbox\.branderson\.io/dejima/dejima/(issues|pulls)/[0-9]+$ ]] \
|
||||
|| { echo "still on the forge: $link"; return 1; }
|
||||
done
|
||||
grep -qF '](https://github.com/dejima-org/dejima)' README.md
|
||||
grep -qF '(https://github.com/dejima-org/dejima-guard/blob/main/SECURITY.md)' docs/threat-model.md
|
||||
! grep -rq 'github.com/dejima-org/[^)]*/src/branch/' --include='*.md' .
|
||||
}
|
||||
|
||||
@test "transforming twice changes nothing, so a clone of the mirror passes this suite" {
|
||||
"$MIRROR" --transform "$F"
|
||||
cp -r "$F" "$T/once"
|
||||
"$MIRROR" --transform "$F"
|
||||
diff -r -x .git "$T/once" "$F"
|
||||
}
|
||||
|
||||
@test "a README the transform no longer recognises stops the mirror" {
|
||||
sed -i 's/dejima-wordmark-darkmode.png/renamed.png/' "$F/README.md"
|
||||
run "$MIRROR" --transform "$F"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"wordmark tag was not found"* ]]
|
||||
}
|
||||
|
||||
@test "the mirror commit sits on the forge's main, and the same input makes the same commit" {
|
||||
run "$MIRROR" --dry-run
|
||||
[ "$status" -eq 0 ]
|
||||
local first src
|
||||
first="$(mirrored)"
|
||||
src="$(git -C "$F" rev-parse main)"
|
||||
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$first^1")" = "$src" ]
|
||||
run git -C "$MIRROR_STATE/dejima.git" rev-parse -q --verify "$first^2"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
MIRROR_STATE="$T/state2" run "$MIRROR" --dry-run
|
||||
[ "$(git -C "$T/state2/dejima.git" rev-parse refs/mirror/next)" = "$first" ]
|
||||
}
|
||||
|
||||
@test "each sync fast-forwards the previous mirror commit" {
|
||||
"$MIRROR" --dry-run
|
||||
local prev
|
||||
prev="$(mirrored)"
|
||||
git -C "$MIRROR_TARGET/dejima" fetch -q "$MIRROR_STATE/dejima.git" "$prev:refs/heads/main"
|
||||
|
||||
echo change >> "$F/docs/ci.md"
|
||||
git -C "$F" commit -qam change
|
||||
"$MIRROR" --dry-run
|
||||
local next
|
||||
next="$(mirrored)"
|
||||
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$next^1")" = "$(git -C "$F" rev-parse main)" ]
|
||||
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$next^2")" = "$prev" ]
|
||||
|
||||
git -C "$MIRROR_TARGET/dejima" fetch -q "$MIRROR_STATE/dejima.git" "+$next:refs/heads/main"
|
||||
run "$MIRROR" --dry-run
|
||||
[[ "$output" == *"already at"* ]]
|
||||
}
|
||||
|
||||
@test "only tags on main are mirrored" {
|
||||
git -C "$F" tag released
|
||||
git -C "$F" checkout -qb development
|
||||
git -C "$F" commit -q --allow-empty -m unreleased
|
||||
git -C "$F" tag unreleased
|
||||
run "$MIRROR" --dry-run
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"tag released"* ]]
|
||||
[[ "$output" != *"tag unreleased"* ]]
|
||||
}
|
||||
|
||||
@test "clone URLs in code move too, and a repo with no wordmark gets the notice under its title" {
|
||||
# Assembled, so the mirror's copy of this file is not itself rewritten.
|
||||
local B="$T/forge/dejima-badge" forge="https://gitbox.branderson.io"
|
||||
git init -q -b main "$B"
|
||||
printf '# Developed in dejima\n\nSee [dejima](%s/dejima/dejima).\n\n## Use\n\n curl -fLO %s/dejima/dejima-badge/raw/branch/main/dejima-badge.svg\n' "$forge" "$forge" > "$B/README.md"
|
||||
printf '#!/bin/sh\nURL=${URL:-%s/dejima/dejima.git}\n' "$forge" > "$B/clone"
|
||||
git -C "$B" add -A
|
||||
"$MIRROR" --transform "$B"
|
||||
[ "$(sed -n 3p "$B/README.md")" = '> **This is a read-only mirror.** dejima is developed on a private forge,' ]
|
||||
grep -qF 'https://github.com/dejima-org/dejima-badge/raw/main/dejima-badge.svg' "$B/README.md"
|
||||
grep -qF 'URL=${URL:-https://github.com/dejima-org/dejima.git}' "$B/clone"
|
||||
}
|
||||
|
||||
@test "a repository that fails does not stop the others, and the run says which" {
|
||||
MIRROR_REPOS="missing dejima" run "$MIRROR" --dry-run
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"dejima: main is "* ]]
|
||||
[[ "$output" == *"failed: missing"* ]]
|
||||
}
|
||||
|
||||
@test "a repository with no forge links still gets its notice" {
|
||||
local B="$T/forge/plain"
|
||||
git init -q -b main "$B"
|
||||
printf '# Plain\n\nNothing to rewrite.\n' > "$B/README.md"
|
||||
git -C "$B" add -A
|
||||
"$MIRROR" --transform "$B"
|
||||
grep -q '^> \*\*This is a read-only mirror' "$B/README.md"
|
||||
}
|
||||
|
||||
@test "a file named like a perl option is rewritten, not run" {
|
||||
local forge="https://gitbox.branderson.io"
|
||||
printf 'See %s/dejima/dejima-guard\n' "$forge" > "$F/-eBEGIN{exit 3}"
|
||||
git -C "$F" add -A
|
||||
"$MIRROR" --transform "$F"
|
||||
grep -qF 'https://github.com/dejima-org/dejima-guard' "$F/-eBEGIN{exit 3}"
|
||||
}
|
||||
|
||||
@test "a README that is a symlink stops the mirror instead of publishing its target" {
|
||||
local B="$T/forge/linked"
|
||||
git init -q -b main "$B"
|
||||
printf '# Not for publishing\n' > "$T/outside"
|
||||
ln -s "$T/outside" "$B/README.md"
|
||||
git -C "$B" add -A
|
||||
run "$MIRROR" --transform "$B"
|
||||
[ "$status" -ne 0 ]
|
||||
[ -L "$B/README.md" ]
|
||||
[ "$(cat "$T/outside")" = '# Not for publishing' ]
|
||||
}
|
||||
|
||||
published() {
|
||||
git -C "$MIRROR_STATE/dejima.git" "$@" refs/mirror/next
|
||||
}
|
||||
|
||||
@test "no AI co-author trailer reaches the published history, and other commits keep their SHA" {
|
||||
local base
|
||||
base="$(git -C "$F" rev-parse HEAD)"
|
||||
echo change >> "$F/docs/ci.md"
|
||||
git -C "$F" commit -qa -m change -m 'Keeps this line.' \
|
||||
-m 'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>'
|
||||
"$MIRROR" --dry-run
|
||||
run published log --format=%B
|
||||
[[ "$output" != *"Co-Authored-By"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"Keeps this line."* ]]
|
||||
run published rev-list --max-parents=0
|
||||
[ "$output" = "$base" ]
|
||||
}
|
||||
|
||||
@test "MIRROR_SCRUB values are replaced in every file and message in the history" {
|
||||
printf '# example\ngitbox.internal.example gitbox.example.com\n10.9.8. 192.0.2.\n' > "$T/scrub"
|
||||
printf 'forge = gitbox.internal.example\nhost = 10.9.8.7\nother = 110.9.8.7\n' > "$F/docs/net.md"
|
||||
git -C "$F" add -A
|
||||
git -C "$F" commit -qm 'Point at gitbox.internal.example'
|
||||
git -C "$F" rm -q docs/net.md
|
||||
git -C "$F" commit -qm 'Take it back out'
|
||||
"$MIRROR" --dry-run
|
||||
run published log -p --format=%B
|
||||
[[ "$output" != *"internal.example"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"Point at gitbox.example.com"* ]]
|
||||
[[ "$output" == *"host = 192.0.2.7"* ]]
|
||||
[[ "$output" == *"other = 110.9.8.7"* ]]
|
||||
}
|
||||
|
||||
@test "a scrub value the rewrite could not reach stops the mirror" {
|
||||
printf 'gitbox.internal.example gitbox.example.com\n' > "$T/scrub"
|
||||
echo 'see xgitbox.internal.example' > "$F/docs/net.md"
|
||||
git -C "$F" add -A
|
||||
git -C "$F" commit -qm net
|
||||
run "$MIRROR" --dry-run
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"MIRROR_SCRUB value is still in the tree"* ]]
|
||||
}
|
||||
|
||||
@test "tags are published on the rewritten commit they map to" {
|
||||
git -C "$F" commit -q --allow-empty -m tagged \
|
||||
-m 'Co-Authored-By: Claude <noreply@anthropic.com>'
|
||||
git -C "$F" tag release
|
||||
run "$MIRROR" --dry-run
|
||||
[ "$status" -eq 0 ]
|
||||
local at
|
||||
at="$(git -C "$MIRROR_STATE/dejima.git" rev-parse refs/clean/main)"
|
||||
[ "$at" != "$(git -C "$F" rev-parse release)" ]
|
||||
[[ "$output" == *"tag release at ${at:0:12}"* ]]
|
||||
}
|
||||
|
||||
@test "a mirror built on other history is refused, and --reset replaces it" {
|
||||
# What GitHub held before trailers were rewritten: a mirror commit whose
|
||||
# first parent is the forge commit itself.
|
||||
git -C "$F" commit -q --allow-empty -m tagged \
|
||||
-m 'Co-Authored-By: Claude <noreply@anthropic.com>'
|
||||
local old
|
||||
old="$(git -C "$F" commit-tree "$(git -C "$F" rev-parse HEAD^{tree})" -p HEAD -m mirror)"
|
||||
git -C "$T/github/dejima" fetch -q "$F" "$old:refs/heads/main"
|
||||
|
||||
run "$MIRROR" --dry-run
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"--reset force-pushes over it"* ]]
|
||||
|
||||
run "$MIRROR" --dry-run --reset
|
||||
[ "$status" -eq 0 ]
|
||||
run git -C "$MIRROR_STATE/dejima.git" rev-parse -q --verify refs/mirror/next^2
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
+635
-7
@@ -61,6 +61,11 @@ launch_setup() {
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
# Stands in for a session that handed something back, for the one test about
|
||||
# an isolated share surviving. Written where the launch says it mounted it.
|
||||
if [ -n "${LAUNCH_SHARE_MARKER:-}" ] && [ -n "${SANDBOX_SHARE_DIR:-}" ]; then
|
||||
: > "$SANDBOX_SHARE_DIR/handed-back" 2>/dev/null || true
|
||||
fi
|
||||
# For a real `docker build` (never `docker compose ... build`, whose $1 is
|
||||
# "compose"), also log the generated Dockerfile's own content, tagged by -t -
|
||||
# argv alone can't tell whether the Dockerfile's FROM line actually points at
|
||||
@@ -84,10 +89,15 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
}
|
||||
|
||||
# SSH_AUTH_SOCK is emptied rather than passed through, so a test never reads
|
||||
# the agent of whoever is running the suite - an inherited one would make the
|
||||
# forwarded-agent path depend on the machine. LAUNCH_SSH_AUTH_SOCK is how the
|
||||
# one test about that path states its own precondition instead.
|
||||
#
|
||||
# ssh_key is written into whatever config file config_root() currently
|
||||
# resolves to (DEJIMA_CONFIG_DIR, which a few tests point elsewhere) rather
|
||||
# than always $CONFIG_DIR/config, and appended rather than written, so it
|
||||
@@ -110,7 +120,8 @@ launch() {
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
SSH_AUTH_SOCK="${LAUNCH_SSH_AUTH_SOCK-}" ALLOWED_DOMAINS= \
|
||||
LAUNCH_SHARE_MARKER="${LAUNCH_SHARE_MARKER:-}" \
|
||||
"$REPO_ROOT/bin/dejima" "$@"
|
||||
}
|
||||
|
||||
@@ -184,11 +195,34 @@ github.com'; do
|
||||
[ "$status" -ne 0 ] || { echo "accepted: $(printf %q "$bad")"; return 1; }
|
||||
done
|
||||
|
||||
# The declaration path uses it, which is what makes the refusal happen
|
||||
# while the operator is looking at the file.
|
||||
run bash -c 'source "$1"; _approved_validator allowed-domains' _ "$LIB"
|
||||
[ "$output" = valid_permitted_domain ] \
|
||||
|| { echo "allowed-domains is validated by $output"; return 1; }
|
||||
# Granting is where it applies. Reading deliberately does not - see the
|
||||
# test below.
|
||||
run bash -c 'source "$1"; approve_entry "$2" allowed-domains loki' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -ne 0 ] || { echo "a single-label domain was granted"; return 1; }
|
||||
[[ "$output" == *"two labels"* ]] \
|
||||
|| { echo "the refusal does not say why: $output"; return 1; }
|
||||
run bash -c 'source "$1"; approve_entry "$2" allowed-domains ok.example.com' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -eq 0 ] || { echo "a fully qualified domain was refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an approval granted before the two-label rule is still read, not called corruption" {
|
||||
# approved_entries validates on every read, and bin/dejima assigns from it
|
||||
# under set -e on every launch. Applying a rule that tightened afterwards
|
||||
# there would turn an approval that was valid when it was made into the
|
||||
# store's tampering message - on every launch, in every project, for an
|
||||
# operator whose only mistake was upgrading. The last release accepted a
|
||||
# single-label domain, so that store is the ordinary one.
|
||||
printf 'crates.io\nloki\n' > "$BATS_TEST_TMPDIR/allowed-domains"
|
||||
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "a pre-existing approval now fails the read: $output"; return 1; }
|
||||
[[ "$output" == *loki* ]] \
|
||||
|| { echo "the entry was dropped rather than read: $output"; return 1; }
|
||||
|
||||
# The shape check that guards against real tampering still fires.
|
||||
printf 'crates.io\na..b.com\n' > "$BATS_TEST_TMPDIR/allowed-domains"
|
||||
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -ne 0 ] || { echo "a malformed entry was read as valid"; return 1; }
|
||||
}
|
||||
|
||||
@test "valid_domain accepts real domains and rejects shell-active input" {
|
||||
@@ -1287,6 +1321,569 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
done
|
||||
}
|
||||
|
||||
# --isolated: a session with nothing before it and nothing after it. The
|
||||
# claim is one sentence - nothing of the host crosses in but the operator's
|
||||
# own read-only config, and nothing survives but ~/shared - and these are the
|
||||
# parts of it that a stubbed launch can decide.
|
||||
#
|
||||
# Driven through the real launcher rather than by grepping it, because every
|
||||
# one of these is a property of what `compose run` is handed, and a grep over
|
||||
# the source passes just as well on a line that no longer takes effect.
|
||||
@test "an isolated launch mounts no host path for its working directory" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# The whole spec, because the compose file cannot build one: a source and
|
||||
# a target would have to be two variables and neither can be empty. The
|
||||
# source here names the compose file's own volume, so nothing of the host
|
||||
# is behind the working directory.
|
||||
local spec; spec="$(launched_env SANDBOX_WORKSPACE_MOUNT)"
|
||||
[ "$spec" = "isolated-work:/dejima/work" ] \
|
||||
|| { echo "the workspace mount is $spec, not the container-local volume"; return 1; }
|
||||
[ "$(launched_env WORKSPACE_DIR)" = /dejima/work ]
|
||||
|
||||
# Whatever the source is, it must not be a path: a source starting with /
|
||||
# is a bind mount, which is the one thing this mode cannot have here.
|
||||
case "${spec%%:*}" in
|
||||
/*) echo "the workspace mount names a host path: $spec"; return 1 ;;
|
||||
esac
|
||||
|
||||
# That the volume it names is this run's own, and is removed afterwards,
|
||||
# belongs to the test below with the other four.
|
||||
|
||||
# And the ordinary case still mounts the project at its own path, so the
|
||||
# assertion above cannot pass by the variable having stopped working.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_WORKSPACE_MOUNT)" = "$PROJ:$PROJ" ]
|
||||
}
|
||||
|
||||
@test "an isolated launch takes nothing from the project it was started in" {
|
||||
# The one in this file that is about an attack rather than about tidiness.
|
||||
# resolve_project_store walks UPWARDS from the directory it is given, so
|
||||
# an operator in $HOME on a machine whose dotfiles were once approved as a
|
||||
# project would otherwise have handed an anonymous session that project's
|
||||
# allowed domains and its dependency layer. The flag means the directory
|
||||
# it was run from decides nothing.
|
||||
#
|
||||
# Two independent things in bin/dejima hold that - the working directory
|
||||
# being container-local, and the approval store not being resolved - and
|
||||
# this fails only when BOTH are reversed. Worth knowing when reading a
|
||||
# mutation result: either one alone still passes, which is what defence
|
||||
# in depth looks like from here rather than a test that misses.
|
||||
"$REPO_ROOT/bin/dejima-allow" --project "$PROJ" example.invalid >/dev/null
|
||||
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
|
||||
launch_setup
|
||||
|
||||
local slug; slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
|
||||
# First that the fixture works at all: an ordinary launch from in there
|
||||
# DOES get both. Without this the assertions below pass on a project
|
||||
# whose approvals were never recorded.
|
||||
cd "$PROJ"
|
||||
run launch
|
||||
cd - >/dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$(launched_env ALLOWED_DOMAINS)" == *example.invalid* ]] \
|
||||
|| { echo "the fixture's project domains were never approved"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ] \
|
||||
|| { echo "the fixture's project deps were never approved"; return 1; }
|
||||
|
||||
# And then that the isolated launch from the same directory gets neither.
|
||||
# The log accumulates across launches in one fixture, so the build line
|
||||
# asserted absent below would otherwise be found from the launch above.
|
||||
: > "$LAUNCH_DIR/docker-log"
|
||||
cd "$PROJ"
|
||||
run launch --isolated
|
||||
cd - >/dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$(launched_env ALLOWED_DOMAINS)" != *example.invalid* ]] \
|
||||
|| { echo "an isolated launch inherited the cwd's approved domains"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" != "localhost/dejima:$(id -un)-$slug" ] \
|
||||
|| { echo "an isolated launch inherited the cwd's dependency layer"; return 1; }
|
||||
grep -q -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) \
|
||||
&& { echo "an isolated launch built the cwd's dependency layer"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch mounts the operator's read-only config and none of the writable state" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
local log; log="$(docker_log)"
|
||||
# The operator's own hooks and skills: theirs, read-only, and not
|
||||
# something an earlier session could have written.
|
||||
grep -q -- "-v $LAUNCH_DIR/home/.claude/skills:" <<< "$log" \
|
||||
|| { echo "the operator's skills are not mounted"; return 1; }
|
||||
# The shared read-write state every project's sessions append to (#380),
|
||||
# and the transcripts and memory under projects/.
|
||||
local d
|
||||
for d in backups tasks plans sessions projects file-history; do
|
||||
grep -q -- "-v $LAUNCH_DIR/home/.claude/$d" <<< "$log" \
|
||||
&& { echo "an isolated launch mounts ~/.claude/$d"; return 1; }
|
||||
done
|
||||
# Not mounted and also not CREATED: the pre-create loop exists so Docker
|
||||
# does not make a root-owned mount source, and a directory nothing mounts
|
||||
# needs no source.
|
||||
for d in backups tasks plans sessions; do
|
||||
[ ! -d "$LAUNCH_DIR/home/.claude/$d" ] \
|
||||
|| { echo "an isolated launch created ~/.claude/$d on the host"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch gets a fresh share directory that no other session has had" {
|
||||
local share; share="$(mktemp -d)"
|
||||
launch_setup
|
||||
printf '[core]\nshare_dir = %s\n' "$share" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local first; first="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local second; second="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
|
||||
[ -n "$first" ] && [ -n "$second" ] || { echo "no share directory was set"; return 1; }
|
||||
[ "$first" != "$second" ] \
|
||||
|| { echo "two isolated launches were handed the same directory: $first"; return 1; }
|
||||
case "$first" in
|
||||
"$share"/isolated/*) ;;
|
||||
*) echo "the isolated share is not under the isolated root: $first"; return 1 ;;
|
||||
esac
|
||||
# Neither survives, because neither was written to: an empty isolated
|
||||
# share is removed in the exit trap. That covers a session that handed
|
||||
# nothing back and a launch refused after the directory was made, which
|
||||
# is most refusals - each used to leave one for an operator to find.
|
||||
[ ! -d "$first" ] && [ ! -d "$second" ] \
|
||||
|| { echo "an empty isolated share was left behind"; return 1; }
|
||||
# And one that WAS written to stays, which is what the directory is for -
|
||||
# and is also what shows the launch creates and mounts it rather than
|
||||
# just computing a path.
|
||||
LAUNCH_SHARE_MARKER=1 run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local used; used="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
[ -f "$used/handed-back" ] \
|
||||
|| { echo "the share directory the launch mounted does not exist: $used"; return 1; }
|
||||
rm -rf "$used"
|
||||
rm -rf "$share"
|
||||
}
|
||||
|
||||
@test "an isolated launch names its volumes for the run and removes them" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Two are sharper than the rest. The shell history is shared by every
|
||||
# project even when the nvim ones are not, so under its usual name an
|
||||
# isolated session reads what was typed in any session before it; and the
|
||||
# working directory is where everything the session did actually lives.
|
||||
local v names=""
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
|
||||
names="$names $(launched_env "$v")"
|
||||
[[ "$(launched_env "$v")" == dejima_isolated-* ]] \
|
||||
|| { echo "$v is not this run's own: $(launched_env "$v")"; return 1; }
|
||||
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|
||||
|| { echo "$v is not a valid Docker volume name"; return 1; }
|
||||
done
|
||||
grep -q "volume rm$names" <(docker_log) \
|
||||
|| { echo "the run's volumes are not removed:$names"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
|
||||
local before="$names"
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
names=""
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
|
||||
names="$names $(launched_env "$v")"
|
||||
done
|
||||
[ "$before" != "$names" ] \
|
||||
|| { echo "two isolated launches asked for the same volumes:$names"; return 1; }
|
||||
|
||||
# And an ordinary launch still gets the shared history volume, which is
|
||||
# what makes a session started tomorrow see what was typed today.
|
||||
#
|
||||
# Asserted as "sets nothing", not as a name. What the volume is called
|
||||
# then comes from the compose default, and whether THAT is the name Docker
|
||||
# would have chosen on its own is a property of the compose file - checked
|
||||
# there, in static.bats, against the project name rather than against a
|
||||
# string repeated here. A name in this test would have been updated to
|
||||
# match a wrong default rather than catching one.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_BASH_HISTORY)" ] \
|
||||
|| { echo "an ordinary launch renamed the history volume to $(launched_env SANDBOX_BASH_HISTORY)"
|
||||
return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch reaches no forge unless the same command names one" {
|
||||
# The forge credential is the widest thing a session holds - a Gitea token
|
||||
# is account-wide - so the mode whose purpose is a session nobody is
|
||||
# watching does not carry one by default. It narrows, like every other
|
||||
# flag here.
|
||||
#
|
||||
# Both orders, because the defaults are applied after the argument loop
|
||||
# rather than inside it: a version that set them in the loop would pass
|
||||
# one of these two and fail the other.
|
||||
launch_setup
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
# With the section header: launch() appends its own [core] block after
|
||||
# this, so a bare key here would sit outside any section and be refused.
|
||||
printf '[core]\nforge = gitea\nworkflow = worktree-pr\n' >> "$conf"
|
||||
|
||||
run launch --isolated --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=none' <<< "$output" \
|
||||
|| { echo "an isolated launch kept the configured forge"; return 1; }
|
||||
grep -qx 'core.workflow=local-only' <<< "$output" \
|
||||
|| { echo "an isolated launch kept a workflow that pushes"; return 1; }
|
||||
|
||||
# Both halves of the name: a forge named on the command line, and a
|
||||
# workflow, in either order relative to the flag. It only ever named a
|
||||
# workflow before, so the forge half of what it is called went untested.
|
||||
local args
|
||||
for args in "--isolated --forge gitea" "--forge gitea --isolated"; do
|
||||
# shellcheck disable=SC2086
|
||||
run launch $args --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=gitea' <<< "$output" \
|
||||
|| { echo "'$args' did not keep the forge the operator named"; return 1; }
|
||||
# And the workflow still defaults, since only the forge was named.
|
||||
grep -qx 'core.workflow=local-only' <<< "$output" \
|
||||
|| { echo "'$args' changed the workflow as well"; return 1; }
|
||||
done
|
||||
for args in "--isolated --workflow worktree-pr" "--workflow worktree-pr --isolated"; do
|
||||
# shellcheck disable=SC2086
|
||||
run launch $args --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.workflow=worktree-pr' <<< "$output" \
|
||||
|| { echo "'$args' did not keep the workflow the operator named"; return 1; }
|
||||
grep -qx 'core.forge=none' <<< "$output" \
|
||||
|| { echo "'$args' changed the forge as well"; return 1; }
|
||||
done
|
||||
|
||||
# And the config file is untouched for a launch without the flag, so the
|
||||
# assertions above are about --isolated rather than about the fixture.
|
||||
run launch --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=gitea' <<< "$output" \
|
||||
|| { echo "an ordinary launch lost the configured forge"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch mounts the configured ssh key and not the forwarded agent" {
|
||||
# Opposite answers for the two, which is the whole reason this is one
|
||||
# test rather than a line in another: a workflow that never pushes gets
|
||||
# no agent socket, and --isolated defaults to one of those, while a
|
||||
# configured key is exported whatever the workflow. Both docs state the
|
||||
# pair, and a test that checked only the key - as this one first did -
|
||||
# cannot catch the half that was written backwards.
|
||||
#
|
||||
# Withholding the key as well, for any launch that never pushes, is #392.
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_SSH_KEY_FILE)" = "$LAUNCH_DIR/sandbox-key" ] \
|
||||
|| { echo "the configured key did not cross: $(launched_env SANDBOX_SSH_KEY_FILE)"
|
||||
echo "- if that is deliberate, the two docs saying it does need updating"
|
||||
return 1; }
|
||||
|
||||
# Now the agent, which needs its own fixture for two reasons. The helper
|
||||
# empties SSH_AUTH_SOCK so no test reads the suite runner's own agent, so
|
||||
# a launch that never sets it could not have forwarded one whatever the
|
||||
# code did - asserting its absence there proves nothing. And a configured
|
||||
# key withdraws the agent too, independently of the workflow, so the key
|
||||
# above has to go for this to be about the rule the docs name. An EMPTY
|
||||
# key file is "not a key" by design, which is how that is arranged.
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
# A real socket, because the launcher tests for one with -S. Binding
|
||||
# creates the filesystem entry and leaves it there after the process
|
||||
# exits, which is all this needs.
|
||||
python3 -c 'import socket,sys
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.bind(sys.argv[1])' "$LAUNCH_DIR/agent.sock"
|
||||
[ -S "$LAUNCH_DIR/agent.sock" ] || { echo "the fixture made no socket"; return 1; }
|
||||
|
||||
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" run launch --isolated
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -z "$(launched_env SSH_AUTH_SOCK)" ] \
|
||||
|| { echo "a workflow that pushes nothing still forwarded the agent"; return 1; }
|
||||
|
||||
# And the positive control. A pushing workflow needs a forge with a host
|
||||
# to push to, so this names both - which is also the combination an
|
||||
# operator asking for an isolated session that pushes would type.
|
||||
printf 'ghp_not_a_real_push_token\n' > "$LAUNCH_DIR/gh-push-token"
|
||||
chmod 600 "$LAUNCH_DIR/gh-push-token"
|
||||
printf '[forge.github]\npush_token_file = %s\n' "$LAUNCH_DIR/gh-push-token" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" \
|
||||
run launch --isolated --forge github --workflow branch-push
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(launched_env SSH_AUTH_SOCK)" = "$LAUNCH_DIR/agent.sock" ] \
|
||||
|| { echo "naming a pushing workflow did not forward the agent"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch gets no GitHub token unless the command names a forge" {
|
||||
# [github] token_file is not a forge setting, which is what makes it easy
|
||||
# to miss: core fills it for every session whatever forge is selected. A
|
||||
# read-only PAT is still read-only across the operator's private
|
||||
# repositories, so the mode that carries no forge credential cannot carry
|
||||
# this one either.
|
||||
#
|
||||
# The filter container keeps it, because that is what authenticates
|
||||
# dejima's own fetch of GitHub's address ranges - a request about the
|
||||
# allowlist, made where no session can reach it. So the two are separate
|
||||
# variables now, and this asserts both halves: the session's is empty and
|
||||
# the launcher's, which is what `-e GH_TOKEN` hands the filter, is not.
|
||||
launch_setup
|
||||
printf 'ghp_not_a_real_token\n' > "$LAUNCH_DIR/gh-token"
|
||||
chmod 600 "$LAUNCH_DIR/gh-token"
|
||||
printf '[github]\ntoken_file = %s\n' "$LAUNCH_DIR/gh-token" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "an isolated session was handed core's GitHub token"; return 1; }
|
||||
[ -n "$(launched_env GH_TOKEN)" ] \
|
||||
|| { echo "the filter container lost the token that authenticates its ranges fetch"
|
||||
return 1; }
|
||||
|
||||
# `--forge` naming the forge with no capabilities is an operator asking
|
||||
# for no forge, and it counted as naming one - so the launch that said
|
||||
# most clearly that it wanted no forge access was the one that got the
|
||||
# credential.
|
||||
run launch --isolated --forge none
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "--isolated --forge none was handed core's GitHub token"; return 1; }
|
||||
|
||||
# Naming a forge is the operator saying this session is meant to reach
|
||||
# one, and nothing is narrowed from there.
|
||||
run launch --isolated --forge gitea
|
||||
[ "$status" -eq 0 ]
|
||||
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "a named forge did not restore the GitHub token"; return 1; }
|
||||
|
||||
# And an ordinary launch is untouched, including one whose config selects
|
||||
# no forge - this narrows for the flag, not for every forge-less launch.
|
||||
run launch --forge none --workflow local-only "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "an ordinary launch lost core's GitHub token"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch says nothing is kept, and every reader agrees" {
|
||||
# The banner worked the epitaph out from [epitaph] enabled alone and said
|
||||
# "epitaph on" a few lines above the isolated notice saying it was off -
|
||||
# and nothing was being kept either way, because the save was gated on
|
||||
# the mode instead. --print-config had the same split for both keys.
|
||||
#
|
||||
# Both are overridden off where the flag is read now, so this asserts the
|
||||
# thing every reader is reading rather than one reader's output.
|
||||
launch_setup
|
||||
printf '[epitaph]\nenabled = on\n[logging]\nconversation = on\nloki_url = http://loki.invalid:3100\n' \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'epitaph.enabled=off' <<< "$output" \
|
||||
|| { echo "--print-config says the epitaph is on for an isolated launch"; return 1; }
|
||||
grep -qx 'logging.conversation=off' <<< "$output" \
|
||||
|| { echo "--print-config says the conversation log is on"; return 1; }
|
||||
|
||||
# And on, as configured, without the flag - so the two lines above are
|
||||
# about --isolated rather than about the keys having stopped working.
|
||||
run launch --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'epitaph.enabled=on' <<< "$output" \
|
||||
|| { echo "an ordinary launch lost its epitaph setting"; return 1; }
|
||||
grep -qx 'logging.conversation=on' <<< "$output" \
|
||||
|| { echo "an ordinary launch lost its conversation setting"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated cline launch is configured, and writes nothing to the host" {
|
||||
# Cline's model configuration reaches the container through the state
|
||||
# mount, which this mode does not have - so it was written to a host
|
||||
# directory keyed on the container-local working directory, shared by
|
||||
# every isolated cline launch, and then not mounted. Cline started with no
|
||||
# model and fell back to its own hosted endpoint, which the firewall was
|
||||
# never asked to allow.
|
||||
#
|
||||
# It is seeded from the per-run staging directory now, so it arrives
|
||||
# without a state mount and the host state directory is untouched.
|
||||
launch_setup
|
||||
local state; state="$(mktemp -d)"
|
||||
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
|
||||
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
# --harness rather than a config line: launch() appends its own
|
||||
# `harness = claude-code` after whatever a test writes, and a repeated
|
||||
# key takes the last value.
|
||||
run launch --isolated --harness cline
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
|
||||
|
||||
# The configuration arrives, as a seed rather than a mount - so the
|
||||
# session owns the copy it edits and nothing it writes reaches the host.
|
||||
local log; log="$(docker_log)"
|
||||
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
|
||||
<<< "$log" \
|
||||
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
|
||||
# From a path that goes away with the run, not from the state directory.
|
||||
grep -qE -- "-v $state[^ ]*:/.*providers\.json" <<< "$log" \
|
||||
&& { echo "it was mounted out of the host state directory"; rm -rf "$state"; return 1; }
|
||||
# And the state directory itself is neither mounted nor written.
|
||||
grep -qF -- "-v $state" <<< "$log" \
|
||||
&& { echo "an isolated launch mounted the host state directory"; rm -rf "$state"; return 1; }
|
||||
[ -z "$(ls -A "$state")" ] \
|
||||
|| { echo "it wrote to the host: $(ls -A "$state")"; rm -rf "$state"; return 1; }
|
||||
rm -rf "$state"
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "a launch leaves no per-run staging directory behind" {
|
||||
# The staging directory is created with mktemp -d above the trap and
|
||||
# removed by cleanup(), and for one release it was neither: the block
|
||||
# that initialises trap-owned names to empty runs AFTER the mktemp in
|
||||
# straight-line order, so it threw the real path away and cleanup()
|
||||
# removed "". Every launch on every harness left one in TMPDIR.
|
||||
#
|
||||
# Nothing caught it because nothing looked after exit. This does, with its
|
||||
# own TMPDIR so it sees only what this launch made - and for both
|
||||
# harnesses, since the one that puts a file in there is not the default.
|
||||
launch_setup
|
||||
local t; t="$(mktemp -d)"
|
||||
TMPDIR="$t" run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t"; return 1; }
|
||||
local left; left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
|
||||
[ -z "$left" ] \
|
||||
|| { echo "left behind: $left"; rm -rf "$t"; return 1; }
|
||||
|
||||
# cline is the harness that writes into it, so it is the one where a
|
||||
# leak carries content rather than being an empty directory.
|
||||
local state; state="$(mktemp -d)"
|
||||
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
|
||||
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
TMPDIR="$t" run launch --harness cline "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t" "$state"; return 1; }
|
||||
left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
|
||||
[ -z "$left" ] \
|
||||
|| { echo "cline left behind: $left ($(find "$left" -type f | head -3))"
|
||||
rm -rf "$t" "$state"; return 1; }
|
||||
# And the other staging paths, which were never broken and are what this
|
||||
# one should have looked like.
|
||||
left="$(find "$t" -maxdepth 1 \( -name 'dejima-settings.*' -o -name 'dejima-claude-json.*' \) -print)"
|
||||
[ -z "$left" ] || { echo "also left behind: $left"; rm -rf "$t" "$state"; return 1; }
|
||||
rm -rf "$t" "$state"
|
||||
}
|
||||
|
||||
@test "an ordinary cline launch still gets its provider configuration" {
|
||||
# The same seed, which for a launch that HAS a state mount lands inside
|
||||
# it - so the end state is what it was before, written by the entrypoint
|
||||
# rather than by the launcher onto the host.
|
||||
launch_setup
|
||||
local state; state="$(mktemp -d)"
|
||||
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
|
||||
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --harness cline "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
|
||||
local log; log="$(docker_log)"
|
||||
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
|
||||
<<< "$log" \
|
||||
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
|
||||
# And this one does mount the state directory, which is what makes the
|
||||
# seeded copy land in it.
|
||||
grep -qF -- "-v $state" <<< "$log" \
|
||||
|| { echo "an ordinary launch did not mount the state directory"; rm -rf "$state"; return 1; }
|
||||
rm -rf "$state"
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch refuses a project path and the mount flags" {
|
||||
launch_setup
|
||||
# Each refusal names what it refused. A bare nonzero status would pass on
|
||||
# a launch that failed for an unrelated reason.
|
||||
run launch --isolated "$PROJ"
|
||||
[ "$status" -ne 0 ] || { echo "a project path was accepted"; return 1; }
|
||||
[[ "$output" == *"takes no project directory"* ]] \
|
||||
|| { echo "the refusal does not say why: $output"; return 1; }
|
||||
|
||||
local flag
|
||||
for flag in --ro --rw; do
|
||||
run launch --isolated "$flag" /tmp
|
||||
[ "$status" -ne 0 ] || { echo "$flag was accepted"; return 1; }
|
||||
[[ "$output" == *"cannot take --ro or --rw"* ]] \
|
||||
|| { echo "the $flag refusal does not say why: $output"; return 1; }
|
||||
[[ "$output" == *"/tmp"* ]] \
|
||||
|| { echo "the $flag refusal does not name the path: $output"; return 1; }
|
||||
done
|
||||
|
||||
# Harness arguments after -- are not a project path, and refusing them
|
||||
# would make the flag unusable with every launch that passes any.
|
||||
run launch --isolated -- --version
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "harness arguments were read as a project path: $output"; return 1; }
|
||||
|
||||
# And the other order, which is the one that went wrong quietly: the flag
|
||||
# loop stops at the first positional, so `--isolated` after a path was
|
||||
# passed to the harness while the project was mounted read-write - the
|
||||
# operator asking for isolation got the opposite with no sign of it.
|
||||
run launch "$PROJ" --isolated
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "--isolated after the path was accepted and mounted $PROJ"; return 1; }
|
||||
[[ "$output" == *"would be passed to the"* ]] \
|
||||
|| { echo "the refusal does not say what would have happened: $output"; return 1; }
|
||||
|
||||
# Still passed through when -- says it is the harness's.
|
||||
run launch "$PROJ" -- --isolated
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "-- did not hand --isolated to the harness: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch says what it is and what it does without" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Printed whatever [ui] banner says and with stderr redirected, unlike
|
||||
# the banner: the share path is the only route back to what the session
|
||||
# hands over, and the two features it does without are ones an operator
|
||||
# would otherwise go looking for afterwards and find empty.
|
||||
[[ "$output" == *"no project mounted, no memory, no transcript"* ]] \
|
||||
|| { echo "the launch does not say what this mode is: $output"; return 1; }
|
||||
[[ "$output" == *"conversation log and epitaph off"* ]] \
|
||||
|| { echo "the launch does not say what is off: $output"; return 1; }
|
||||
[[ "$output" == *"$(launched_env SANDBOX_SHARE_DIR)"* ]] \
|
||||
|| { echo "the launch does not name the share directory: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch does not apply the configured mounts, and says so" {
|
||||
local extra; extra="$(mktemp -d)"
|
||||
launch_setup
|
||||
printf '[mounts]\nreadonly = %s\n' "$extra" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q -- "-v $extra" <(docker_log) \
|
||||
&& { echo "an isolated launch applied a configured mount"; return 1; }
|
||||
[[ "$output" == *"[mounts] readonly not applied"* ]] \
|
||||
|| { echo "the launch does not say the mount was dropped: $output"; return 1; }
|
||||
|
||||
# Dropped for an isolated launch only - the same config still mounts it
|
||||
# for an ordinary one, so the assertion above is about the flag rather
|
||||
# than about the list having stopped working.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q -- "-v $extra" <(docker_log) \
|
||||
|| { echo "an ordinary launch stopped applying [mounts] readonly"; return 1; }
|
||||
rm -rf "$extra"
|
||||
}
|
||||
|
||||
@test "the launcher builds and runs the global layer when only global deps are approved" {
|
||||
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
|
||||
launch_setup
|
||||
@@ -1360,6 +1957,37 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
[[ "$output" == *"approve its own domains and dependencies"* ]]
|
||||
}
|
||||
|
||||
@test "the launcher warns about the epitaph and share stores inside the workspace too" {
|
||||
# Three stores were warned about and six were guarded, so a path dejima
|
||||
# refuses to mount through [mounts] or --ro was mounted without a word
|
||||
# when it happened to sit inside the project. Each consequence differs,
|
||||
# which is why each store says its own.
|
||||
launch_setup
|
||||
local conf; conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
printf '[epitaph]\ndir = %s\n[core]\nshare_dir = %s\n' \
|
||||
"$PROJ/.epitaphs" "$PROJ/.share" >> "$conf"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"the epitaph store"* ]] \
|
||||
|| { echo "no warning for the epitaph store: $output"; return 1; }
|
||||
[[ "$output" == *"what every previous one in this project concluded"* ]] \
|
||||
|| { echo "the epitaph warning does not name its own consequence"; return 1; }
|
||||
[[ "$output" == *"share directories of every project"* ]] \
|
||||
|| { echo "no warning for the per-project share root"; return 1; }
|
||||
[[ "$output" == *"share directories of every isolated session"* ]] \
|
||||
|| { echo "no warning for the isolated share root"; return 1; }
|
||||
|
||||
# And none of them for a project that contains none of these, so the
|
||||
# three above are about the overlap rather than printed always.
|
||||
local other; other="$(mktemp -d)"
|
||||
run launch "$other"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"writable from inside the sandbox"* ]] \
|
||||
|| { echo "a clean project was warned about: $output"; return 1; }
|
||||
rm -rf "$other"
|
||||
}
|
||||
|
||||
@test "the launcher warns when the audit report or baseline store sits inside the mounted workspace" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
|
||||
@@ -215,6 +215,33 @@ assert_remedy_forges_exist() {
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "the briefing tells an agent how a refusal differs from a hang" {
|
||||
# The policy rejects rather than drops, for the reason its own chain
|
||||
# comment gives: silently dropping makes every blocked destination look
|
||||
# like a hang. The briefing described both as failing to connect, which
|
||||
# throws that signal away - and two agents in one day reported a host
|
||||
# firewall on the far side as dejima blocking a domain, because from
|
||||
# inside a sandbox the two are indistinguishable except by timing.
|
||||
local lib="$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
grep -q 'refused at once rather than' "$lib" \
|
||||
|| { echo "the briefing no longer says a refusal is immediate"; return 1; }
|
||||
grep -q 'HANGS is not this allowlist' "$lib" \
|
||||
|| { echo "the briefing no longer tells an agent what a hang means"; return 1; }
|
||||
|
||||
# And the property it describes is still true of the policy, so the advice
|
||||
# cannot outlive the behaviour it rests on.
|
||||
local chain
|
||||
chain=$(sed -n '/^ chain refuse {/,/^ }/p' "$REPO_ROOT/docker/egress-policy")
|
||||
[ -n "$chain" ] || { echo "no refuse chain in the policy"; return 1; }
|
||||
grep -q 'reject with tcp reset' <<< "$chain" \
|
||||
|| { echo "the policy no longer rejects TCP, so a block would hang"; return 1; }
|
||||
grep -q 'reject with icmpx' <<< "$chain" \
|
||||
|| { echo "the policy no longer rejects non-TCP"; return 1; }
|
||||
grep -qE '(^|[^a-z])drop([^a-z]|$)' <<< "$chain" \
|
||||
&& { echo "the refuse chain drops as well as rejects: $chain"; return 1; }
|
||||
true
|
||||
}
|
||||
|
||||
@test "gitea's CLIs moved out of bin/ and into its forge" {
|
||||
[ ! -e "$REPO_ROOT/bin/gitea-pr" ]
|
||||
[ ! -e "$REPO_ROOT/bin/gitea-issue" ]
|
||||
|
||||
+260
-11
@@ -292,17 +292,18 @@ teardown() {
|
||||
|
||||
@test "forwarder bridges TCP to the proxy's unix socket" {
|
||||
start_proxy
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
|
||||
local fport; fport="$(free_port)"
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT="$fport" \
|
||||
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
|
||||
FWD_PID=$!
|
||||
ok=""
|
||||
for _ in $(seq 1 50); do
|
||||
if curl -s -o /dev/null http://127.0.0.1:18399/echo; then ok=1; break; fi
|
||||
if curl -s -o /dev/null http://127.0.0.1:$fport/echo; then ok=1; break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
[ -n "$ok" ]
|
||||
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:18399/v1/messages
|
||||
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:$fport/v1/messages
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
|
||||
}
|
||||
@@ -353,6 +354,20 @@ PYEOF
|
||||
[[ "$output" != *"501"* ]]
|
||||
}
|
||||
|
||||
# The forwarder is given a port rather than asking for one, so these tests
|
||||
# have to name a number. Asking the kernel for a free one and handing that over
|
||||
# leaves a window where something else could take it; naming a constant leaves
|
||||
# the test failing on any machine already using it, which is the precondition
|
||||
# these tests were reading from the environment instead of stating. The window
|
||||
# is the smaller of the two, and the suites run on a developer's machine.
|
||||
free_port() {
|
||||
python3 -c 'import socket
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()'
|
||||
}
|
||||
|
||||
# The forwarder is the container-side half of the chain. In tls mode it
|
||||
# terminates TLS for api.anthropic.com on loopback so Claude Code needs no
|
||||
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
|
||||
@@ -382,13 +397,14 @@ make_cert() {
|
||||
@test "forwarder tls mode: https to api.anthropic.com reaches the proxy, which injects the real token" {
|
||||
make_cert
|
||||
start_proxy
|
||||
start_forwarder tls 18443
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder tls "$fport"
|
||||
# --resolve is what stands in for the container's /etc/hosts entry: the
|
||||
# name must really be api.anthropic.com for certificate verification to
|
||||
# be a meaningful check, not a hostname the test picked to be easy.
|
||||
run curl -s --cacert "$TD/cert.pem" \
|
||||
--resolve "api.anthropic.com:18443:127.0.0.1" \
|
||||
-X POST "https://api.anthropic.com:18443/v1/messages" -d '{}'
|
||||
--resolve "api.anthropic.com:$fport:127.0.0.1" \
|
||||
-X POST "https://api.anthropic.com:$fport/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
@@ -403,8 +419,9 @@ make_cert() {
|
||||
# no HTTP awareness to catch it.
|
||||
make_cert
|
||||
start_proxy
|
||||
start_forwarder tls 18444
|
||||
run openssl s_client -connect 127.0.0.1:18444 -alpn h2,http/1.1 \
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder tls "$fport"
|
||||
run openssl s_client -connect "127.0.0.1:$fport" -alpn h2,http/1.1 \
|
||||
-servername api.anthropic.com -CAfile "$TD/cert.pem" </dev/null
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[[ "$output" == *"ALPN protocol: http/1.1"* ]]
|
||||
@@ -412,8 +429,9 @@ make_cert() {
|
||||
|
||||
@test "forwarder loopback mode serves plaintext http on the port it is given" {
|
||||
start_proxy
|
||||
start_forwarder loopback 18445
|
||||
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder loopback "$fport"
|
||||
run curl -s -X POST "http://127.0.0.1:$fport/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
@@ -437,7 +455,7 @@ make_cert() {
|
||||
# today, so a fall-through lands on an unused port if that ever changes.
|
||||
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT="$(free_port)" \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
|
||||
@@ -467,6 +485,45 @@ make_cert() {
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an oversized body is refused before the 100 Continue that invites it" {
|
||||
# The test above is about what the client ends up with, and it can pass
|
||||
# either way when the timing is kind: the proxy used to answer
|
||||
# `100 Continue` first and refuse from the body reader afterwards, so
|
||||
# whether a client read the 413 or only the 100 was a race. This one asks
|
||||
# the question the race cannot reach - what the FIRST answer is - and never
|
||||
# sends a body at all.
|
||||
start_proxy
|
||||
|
||||
run python3 - "$SOCK" "$PLACEHOLDER" <<'PROBE'
|
||||
import socket, sys
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect(sys.argv[1])
|
||||
# Declared far over the cap, and not one byte of it sent: a client offering a
|
||||
# body is entitled to be told no before it starts.
|
||||
sock.sendall(
|
||||
b"POST /v1/messages HTTP/1.1\r\n"
|
||||
b"Host: x\r\n"
|
||||
b"Authorization: Bearer " + sys.argv[2].encode() + b"\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Content-Length: 99999999999\r\n"
|
||||
b"Expect: 100-continue\r\n\r\n")
|
||||
data = b""
|
||||
while b"\r\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
print(data.split(b"\r\n")[0].decode("ascii", "replace"))
|
||||
PROBE
|
||||
[ "$status" -eq 0 ] || { echo "the probe failed: $output"; return 1; }
|
||||
[[ "$output" == *"413"* ]] \
|
||||
|| { echo "the first answer was not a refusal: $output"; return 1; }
|
||||
grep -q 'refused a request body' "$TD/stderr" \
|
||||
|| { echo "nothing logged: $(cat "$TD/stderr")"; return 1; }
|
||||
}
|
||||
|
||||
@test "a normal-sized request body still passes through" {
|
||||
# The cap must be far above anything real. A conversation with attachments
|
||||
# is the biggest thing a Messages request carries.
|
||||
@@ -816,3 +873,195 @@ print(raw.decode().strip())
|
||||
[[ "$output" == *"path=/v1/"* ]] && [[ "$output" == *"red"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a refresh the provider rejects names the login that fixes it, in the session" {
|
||||
# What an overnight session meets in the morning: the access token has
|
||||
# expired, the refresh token with it, and the provider answers the refresh
|
||||
# with invalid_grant. That is not transient and no amount of retrying or
|
||||
# waiting improves it - the operator has to log in on the host.
|
||||
#
|
||||
# The session is where the failure is met and the host terminal is behind a
|
||||
# window the agent cannot read, so "see host terminal" costs a trip to find
|
||||
# one sentence. This message is written in the proxy rather than taken from
|
||||
# the provider's response, which is what makes it safe to send.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
|
||||
|
||||
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
|
||||
[ "$output" = 502 ] || { echo "status was $output"; return 1; }
|
||||
|
||||
run pcurl -X POST --data '{}' http://x/v1/messages
|
||||
[[ "$output" == *"log in again"* ]] \
|
||||
|| { echo "the session was not told how to fix it: $output"; return 1; }
|
||||
[[ "$output" == *"on the host"* ]] \
|
||||
|| { echo "the session was not told where to fix it: $output"; return 1; }
|
||||
|
||||
# The provider's own bytes stay on the host, which is the rule this is
|
||||
# working within rather than around.
|
||||
[[ "$output" != *"invalid_grant"* ]] \
|
||||
|| { echo "an upstream response body reached the session: $output"; return 1; }
|
||||
grep -q 'invalid_grant' "$TD/stderr" \
|
||||
|| { echo "the host log lost the detail:"; cat "$TD/stderr"; return 1; }
|
||||
|
||||
# And the host log carries the remedy as well as the detail. The exit audit
|
||||
# prints the last line of this file when a session ends, so an operator who
|
||||
# was away while it failed reads it there - which is where this was found
|
||||
# wanting: it showed the provider's error code and nothing to do about it.
|
||||
# The LAST line specifically. bin/dejima's exit audit prints that one when a
|
||||
# session ends, so it is what an operator who was away while it failed reads
|
||||
# without going looking - and it was the provider's error code with no
|
||||
# remedy in it, which is what sent one looking in the first place.
|
||||
run tail -n 1 "$TD/stderr"
|
||||
[[ "$output" == *"log in again"* ]] \
|
||||
|| { echo "the last host line is not the actionable one: $output"; return 1; }
|
||||
[[ "$output" != *"HTTP 400"* ]] \
|
||||
|| { echo "the last host line still leads with the provider's answer: $output"; return 1; }
|
||||
}
|
||||
|
||||
# The exit audit in bin/dejima reads this log. Extracted and driven here rather
|
||||
# than restated, and fed logs this proxy actually wrote, because the two live in
|
||||
# different files and the classification only works if they agree about what a
|
||||
# line looks like.
|
||||
run_exit_audit() { # <proxy log>
|
||||
C_WARN='' C_RESET='' SANDBOX_CREDENTIAL_PROXY_LOG="$1" bash -c '
|
||||
C_WARN=""; C_RESET=""
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG="$1"
|
||||
'"$(sed -n '/_audit_counts="\$(awk/,/unset _audit_counts _audit_last/p' \
|
||||
"$REPO_ROOT/bin/dejima")"'
|
||||
' _ "$1" 2>&1
|
||||
}
|
||||
|
||||
@test "the exit audit does not report a credential failure as the API answering" {
|
||||
# An overnight expiry logs no upstream status, because no request was made -
|
||||
# the credential could not be loaded. Announcing "the model API answered
|
||||
# with an error" there sends an operator to check the provider, and the
|
||||
# retry caveat invites them to hope it succeeded anyway. Neither is true,
|
||||
# and the line the proxy leaves last is an instruction rather than a status.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "reported as the API answering: $output"; return 1; }
|
||||
[[ "$output" != *"may still have succeeded"* ]] \
|
||||
|| { echo "invited a retry that cannot succeed: $output"; return 1; }
|
||||
[[ "$output" == *"could not authenticate"* ]] \
|
||||
|| { echo "did not say what happened: $output"; return 1; }
|
||||
[[ "$output" == *"log in again"* ]] \
|
||||
|| { echo "did not carry the remedy: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an upstream that answered with errors is still reported as one" {
|
||||
# The control: the audit's original purpose has to survive the split.
|
||||
start_proxy
|
||||
pcurl -o /dev/null "http://localhost/status-429"
|
||||
pcurl -o /dev/null "http://localhost/status-429"
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" == *"model API answered with an error"* ]] \
|
||||
|| { echo "an upstream error stopped being reported as one: $output"; return 1; }
|
||||
[[ "$output" == *"2 x HTTP 429"* ]] \
|
||||
|| { echo "the grouping by status is gone: $output"; return 1; }
|
||||
[[ "$output" == *"may still have succeeded"* ]] \
|
||||
|| { echo "the retry caveat is gone from the case it is about: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a request that never reached the upstream is reported as neither case" {
|
||||
# `upstream request failed` carries no status - nothing answered - and no
|
||||
# credential failed either. Classified by "no status lines anywhere", it
|
||||
# read as an authentication failure, sending an operator to log in again
|
||||
# over a connection problem.
|
||||
start_proxy --upstream "http://127.0.0.1:1"
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"could not authenticate"* ]] \
|
||||
|| { echo "a connection failure read as an authentication failure: $output"; return 1; }
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "reported as the API answering: $output"; return 1; }
|
||||
[[ "$output" == *"credential proxy reported a problem"* ]] \
|
||||
|| { echo "the failure went unreported: $output"; return 1; }
|
||||
[[ "$output" == *"upstream request failed"* ]] \
|
||||
|| { echo "the line itself is missing: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "upstream text saying 'answered 503' cannot fabricate a status count" {
|
||||
# The refresh failure's detail line carries up to 200 bytes of the token
|
||||
# endpoint's response body - text the upstream chose. Counted wherever
|
||||
# `answered N` appeared, that body minted "1 x HTTP 503" for a session
|
||||
# that made no request, and brought the retry caveat with it. The count
|
||||
# is anchored to the proxy's own `upstream answered N for` lines.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject?say=the+upstream+answered+503+today" >/dev/null
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "an upstream string was counted as an API answer: $output"; return 1; }
|
||||
[[ "$output" != *"HTTP 503"* ]] \
|
||||
|| { echo "a status count was minted from body text: $output"; return 1; }
|
||||
[[ "$output" == *"could not authenticate"* ]] \
|
||||
|| { echo "the real failure went unreported: $output"; return 1; }
|
||||
[[ "$output" == *"answered 503 today"* ]] \
|
||||
|| { echo "the detail line is not shown: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a negative Content-Length is refused rather than read to EOF" {
|
||||
# The cap exists because this runs on the host as the operator's own user.
|
||||
# int() accepts a negative, a negative passes a `> MAX` test, and a buffered
|
||||
# reader takes read(-1) as "to EOF" - so `Content-Length: -1` read an
|
||||
# unbounded body into host RAM past the cap. A non-numeric value raised out
|
||||
# past the types handle_error knows and put a traceback on the terminal.
|
||||
start_proxy
|
||||
|
||||
run curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" \
|
||||
-H "Authorization: Bearer $PLACEHOLDER" -H 'Content-Length: -1' \
|
||||
-X POST --data 'x' http://x/v1/messages
|
||||
[ "$output" = 413 ] \
|
||||
|| { echo "a negative Content-Length was not refused: $output"; return 1; }
|
||||
|
||||
# A value that is not a number is left alone: it still raises, because the
|
||||
# test above for narrow error suppression uses exactly that fault to prove
|
||||
# the suppression is specific to a client going away. This fix is about the
|
||||
# value that passes the cap and then reads to EOF, not about input tidiness.
|
||||
|
||||
# The log says what arrived, which is what tells a hostile negative length
|
||||
# apart from a genuinely oversized upload.
|
||||
grep -q 'Content-Length of -1' "$TD/stderr" \
|
||||
|| { echo "the refusal log does not name the offending length:"; cat "$TD/stderr"; return 1; }
|
||||
|
||||
# And an ordinary request still works, so the check did not close the door.
|
||||
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
|
||||
[ "$output" = 200 ] || { echo "a normal request was refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a negative chunk size is refused too, not only Content-Length" {
|
||||
# The two paths share one helper today. A refactor that inlines the chunked
|
||||
# branch - int(readline().split(b";")[0], 16) - puts the unbounded read back
|
||||
# with nothing failing, so the chunked shape is asserted in its own right.
|
||||
start_proxy
|
||||
python3 - "$SOCK" <<'PYEOF'
|
||||
import socket, sys
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.connect(sys.argv[1])
|
||||
s.sendall(b"POST /v1/messages HTTP/1.1\r\nHost: proxy\r\n"
|
||||
b"Authorization: Bearer placeholder\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n\r\n-1\r\n")
|
||||
s.settimeout(5)
|
||||
try:
|
||||
got = s.recv(200)
|
||||
except Exception:
|
||||
got = b"<no answer>"
|
||||
print(got.split(b"\r\n")[0].decode("latin-1"))
|
||||
s.close()
|
||||
PYEOF
|
||||
run cat "$TD/stderr"
|
||||
[[ "$output" == *"chunk size of -1"* ]] \
|
||||
|| { echo "a negative chunk size was not refused as one: $output"; return 1; }
|
||||
[[ "$output" != *"Traceback"* ]] \
|
||||
|| { echo "it raised instead of being refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
+25
-7
@@ -143,6 +143,22 @@ touches '^tools/' &&
|
||||
touches '^\.dejima/allowed-domains$' &&
|
||||
manual "A domain here is read from main by the CI hardener and widens every
|
||||
future CI job on that runner. Say in the PR what it is for."
|
||||
# The boundary itself had no pattern at all, so a release that rewrote the
|
||||
# egress policy, the capability floor or the credential path was reported as
|
||||
# touching nothing that needs a manual check - over exactly the things this
|
||||
# project exists to hold.
|
||||
touches '^(docker/egress-policy|docker/firewall-up|docker/fixed-domains|docker/dns-lookup-log|docker/cidr-collapse|bin/dejima-allow)' &&
|
||||
manual "Reach an allowed destination and a refused one from inside a real
|
||||
session, and check a refusal is immediate rather than a hang. The suites
|
||||
drive the policy; only a session drives it with a real resolver."
|
||||
touches '^(docker/caps-lib|docker/docker-compose)' &&
|
||||
manual "Check the capability set of a running session against the floor the
|
||||
compose file declares. A container that starts says nothing about what
|
||||
it kept."
|
||||
touches '^(bin/dejima-credential-proxy|docker/credential-proxy-forwarder|backends/|bin/dejima-credential-scan)' &&
|
||||
manual "Run a session against a real model through the proxy, and let a
|
||||
credential expire or be refused. The failure paths are what the suites
|
||||
reach least."
|
||||
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
|
||||
|
||||
note "Required before this merges"
|
||||
@@ -158,9 +174,11 @@ echo " launch on the other configured harness to confirm it starts."
|
||||
# turns the obvious rollback into a second outage, so it is worth knowing
|
||||
# before rather than after.
|
||||
note "Config keys this release adds (a rollback refuses a config using them)"
|
||||
# Both sides through git, because this reports on $base...$head and the working
|
||||
# tree is neither of them once a head is named explicitly.
|
||||
added="$(comm -13 \
|
||||
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
|
||||
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
|
||||
<(git show "$head:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u))"
|
||||
# Providers too, and this is the sharper half: a [harness.<name>] section for
|
||||
# a harness the older dejima does not ship is an unknown SECTION, which fails
|
||||
# validation exactly as an unknown key does. So adopting a new provider is
|
||||
@@ -193,12 +211,12 @@ new_providers=""
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
git cat-file -e "$head:$axis" 2>/dev/null || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
|
||||
new_providers="$new_providers $one.$name"
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
|
||||
done
|
||||
|
||||
# And the keys a provider DECLARES, which are config keys an operator writes
|
||||
@@ -210,18 +228,18 @@ new_keys=""
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
git cat-file -e "$head:$axis" 2>/dev/null || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
|
||||
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
now="$(git show "$head:$axis/$name/$one.conf" 2>/dev/null \
|
||||
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
|
||||
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
new_keys="$new_keys [$one.$name]|$k"
|
||||
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
|
||||
done
|
||||
|
||||
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
|
||||
|
||||
+750
-9
@@ -33,15 +33,59 @@ setup() {
|
||||
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
|
||||
}
|
||||
teardown() {
|
||||
# An attach client this test backgrounded. `kill` only sends the signal, and
|
||||
# the client - plus the script(1) and tmux processes under it - takes a
|
||||
# moment to go. Removing its directories in that window is what produced
|
||||
# "rm: cannot remove ...: Directory not empty": rm lists a directory,
|
||||
# deletes what it saw, and the still-dying client creates something else
|
||||
# before the rmdir. So the wait is for the process to be gone, not a sleep.
|
||||
if [ -n "${attach_pid:-}" ]; then
|
||||
kill "$attach_pid" 2>/dev/null || true
|
||||
_await_gone kill -0 "$attach_pid"
|
||||
fi
|
||||
# Only ever the private server start_tmux created (its own TMUX_TMPDIR),
|
||||
# never the operator's.
|
||||
if [ -n "${TMUX_DIR:-}" ]; then
|
||||
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
|
||||
# kill-server returns once the server has been told, and the server
|
||||
# unlinks its socket on its own schedule.
|
||||
_await_gone env TMUX_TMPDIR="$TMUX_DIR" tmux has-session
|
||||
fi
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
_rm_settled "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
|
||||
}
|
||||
|
||||
# Waits until the given command FAILS, which is how each of the things above
|
||||
# reports that it is gone. Bounded, and silent on expiry: teardown's job is to
|
||||
# clean up, and a cleanup that fails a passing test is the bug being fixed.
|
||||
_await_gone() {
|
||||
local i
|
||||
for i in $(seq 1 50); do
|
||||
"$@" >/dev/null 2>&1 || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# rm -rf, retried. The waits above remove the cause; this is the backstop for
|
||||
# anything else that writes into a temp directory as it exits, and it reports
|
||||
# what is left rather than failing the test - a leaked temp directory costs
|
||||
# disk, and a red check on a passing test costs a re-run and some trust in the
|
||||
# suite.
|
||||
_rm_settled() {
|
||||
local d i
|
||||
for d in "$@"; do
|
||||
[ -n "$d" ] || continue
|
||||
for i in 1 2 3 4 5; do
|
||||
rm -rf "$d" 2>/dev/null
|
||||
[ -e "$d" ] || break
|
||||
sleep 0.2
|
||||
done
|
||||
[ ! -e "$d" ] \
|
||||
|| echo "teardown: $d survived removal, holding: $(ls -A "$d" 2>/dev/null | tr '\n' ' ')" >&3
|
||||
done
|
||||
}
|
||||
|
||||
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||
|
||||
# Writes a [remote-access] config with enabled = on and base_url set (so
|
||||
@@ -160,10 +204,22 @@ for arg in "$@"; do
|
||||
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
|
||||
break
|
||||
done
|
||||
# The marker directory and what the host created inside it, while it still
|
||||
# exists: bin/dejima's EXIT trap rm -rf's the whole thing, so from outside the
|
||||
# run there is nothing left to look at. The host path comes off the mount
|
||||
# argument, which is the only place this stub can learn it.
|
||||
if [ -n "${SANDBOX_TEST_LEVEL_PROBE:-}" ]; then
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*:/run/dejima-notify)
|
||||
[ ! -d "${arg%%:*}/level" ] || echo level >> "$SANDBOX_TEST_LEVEL_PROBE" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
@@ -188,6 +244,7 @@ launch() {
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||
SANDBOX_TEST_LEVEL_PROBE="$LAUNCH_DIR/level-probe" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
@@ -566,7 +623,9 @@ stub_docker_run_id() {
|
||||
#!/bin/bash
|
||||
case "\$1" in
|
||||
ps) echo dejima-test-container ;;
|
||||
inspect) echo "DEJIMA_RUN_ID=$1" ;;
|
||||
# One line per container, both values JSON, which is the shape
|
||||
# dejima-attach asks for so that one call answers for all of them.
|
||||
inspect) echo '"/dejima-test-container" ["PATH=/usr/bin","DEJIMA_RUN_ID=$1"]' ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
@@ -589,6 +648,9 @@ label=$3
|
||||
started=${4:-$(date +%s)}
|
||||
EOF
|
||||
[ -z "${5:-}" ] || printf 'pid=%s\n' "$5" >> "$RUNTIME_DIR/dejima/sessions/$1"
|
||||
# bin/dejima writes name= first; written last here on purpose, so the
|
||||
# reader is exercised against a field order it does not control.
|
||||
[ -z "${6:-}" ] || printf 'name=%s\n' "$6" >> "$RUNTIME_DIR/dejima/sessions/$1"
|
||||
}
|
||||
|
||||
# A pid that is certain to be dead: spawn a process and reap it, so the number
|
||||
@@ -674,6 +736,552 @@ dead_pid() {
|
||||
[[ "$output" == *"no-tmux"* ]]
|
||||
}
|
||||
|
||||
# A `docker` that records the launching window's name instead of its argv. The
|
||||
# rename happens before the first docker call and is undone by the EXIT trap,
|
||||
# so from outside the run there is no moment at which it is observable - the
|
||||
# stub is standing in for that moment.
|
||||
stub_window_probe() {
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
tmux display-message -p -t '$TARGET_PANE' '#{window_name}' >> "$LAUNCH_DIR/winname"
|
||||
# And what the registry recorded for the same launch, which the exit trap
|
||||
# removes too. The two have to be one value: the window is how an operator
|
||||
# finds the session and the entry is how both front doors name it.
|
||||
sed -n 's/^name=//p' "$RUNTIME_DIR"/dejima/sessions/* 2>/dev/null >> "$LAUNCH_DIR/regname"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
: > "$LAUNCH_DIR/regname"
|
||||
}
|
||||
|
||||
@test "an unnamed launch takes the project's name, and a colliding one takes a suffix" {
|
||||
# Most launches pass no --name, so the default is what this feature mostly
|
||||
# is. A collision must not stop a launch: several sessions against one
|
||||
# project is the case the name exists for, not an error.
|
||||
stub_launch
|
||||
write_remote_config
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
local proj; proj="$(basename "$PROJ")"
|
||||
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
# The window and the registry carry one value, not two derivations of it.
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|
||||
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
|
||||
|
||||
# A live session already holding the name. $$ is this bats process, which
|
||||
# is alive by construction.
|
||||
write_entry 0000000000000001 '%1' 'work:1' '' $$ "$proj"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
write_entry 0000000000000002 '%1' 'work:2' '' $$ "$proj-2"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj-3" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# The FIRST free suffix: a number a finished session gives back is used
|
||||
# again rather than the count climbing for the life of the host.
|
||||
rm "$RUNTIME_DIR/dejima/sessions/0000000000000001"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# An entry whose launcher is gone is holding no name: the exit trap removes
|
||||
# the entry, but a session killed outright leaves it behind.
|
||||
write_entry 0000000000000003 '%1' 'work:3' '' "$(dead_pid)" "$proj"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# An explicit --name is taken verbatim, collision or not: it was typed for
|
||||
# this launch, and handing back something else would be worse than two rows
|
||||
# that agree.
|
||||
write_entry 0000000000000004 '%1' 'work:4' '' $$ 'mine'
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch --name mine "$PROJ"
|
||||
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "a tmux window already holding the name is a collision too" {
|
||||
# The other source, and the one that works with remote access off: there is
|
||||
# no registry then, and two windows wearing one name is exactly what the
|
||||
# name is meant to prevent.
|
||||
stub_launch
|
||||
write_remote_config
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
local proj; proj="$(basename "$PROJ")"
|
||||
|
||||
tmux rename-window -t work:0 "$proj"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# But not this launch's own window, which may already carry the name from
|
||||
# the session before it or from automatic-rename guessing the directory.
|
||||
#
|
||||
# Asserted on the registry rather than on the window: renaming a window by
|
||||
# hand turns automatic-rename off, which is exactly the case where the
|
||||
# launch leaves the name alone - so the window would read `$proj` here
|
||||
# whatever label was chosen, and the assertion would hold for the wrong
|
||||
# reason.
|
||||
tmux rename-window -t work:0 'other'
|
||||
tmux rename-window -t "$TARGET_PANE" "$proj"
|
||||
: > "$LAUNCH_DIR/regname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|
||||
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "a project name too long or too odd for a label is cut down, not refused" {
|
||||
# --name refuses these shapes because the operator typed them. A directory
|
||||
# name was not chosen for this, so a launch that stopped over one would be
|
||||
# refusing over something it can fix itself.
|
||||
stub_launch
|
||||
write_remote_config
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
|
||||
local long; long="$(printf 'x%.0s' {1..50})"
|
||||
local deep="$PROJ/$long"
|
||||
mkdir -p "$deep"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$deep"
|
||||
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:32}" ] \
|
||||
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# And the suffix still fits, which means the base gives room for it rather
|
||||
# than the label growing past the cap.
|
||||
write_entry 0000000000000001 '%1' 'work:1' '' $$ "${long:0:32}"
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$deep"
|
||||
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:30}-2" ] \
|
||||
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "the launching tmux window is named for the session and handed back after" {
|
||||
# tmux's own navigation - choose-window, the status line, the window list -
|
||||
# shows a window name, and for a dozen sandboxes it showed a dozen copies
|
||||
# of whatever automatic-rename made of the container command.
|
||||
stub_launch
|
||||
start_tmux
|
||||
stub_window_probe
|
||||
|
||||
TMUX_PANE="$TARGET_PANE" launch --name 'release notes' "$PROJ"
|
||||
grep -qxF 'release notes' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
# Handed back, so the window does not keep the name of a session that has
|
||||
# exited. The option is the assertion rather than the name: tmux recomputes
|
||||
# the name on its own schedule, but turning the option back on is what this
|
||||
# does and what makes the recompute happen at all.
|
||||
[ "$(tmux display-message -p -t "$TARGET_PANE" '#{automatic-rename}')" = 1 ] \
|
||||
|| { echo "automatic-rename was left off"; return 1; }
|
||||
|
||||
# Without --name the project stands in, which is still better than the
|
||||
# container command for finding one window among many.
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF "$(basename "$PROJ")" "$LAUNCH_DIR/winname" \
|
||||
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
|
||||
# A window the operator named by hand keeps that name. tmux turns
|
||||
# automatic-rename off on rename, so the option is the evidence that the
|
||||
# current name is tmux's guess rather than a choice to leave alone.
|
||||
tmux rename-window -t "$TARGET_PANE" 'mine'
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
|
||||
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "the operator's own window name was taken: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
# But an explicit --name was typed for this launch, so it wins over that.
|
||||
: > "$LAUNCH_DIR/winname"
|
||||
TMUX_PANE="$TARGET_PANE" launch --name 'explicit' "$PROJ"
|
||||
grep -qxF 'explicit' "$LAUNCH_DIR/winname" \
|
||||
|| { echo "--name lost to a hand-named window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
# The level the hook would have written, for a session this suite registered
|
||||
# by hand. $STATE_DIR/notify/<run-id>/level is the host's shape, not the
|
||||
# session's, so writing it here is standing in for the hook and not for a
|
||||
# sandbox reaching somewhere it should not.
|
||||
write_level() { # <run-id> <state>
|
||||
mkdir -p "$RUNTIME_DIR/dejima/notify/$1/level"
|
||||
: > "$RUNTIME_DIR/dejima/notify/$1/level/$2"
|
||||
}
|
||||
|
||||
@test "docker is asked twice for a whole listing, whatever it holds" {
|
||||
# It used to run `docker ps` and then inspect containers until it found a
|
||||
# match, per registry entry: quadratic in the number of sessions, on the
|
||||
# listing both front doors open on. A dozen parallel sessions is the case
|
||||
# this tool exists for, so the cost has to be linear in them.
|
||||
write_remote_config
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
|
||||
case "\$1" in
|
||||
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
|
||||
# Every name it was handed, one line each: dejima-attach asks about all
|
||||
# of them in one call, so a stub answering only for the last would
|
||||
# silently prune every other session.
|
||||
inspect)
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
|
||||
esac
|
||||
done ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
: > "$BATS_TEST_TMPDIR/docker-calls"
|
||||
local i
|
||||
for i in 1 2 3 4; do write_entry "000000000000000$i" '%1' "work:$i"; done
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|
||||
|| { echo "docker ps ran $(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
|
||||
# One for all of them, not one each and not one per session: this is two
|
||||
# round trips to the daemon whatever the host is running, where it was one
|
||||
# plus one per container, and before that one plus one per container per
|
||||
# session.
|
||||
[ "$(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|
||||
|| { echo "inspect ran $(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
|
||||
# And it still answered: a listing that asks nothing would also be cheap.
|
||||
[ "$(printf '%s\n' "$output" | wc -l)" = 4 ]
|
||||
}
|
||||
|
||||
@test "the picker's row is narrow enough for the phone that reads it" {
|
||||
# Five columns made a row about eighty characters wide, which wrapped onto
|
||||
# a second line on a phone and put the state and the age underneath the
|
||||
# NEXT session's number. The two that went are in --list, which is where a
|
||||
# script reads them: the tmux label is a coordinate nobody has to act on
|
||||
# now that picking a session goes to it, and every container id shares a
|
||||
# prefix with every other.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' \
|
||||
$(( $(date +%s) - 7200 )) $$ 'release-notes'
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
local row
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F release-notes)"
|
||||
[ -n "$row" ] || { echo "no row for the session"; return 1; }
|
||||
[ "${#row}" -le 52 ] || { echo "row is ${#row} columns: $row"; return 1; }
|
||||
[[ "$row" != *"work:1"* ]] || { echo "the tmux label is still in the row: $row"; return 1; }
|
||||
[[ "$row" != *"c-0123456789"* ]] || { echo "the container id is still in the row: $row"; return 1; }
|
||||
# Two hours, not a hundred and twenty minutes: three characters is what
|
||||
# makes the row fit, and the sum is one an operator should not have to do.
|
||||
[[ "$row" == *"2h"* ]] || { echo "age reads: $row"; return 1; }
|
||||
|
||||
# Both are still in the listing a script reads, in the units it wants.
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"work:1"* ]] || { echo "--list lost the label: $output"; return 1; }
|
||||
[[ "$output" == *"120m"* ]] || { echo "--list lost the minutes: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the name column is what the terminal has room for, and says when it cut one" {
|
||||
# A fixed width is wrong in both directions at once: a desktop has room for
|
||||
# every name and would cut them anyway, and a narrow phone would still
|
||||
# overflow and wrap, which is the failure this layout exists to avoid.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'flashpoint-ultimate-server'
|
||||
|
||||
local row
|
||||
run script -qec "stty cols 70; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint)"
|
||||
[[ "$row" == *"flashpoint-ultimate-server"* ]] \
|
||||
|| { echo "a terminal with room still cut it: $row"; return 1; }
|
||||
|
||||
run script -qec "stty cols 50; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint | tr -d '\r')"
|
||||
[[ "$row" != *"flashpoint-ultimate-server"* ]] \
|
||||
|| { echo "the name was not cut to fit: $row"; return 1; }
|
||||
# Marked, because a name quietly losing its last characters is worse than a
|
||||
# short column: two projects can differ only past the cut.
|
||||
[[ "$row" == *"~"* ]] || { echo "the cut was silent: $row"; return 1; }
|
||||
# The dot is one column and three bytes, and ${#} counts bytes wherever
|
||||
# the locale is not UTF-8 - which is this suite. Swapped for one byte so
|
||||
# the measurement is of columns, which is what has to fit.
|
||||
row="${row//●/*}"
|
||||
[ "${#row}" -le 50 ] || { echo "row is ${#row} columns on a 50-column terminal: $row"; return 1; }
|
||||
}
|
||||
|
||||
@test "a session still building reaches the picker as starting, not as attachable" {
|
||||
# It was the container column that said so, and that column is gone. A
|
||||
# session whose image is still being built is not one to attach to yet.
|
||||
write_remote_config
|
||||
stub_docker_run_id ffffffffffffffff
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'building'
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
local row
|
||||
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F building)"
|
||||
[[ "$row" == *"starting"* ]] || { echo "row reads: $row"; return 1; }
|
||||
[[ "$row" != *"attachable"* ]] || { echo "row reads: $row"; return 1; }
|
||||
}
|
||||
|
||||
@test "an empty field does not shift the picker's columns" {
|
||||
# A tab is IFS whitespace, so a run of them collapses into one delimiter.
|
||||
# An entry written before the name field existed has an empty one, and the
|
||||
# picker then read the tmux label as the name, the state as the age and the
|
||||
# container id as the state - so a stale session was neither coloured as
|
||||
# one nor labelled stale where it was being read.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef '%99999' 'work:1'
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
local plain; plain="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output")"
|
||||
# The project is the first thing after the index and the dot, where the
|
||||
# label used to land.
|
||||
# [^[:space:]]+ for the dot rather than `.`, which matches one BYTE of a
|
||||
# three-byte glyph outside a UTF-8 locale.
|
||||
grep -qE '^[[:space:]]*1[[:space:]]+[^[:space:]]+[[:space:]]+myproject' <<< "$plain" \
|
||||
|| { echo "row: $(grep -F myproject <<< "$plain")"; return 1; }
|
||||
# And the state is in the state column, which is what colours it: under the
|
||||
# shift this was the age column's muted grey.
|
||||
[[ "$output" == *$'\033[91m'"stale"* ]] \
|
||||
|| { echo "stale was not coloured as a failure state"; return 1; }
|
||||
}
|
||||
|
||||
@test "the listing says which sessions want attention and which are working" {
|
||||
# The bouncing-between-windows problem: every row said "attachable",
|
||||
# which is true of all of them and the answer to a question nobody asked.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' $(( $(date +%s) - 300 ))
|
||||
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2' $(( $(date +%s) - 200 ))
|
||||
write_entry 00112233445566aa "$TARGET_PANE" 'work:3' $(( $(date +%s) - 100 ))
|
||||
write_level 0123456789abcdef done
|
||||
write_level fedcba9876543210 running
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"0123456789abcdef"*$'\t'"waiting"$'\t'* ]] \
|
||||
|| { echo "stopped session: $output"; return 1; }
|
||||
[[ "$output" == *"fedcba9876543210"*$'\t'"working"$'\t'* ]] \
|
||||
|| { echo "running session: $output"; return 1; }
|
||||
# Said nothing yet, so the plain reachability answer stands. A harness that
|
||||
# reports no state at all has to keep working, and so does a session that
|
||||
# started before its first prompt.
|
||||
[[ "$output" == *"00112233445566aa"*$'\t'"attachable"$'\t'* ]] \
|
||||
|| { echo "silent session: $output"; return 1; }
|
||||
|
||||
# waiting is the other half of the same question, for a harness that can
|
||||
# say it: both render as one word, because an operator acts on them alike.
|
||||
rm "$RUNTIME_DIR/dejima/notify/0123456789abcdef/level/done"
|
||||
write_level 0123456789abcdef waiting
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"waiting"* ]] || { echo "$output"; return 1; }
|
||||
|
||||
# The hook clears the siblings before writing, so holding two levels at
|
||||
# once means a session has contradicted itself - which it can, the
|
||||
# directory being its own. The answer that sends an operator to look is the
|
||||
# one to be wrong with.
|
||||
write_level 00112233445566aa running
|
||||
write_level 00112233445566aa done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"00112233445566aa"*$'\t'"waiting"$'\t'* ]] \
|
||||
|| { echo "contradicting session: $output"; return 1; }
|
||||
rm "$RUNTIME_DIR/dejima/notify/00112233445566aa/level/done" \
|
||||
"$RUNTIME_DIR/dejima/notify/00112233445566aa/level/running"
|
||||
|
||||
# A session that cannot be attached to has nothing useful to say about
|
||||
# whether it wants attention, so reachability still wins the column.
|
||||
write_entry ffeeddccbbaa9988 '%99999' 'work:9' $(( $(date +%s) - 50 ))
|
||||
write_level ffeeddccbbaa9988 done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"ffeeddccbbaa9988"*$'\t'"stale"$'\t'* ]] \
|
||||
|| { echo "stale session: $output"; return 1; }
|
||||
|
||||
# Oldest first, and by start time rather than by state: the numbers are how
|
||||
# a session is picked, so an order that followed the state would renumber
|
||||
# the rows between an operator reading one and typing it.
|
||||
run bash -c "'$REPO_ROOT/bin/dejima-attach' --list | cut -f1 | tr '\n' ' '"
|
||||
[ "$output" = "0123456789abcdef fedcba9876543210 00112233445566aa ffeeddccbbaa9988 " ] \
|
||||
|| { echo "order was: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--count-waiting counts the sessions an operator has to go to, and asks docker nothing" {
|
||||
# For a tmux status line, which re-runs it every few seconds: the point of
|
||||
# the number is to not have to open anything, so it must not cost a round
|
||||
# trip per container to produce.
|
||||
write_remote_config
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
: > "$BATS_TEST_TMPDIR/docker-calls"
|
||||
start_tmux
|
||||
|
||||
# Two waiting, one working, one waiting but unreachable: the last is a
|
||||
# session nothing can be done about, and counting it sends an operator
|
||||
# looking for a window that is not there.
|
||||
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
|
||||
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
|
||||
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
|
||||
write_entry 0000000000000004 '%99999' 'work:4' '' $$
|
||||
write_level 0000000000000001 done
|
||||
write_level 0000000000000002 waiting
|
||||
write_level 0000000000000003 running
|
||||
write_level 0000000000000004 done
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = 2 ] || { echo "counted $output"; return 1; }
|
||||
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|
||||
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
|
||||
|
||||
# And a dead launcher is not waiting for anyone.
|
||||
write_entry 0000000000000005 "$TARGET_PANE" 'work:5' '' "$(dead_pid)"
|
||||
write_level 0000000000000005 done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
|
||||
[ "$output" = 2 ] || { echo "counted $output with a dead launcher"; return 1; }
|
||||
}
|
||||
|
||||
@test "--counts answers for every state in one pass, and asks docker nothing" {
|
||||
# Two numbers on a status bar cost two invocations otherwise, and this is
|
||||
# the one thing here that runs on a timer.
|
||||
write_remote_config
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
: > "$BATS_TEST_TMPDIR/docker-calls"
|
||||
start_tmux
|
||||
|
||||
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
|
||||
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
|
||||
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
|
||||
write_entry 0000000000000004 '%99999' 'work:4' '' $$
|
||||
write_entry 0000000000000005 '' '' '' $$
|
||||
write_level 0000000000000001 done
|
||||
write_level 0000000000000002 running
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --counts
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|
||||
|| { echo "counts read: $output"; return 1; }
|
||||
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|
||||
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
|
||||
|
||||
# The words are the picker's, so a number on a status bar and a row on a
|
||||
# screen say the same thing about the same session.
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[[ "$output" == *"waiting"* ]] && [[ "$output" == *"no-tmux"* ]] \
|
||||
|| { echo "the listing uses different words: $output"; return 1; }
|
||||
|
||||
# A session whose launcher is gone is not counted at all: the listing is
|
||||
# where those are found, and a status bar that never reaches zero stops
|
||||
# being read.
|
||||
write_entry 0000000000000006 "$TARGET_PANE" 'work:6' '' "$(dead_pid)"
|
||||
write_level 0000000000000006 done
|
||||
run "$REPO_ROOT/bin/dejima-attach" --counts
|
||||
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|
||||
|| { echo "a dead launcher was counted: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--state-dir names the directory the registry is actually in" {
|
||||
# For anything on this host that wants to read the states without
|
||||
# re-deriving which of XDG_RUNTIME_DIR and /tmp this user got.
|
||||
write_remote_config
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --state-dir
|
||||
[ "$status" -eq 0 ]
|
||||
[ -d "$output" ] || { echo "not a directory: $output"; return 1; }
|
||||
[ -f "$output/sessions/0123456789abcdef" ] \
|
||||
|| { echo "the registry is not under $output"; return 1; }
|
||||
# And the levels, which is the half an outside reader is actually after.
|
||||
write_level 0123456789abcdef running
|
||||
[ -e "$output/notify/0123456789abcdef/level/running" ] \
|
||||
|| { echo "the levels are not under $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--watch redraws the list until something is typed" {
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||
|
||||
# Nothing typed for longer than one interval, then a quit. Two drawings of
|
||||
# the heading is the assertion: the one-shot picker would print it once and
|
||||
# then sit on a read that never returns.
|
||||
run script -qec "{ sleep 8; echo q; } | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(grep -c 'live sessions' <<< "$output")" -ge 2 ] \
|
||||
|| { echo "drew $(grep -c 'live sessions' <<< "$output") times"; return 1; }
|
||||
|
||||
# And it is still the picker: a number attaches rather than being read as
|
||||
# part of the next redraw. Nothing here can attach - there is no terminal
|
||||
# for it - but reaching the attempt at all is what distinguishes picking
|
||||
# from the prompt timing out again.
|
||||
run script -qec "echo 1 | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
|
||||
[[ "$output" == *"live sessions"* ]]
|
||||
[ "$(grep -c 'live sessions' <<< "$output")" = 1 ] \
|
||||
|| { echo "a typed choice did not end the loop"; return 1; }
|
||||
}
|
||||
|
||||
@test "a session's name reaches both the listing and the picker" {
|
||||
# The case the name exists for: two sessions, one workspace, so the
|
||||
# project column answers nothing. --list carries both fields; the picker
|
||||
# has one column for the two and spends it on whichever is more specific.
|
||||
write_remote_config
|
||||
stub_docker_sessions
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' '' 'release notes'
|
||||
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2'
|
||||
|
||||
run "$REPO_ROOT/bin/dejima-attach" --list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"0123456789abcdef"$'\t'"release notes"$'\t'"work:1"$'\t'"myproject"$'\t'* ]] \
|
||||
|| { echo "named row: $output"; return 1; }
|
||||
# An entry from before the field existed: present and empty rather than
|
||||
# absent, so a reader splitting on tabs does not find the project where
|
||||
# the name should be. A launch always writes one now.
|
||||
[[ "$output" == *"fedcba9876543210"$'\t'$'\t'"work:2"$'\t'"myproject"$'\t'* ]] \
|
||||
|| { echo "unnamed row: $output"; return 1; }
|
||||
|
||||
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"release notes"* ]] \
|
||||
|| { echo "the picker dropped the name: $output"; return 1; }
|
||||
[[ "$output" == *"myproject"* ]] \
|
||||
|| { echo "the picker dropped the unnamed row's project: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--list marks a session whose pane is gone as stale" {
|
||||
write_remote_config
|
||||
stub_docker_run_id 0123456789abcdef
|
||||
@@ -691,7 +1299,15 @@ stub_docker_sessions() {
|
||||
#!/bin/bash
|
||||
case "\$1" in
|
||||
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
|
||||
inspect) name="\${!#}"; echo "DEJIMA_RUN_ID=\${name#c-}" ;;
|
||||
# Every name it was handed, one line each: dejima-attach asks about all
|
||||
# of them in one call, so a stub answering only for the last would
|
||||
# silently prune every other session.
|
||||
inspect)
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
|
||||
esac
|
||||
done ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
@@ -821,6 +1437,46 @@ start_tmux() {
|
||||
done
|
||||
}
|
||||
|
||||
@test "inside tmux the picker switches the client instead of nesting a session" {
|
||||
# tmux refuses to attach a session from inside one of its own panes, so on
|
||||
# the operator's own machine the picker could list every session and then
|
||||
# reach none of them. Switching is also what makes it a window switcher,
|
||||
# which is the thing a dozen parallel sessions actually need.
|
||||
write_remote_config
|
||||
stub_docker_run_id 0123456789abcdef
|
||||
start_tmux
|
||||
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||
|
||||
# A real client, because switch-client has nothing to move without one,
|
||||
# and it starts on window 0 while the session being picked is window 1.
|
||||
script -qec "tmux attach -t work" /dev/null >/dev/null 2>&1 &
|
||||
client_pid=$!
|
||||
for _ in $(seq 100); do
|
||||
[ -n "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
tmux select-window -t work:0
|
||||
[ "$(tmux display-message -p -t work '#{window_index}')" = "0" ]
|
||||
|
||||
# Run from inside a pane of that session, which is how an operator reaches
|
||||
# it and the only way $TMUX is set for the right server.
|
||||
tmux new-window -t work -d \
|
||||
"'$REPO_ROOT/bin/dejima-attach' 0123456789abcdef; touch '$BATS_TEST_TMPDIR/switched'"
|
||||
for _ in $(seq 100); do
|
||||
[ -e "$BATS_TEST_TMPDIR/switched" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -e "$BATS_TEST_TMPDIR/switched" ] || { echo "dejima-attach never returned"; return 1; }
|
||||
|
||||
# The client moved to the sandbox's own window, and no throwaway session
|
||||
# was created to do it.
|
||||
[ "$(tmux display-message -p -t work '#{window_index}')" = "1" ] \
|
||||
|| { echo "client is on window $(tmux display-message -p -t work '#{window_index}')"; return 1; }
|
||||
run tmux has-session -t '=dejima-0123456789abcdef'
|
||||
[ "$status" -ne 0 ] || { echo "a grouped session was created anyway"; return 1; }
|
||||
kill "$client_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
|
||||
# The positive direction, which is where every interesting tmux mistake
|
||||
# lives: an invalid set-option target, or setting destroy-unattached while
|
||||
@@ -1327,6 +1983,46 @@ EOF
|
||||
rm -rf "$NOTIFY" "$payload_file"
|
||||
}
|
||||
|
||||
@test "the notify hook keeps a level beside the edge, and only the edge is pushable" {
|
||||
# The edge markers are consumed by the watcher, so after a push there is
|
||||
# nothing left to read - and with no watcher running, nothing has read them
|
||||
# at all. The level answers "which state is this session in now" for the
|
||||
# picker and the status line, which is a different question from "it just
|
||||
# changed" and needs an answer that survives being read.
|
||||
NOTIFY="$(mktemp -d)"
|
||||
mkdir "$NOTIFY/level"
|
||||
|
||||
# `running` is level-only: the watcher iterates waiting and done, so an
|
||||
# edge marker it never looks for would be a file nothing ever removes.
|
||||
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" running
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(ls -A "$NOTIFY" | sort | tr '\n' ' ')" = "level " ] \
|
||||
|| { echo "running left an edge marker: $(ls -A "$NOTIFY")"; return 1; }
|
||||
[ "$(ls -A "$NOTIFY/level")" = running ]
|
||||
[ ! -s "$NOTIFY/level/running" ]
|
||||
|
||||
# Exactly one level at a time, so a reader never has to choose.
|
||||
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" done
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(ls -A "$NOTIFY/level")" = done ] \
|
||||
|| { echo "level holds: $(ls -A "$NOTIFY/level")"; return 1; }
|
||||
[ -f "$NOTIFY/done" ] || { echo "done left no edge marker"; return 1; }
|
||||
|
||||
# The same whitelist covers the level: a name outside the enum writes
|
||||
# neither, and leaves the state that was there.
|
||||
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" 'running;done'
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(ls -A "$NOTIFY/level")" = done ]
|
||||
|
||||
# A run directory with no level/ is a launch from before it existed. The
|
||||
# edge still works there, which is what keeps notifications independent.
|
||||
OLD="$(mktemp -d)"
|
||||
DEJIMA_NOTIFY_DIR="$OLD" run "$REPO_ROOT/hooks/dejima-notify.sh" waiting
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$OLD/waiting" ]
|
||||
rm -rf "$NOTIFY" "$OLD"
|
||||
}
|
||||
|
||||
@test "the notify hook refuses any event name outside the enum" {
|
||||
NOTIFY="$(mktemp -d)"
|
||||
for bad in 'error' '../escape' 'waiting done' '' '$(id)' 'waiting;done' '.'; do
|
||||
@@ -1375,6 +2071,21 @@ EOF
|
||||
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
||||
[[ "$output" != *operator-desktop-notify.sh* ]]
|
||||
[[ "$output" != *operator-tts.sh* ]]
|
||||
|
||||
# The complement of Stop, for the level and not for the phone: without it
|
||||
# a picker cannot tell "this session has stopped" from "nothing has been
|
||||
# heard from this session". It pushes nothing, because the hook writes no
|
||||
# edge marker for `running`.
|
||||
grep -q 'dejima-notify.sh running' "$LAUNCH_DIR/settings-copy.json" \
|
||||
|| { echo "nothing marks the session as working again"; return 1; }
|
||||
[ "$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "$LAUNCH_DIR/settings-copy.json")" \
|
||||
= "/usr/local/lib/dejima/hooks/dejima-notify.sh running" ]
|
||||
|
||||
# And the directory the hook writes that level into is the host's to
|
||||
# create, like the marker directory it sits in. Observed from inside the
|
||||
# run, because the EXIT trap takes the whole tree with it.
|
||||
grep -qxF level "$LAUNCH_DIR/level-probe" \
|
||||
|| { echo "no level directory was created inside the marker directory"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@@ -2003,15 +2714,16 @@ EOF
|
||||
}
|
||||
ttyd_log() { cat "$STUB_DIR/ttyd-argv" 2>/dev/null; }
|
||||
|
||||
# Reports no listener, whatever the host is actually running. Opt-in, and
|
||||
# deliberately not part of stub_ttyd: the port-in-use refusal has its own test
|
||||
# below, which binds a real socket and needs the real ss to see it.
|
||||
# Reports no listener, whatever the host is actually running. Called beside
|
||||
# stub_ttyd by every ttyd test but one, and deliberately not folded into it:
|
||||
# the port-in-use refusal binds a real socket and needs the real ss to see it.
|
||||
#
|
||||
# For every OTHER ttyd test the port being free is a precondition, not the
|
||||
# subject - and one that was being inherited from the machine rather than
|
||||
# stated. The default-port case asserts against 7681, which is exactly the
|
||||
# port a real dejima-ttyd front door holds, so it failed on any host running
|
||||
# the feature: the machine a developer is most likely to run this suite on.
|
||||
# The rest name 12345, which is free on most machines and on none reliably.
|
||||
# Same shape as #56 - a test reading its environment instead of setting it.
|
||||
stub_ss_no_listeners() {
|
||||
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
|
||||
@@ -2087,6 +2799,8 @@ unit_exec_start() {
|
||||
@test "dejima-ttyd refuses to start when something else already holds its port" {
|
||||
command -v ss >/dev/null 2>&1 || skip "ss is not available"
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
# The one ttyd test with no stub_ss_no_listeners beside stub_ttyd: ss
|
||||
# seeing the socket bound below is the subject here, not a precondition.
|
||||
stub_ttyd
|
||||
|
||||
# The distro's ttyd package ships an enabled service that binds every
|
||||
@@ -2143,6 +2857,7 @@ PY
|
||||
@test "dejima-ttyd refuses to start when remote access is unconfigured, before ttyd is reached" {
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not configured"* ]]
|
||||
@@ -2254,6 +2969,7 @@ PY
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
write_remote_config 'ttyd_port = 99999'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
# Named, so this cannot pass because ttyd was missing or the config was.
|
||||
@@ -2270,6 +2986,7 @@ PY
|
||||
# currently happens to agree with it.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345
|
||||
@@ -2299,6 +3016,7 @@ PY
|
||||
local bad
|
||||
for bad in 0 7 73 999 abc 1e2 -20; do
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' "ttyd_font_size = $bad"
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
@@ -2319,6 +3037,7 @@ PY
|
||||
# moment either default moves.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345
|
||||
@@ -2347,6 +3066,7 @@ PY
|
||||
# a host-wide "off" written here would be a different thing from silence.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
|
||||
@@ -2378,6 +3098,7 @@ PY
|
||||
# would be wrong for whichever half of the clients it did not match.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
|
||||
@@ -2413,6 +3134,7 @@ PY
|
||||
local bad
|
||||
for bad in b M-b C-ab C- ctrl-a 2 'C-\' ; do
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' "ttyd_tmux_prefix = $bad"
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
@@ -2442,6 +3164,7 @@ PY
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$fake/bin/"
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$fake/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"$fake/web/index.html"* ]]
|
||||
@@ -2459,7 +3182,7 @@ PY
|
||||
local web="$REPO_ROOT/web"
|
||||
[ -f "$web/index.html" ]
|
||||
[ -f "$web/sources.sha256" ]
|
||||
run bash -c "cd '$web' && sha256sum src/* package-lock.json | LC_ALL=C sort -k2"
|
||||
run bash -c "cd '$web' && sha256sum build.mjs src/* package-lock.json | LC_ALL=C sort -k2"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "$(cat "$web/sources.sha256")" ] || {
|
||||
echo "web/sources.sha256 is stale. Rebuild it:" >&2
|
||||
@@ -2497,10 +3220,26 @@ PY
|
||||
# restricts outbound traffic to an allowlist, so a CDN reference would not
|
||||
# resolve even from a browser outside it if the operator is on the same
|
||||
# restricted network.
|
||||
#
|
||||
# A `data:` URI is exempt and only that: it carries its bytes in the
|
||||
# attribute, so it fetches nothing and neither reason above applies to it.
|
||||
# The favicon is one. Matched per attribute rather than per line, so a data
|
||||
# URI on a line cannot carry a real reference past this beside it.
|
||||
local page="$REPO_ROOT/web/index.html"
|
||||
[ -f "$page" ]
|
||||
run grep -nEi '<(script|link|img|iframe)[^>]+(src|href)=' "$page"
|
||||
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$page' \
|
||||
| grep -vEi '(src|href)=\"data:'"
|
||||
[ "$status" -ne 0 ] || { echo "the page references an external asset: $output" >&2; false; }
|
||||
|
||||
# And the exemption is not a hole: anything that is not a data URI still
|
||||
# fails, checked against a copy rather than trusted.
|
||||
local probe="$BATS_TEST_TMPDIR/probe.html"
|
||||
sed 's|<link rel="icon"|<link rel="stylesheet" href="https://cdn.example.com/x.css"><link rel="icon"|' \
|
||||
"$page" > "$probe"
|
||||
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$probe' \
|
||||
| grep -vEi '(src|href)=\"data:'"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "the check no longer catches a real external reference" >&2; false; }
|
||||
}
|
||||
|
||||
@test "the terminal page's own unit tests pass" {
|
||||
@@ -2525,6 +3264,7 @@ PY
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
# Executed, not grepped: the flag has to reach ttyd's argv.
|
||||
@@ -2928,6 +3668,7 @@ nginx_blocks() {
|
||||
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = 12345\n' \
|
||||
> "$FAKE_HOME/.config/dejima/config"
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
|
||||
local exec_start
|
||||
exec_start="$(unit_exec_start dejima-ttyd.service "$FAKE_HOME")"
|
||||
|
||||
+87
-26
@@ -37,43 +37,84 @@ PRIVILEGED=" runtime ci-runner "
|
||||
# is a path nobody has decided about, and guessing "none" there is how a change
|
||||
# ships having been checked by nothing.
|
||||
suites_for() {
|
||||
# Every suite that READS the path, not the one that owns it. static.bats
|
||||
# asserts invariants over the whole tree and config.bats sources most of
|
||||
# the libraries, so both appear far more often than a by-area reading
|
||||
# suggests - and leaving them out is what made the --changed lane report an
|
||||
# edit as green from a selection that never loaded the file.
|
||||
#
|
||||
# The map is checked against the suites mechanically; see the test named
|
||||
# for it in static.bats. Over-selecting costs a suite run, under-selecting
|
||||
# costs a missed regression, so a rule here errs wide.
|
||||
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-audit) echo 'audit config proxy remote static' ;;
|
||||
bin/dejima-update-check) echo 'config project remote static' ;;
|
||||
bin/dejima-epitaph) echo 'config static' ;;
|
||||
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 ;;
|
||||
bin/dejima-color-lib.sh) echo 'remote project config static' ;;
|
||||
bin/dejima-tui-lib.sh) echo 'remote project static' ;;
|
||||
bin/dejima-conversation-ship) echo 'conversation static' ;;
|
||||
bin/dejima-share) echo 'share static' ;;
|
||||
# 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 ;;
|
||||
bin/dejima-slug-lib.sh) echo 'share conversation audit project static' ;;
|
||||
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo 'proxy static' ;;
|
||||
bin/dejima-credential-scan*) echo 'config static' ;;
|
||||
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep) \
|
||||
echo 'project config launch providers remote static' ;;
|
||||
bin/dejima-allow) echo 'project static config' ;;
|
||||
bin/dejima-attach) echo 'remote config project static' ;;
|
||||
bin/dejima-ttyd|bin/dejima-notify-watch) echo 'remote config static' ;;
|
||||
web/*|systemd/*) echo 'remote static' ;;
|
||||
bin/dejima-provider-lib.sh) echo 'providers config remote share static' ;;
|
||||
bin/dejima-harness-lib.sh) echo 'providers config remote static' ;;
|
||||
bin/harness/*) echo 'providers remote static' ;;
|
||||
# A manifest is read by more than the suite that owns the schema: the
|
||||
# audit reads a workflow's briefing, conversation.bats sed-injects a
|
||||
# harness.conf, and config.bats copies the whole set in.
|
||||
forges/*|workflows/*|harnesses/*|tools/*) \
|
||||
echo 'providers static audit config conversation' ;;
|
||||
install.sh) echo 'config audit providers remote static' ;;
|
||||
mirror/*) echo 'mirror static' ;;
|
||||
|
||||
# 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 ;;
|
||||
hooks/*) echo 'static config providers project remote' ;;
|
||||
.gitea/*|.shellcheckrc) echo static ;;
|
||||
.dejima/*) echo 'static config project providers' ;;
|
||||
|
||||
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
|
||||
# A suite file is read by static.bats as well as run by bats: it
|
||||
# asserts invariants over the text of the others, so an edit to one
|
||||
# that only selected itself was reported green by a lane that never
|
||||
# loaded the assertions about it.
|
||||
test/*.bats) local n="${1##*/}"; echo "${n%.bats} static" ;;
|
||||
# Fixtures are executed by the suites that consume them, not merely
|
||||
# read: fake-anthropic.py is the upstream in proxy.bats (and in
|
||||
# runtime.bats, which no confined selection can carry), and
|
||||
# answer-prompt.py drives config.bats' prompts.
|
||||
test/fixtures/*) echo 'proxy static config' ;;
|
||||
test/*) echo static ;;
|
||||
|
||||
# Last of the specific rules, not before them: a .md under test/ or
|
||||
# .dejima/ belongs to whatever reads that directory, and this pattern
|
||||
# would otherwise answer for it. The docs themselves are asserted
|
||||
# against by more than static.bats - the README's axis lists, the
|
||||
# provider tables, the nginx example and the remote-access page each
|
||||
# have a suite checking the code agrees with them, and mirror.bats runs
|
||||
# the GitHub rewrite over them.
|
||||
#
|
||||
# So a doc-only edit runs more than static alone. That is the cost of
|
||||
# the lane answering for what reads a file, and it is the safe
|
||||
# direction: narrowing this back would report a doc change as green
|
||||
# from suites that assert against it.
|
||||
docs/*|README.md|*.md) echo 'static config providers remote mirror' ;;
|
||||
|
||||
*) echo ALL ;;
|
||||
esac
|
||||
}
|
||||
@@ -208,6 +249,15 @@ fi
|
||||
bats_flags=()
|
||||
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
|
||||
|
||||
# What this run asks the machine for, for the fixtures that wait on a bounded
|
||||
# deadline. They multiply their bounds by how oversubscribed that makes it
|
||||
# rather than carrying a number picked to survive the gate, which is a number
|
||||
# that is then far too generous anywhere else (#295, test/fixtures/loadscale.py).
|
||||
#
|
||||
# Exported rather than passed, because it has to reach a python fixture several
|
||||
# processes below bats.
|
||||
export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))
|
||||
|
||||
{
|
||||
if [ "$lane" = changed ]; then
|
||||
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
|
||||
@@ -246,14 +296,21 @@ wait
|
||||
# suite that fails to run at all - a syntax error, or duplicate test names,
|
||||
# which bats refuses outright - exits nonzero having run nothing, and reporting
|
||||
# that as "0 failures" is how a green board comes to mean nothing.
|
||||
#
|
||||
# Skips are counted and shown separately because bats emits one as `ok N ...
|
||||
# # skip`, so a test that decided not to run is indistinguishable from one that
|
||||
# passed. Several here skip on host state - an empty /opt/dejima, a missing
|
||||
# tool - so the column is the only thing that says how much of the suite the
|
||||
# number above actually covers.
|
||||
failed=0
|
||||
printf '%-16s %6s %8s\n' suite ran failed
|
||||
printf '%-16s %6s %8s %8s\n' suite ran skipped failed
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
n_skipped="$(grep -cE '^ok [0-9]+ .* # skip' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
|
||||
printf '%-16s %6s %8s' "$s" "$ran" "$bad"
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
printf '%-16s %6s %8s %8s' "$s" "$(( emitted - n_skipped ))" "$n_skipped" "$bad"
|
||||
if [ "$emitted" -eq 0 ]; then
|
||||
printf ' <- ran nothing\n'
|
||||
failed=1
|
||||
elif [ "$bad" -gt 0 ] || [ "$status" -ne 0 ]; then
|
||||
@@ -264,14 +321,18 @@ for s in "${selected[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
for s in "${selected[@]}"; do
|
||||
grep -hE '^ok [0-9]+ .* # skip' "$out/$s" | sed -E "s/^ok [0-9]+ / $s: /"
|
||||
done | { grep . && echo " (skipped, so the counts above do not cover them)"; } || true
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
|
||||
{ [ "$bad" -gt 0 ] || [ "$emitted" -eq 0 ]; } || continue
|
||||
echo "--- $s"
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
if [ "$emitted" -eq 0 ]; then
|
||||
head -20 "$out/$s"
|
||||
else
|
||||
grep -E '^not ok' -A 20 "$out/$s"
|
||||
|
||||
+206
-29
@@ -476,6 +476,11 @@ teardown() {
|
||||
--format '{{.Names}}' 2>/dev/null)
|
||||
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
|
||||
| xargs -r docker rmi -f >/dev/null 2>&1 || true
|
||||
# Volumes too, for the same reason and in the same namespace: a test that
|
||||
# makes one removes it itself, and this is what covers the run that was
|
||||
# interrupted between the two.
|
||||
docker volume ls -q --filter "name=^dejima-test-.*$suffix\$" 2>/dev/null \
|
||||
| xargs -r docker volume rm >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# The capability list bin/dejima gives the filter container, read out of the
|
||||
@@ -518,17 +523,39 @@ start_pair() { # <purpose|""> <image> [docker run args...]
|
||||
filter="$(test_container_name "$purpose-filter")"
|
||||
fi
|
||||
|
||||
# The run's cached GitHub ranges, when the job fetched them. Each filter
|
||||
# fetched its own, which is twenty-seven requests in about five minutes
|
||||
# across this suite - inside the hourly ceiling a token buys, and exactly
|
||||
# the burst GitHub's secondary limit answers with a 403 (#357).
|
||||
#
|
||||
# Read-only, and only ever this one file. egress-policy refuses a file://
|
||||
# source unless SANDBOX_TEST_MODE is set, which start_pair's callers supply
|
||||
# and no launch does.
|
||||
#
|
||||
# Absent when the job could not fetch, or when the suite is run by hand
|
||||
# outside CI: every container then fetches for itself, which is the
|
||||
# behaviour this replaces rather than a failure.
|
||||
local -a meta=()
|
||||
if [ -r /tmp/dejima-ci/github-meta.json ]; then
|
||||
meta=(-v /tmp/dejima-ci/github-meta.json:/run/forge-meta-cache.json:ro
|
||||
-e EGRESS_FORGE_META_URL=file:///run/forge-meta-cache.json
|
||||
-e SANDBOX_TEST_MODE=1)
|
||||
fi
|
||||
|
||||
local -a fcaps=() scaps=()
|
||||
while IFS= read -r cap; do fcaps+=(--cap-add="$cap"); done < <(launcher_filter_cap_add)
|
||||
while IFS= read -r cap; do scaps+=(--cap-add="$cap"); done < <(compose_cap_add)
|
||||
[ "${#fcaps[@]}" -gt 0 ] || { echo "no --cap-add found in bin/dejima's filter run" >&2; return 1; }
|
||||
[ "${#scaps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
|
||||
|
||||
# The cache goes to the FILTER only: it is the half that fetches the ranges,
|
||||
# and the session container has no business holding a path that names them.
|
||||
docker run -d --init --name "$filter" \
|
||||
--cap-drop=ALL "${fcaps[@]}" \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
|
||||
--network "$(network_name)" \
|
||||
${meta[@]+"${meta[@]}"} \
|
||||
"$@" \
|
||||
--entrypoint /usr/local/bin/filter-entrypoint.sh \
|
||||
"$image" >/dev/null
|
||||
@@ -1457,11 +1484,37 @@ session_pid() { # [container], defaulting to the file-wide one
|
||||
# running session, and it is the operator's to run from the host. From
|
||||
# inside the session it must do nothing - it is the same binary in the same
|
||||
# image, and only the container it runs in differs.
|
||||
# What the policy would have CHANGED is re-read after each refusal, not
|
||||
# just the exit status checked: an amendment that landed and then failed on
|
||||
# something later would exit non-zero with the destination permitted, which
|
||||
# is the outcome this test is named for and the one a status cannot see.
|
||||
#
|
||||
# The entries file and the resolver configuration, because a permit for a
|
||||
# NAME writes both - the address sets only ever hold addresses, so grepping
|
||||
# those for a hostname is a check that cannot fail.
|
||||
local before_entries before_conf after_entries after_conf
|
||||
before_entries="$(dexec_filter cat /run/egress-policy/entries)"
|
||||
before_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
|
||||
[ -n "$before_entries" ] && [ -n "$before_conf" ] \
|
||||
|| { echo "could not read the policy's own record of what is permitted"; return 1; }
|
||||
|
||||
run dexec_node /usr/local/sbin/egress-policy permit evil.example.com
|
||||
[ "$status" -ne 0 ]
|
||||
run docker exec -u root "$(container_name)" /usr/local/sbin/egress-policy permit evil.example.com
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "root in the session container amended the permitted set"; return 1; }
|
||||
|
||||
after_entries="$(dexec_filter cat /run/egress-policy/entries)"
|
||||
after_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
|
||||
[ "$after_entries" = "$before_entries" ] \
|
||||
|| { echo "the entries file changed despite both refusals"; return 1; }
|
||||
[ "$after_conf" = "$before_conf" ] \
|
||||
|| { echo "the resolver configuration changed despite both refusals"; return 1; }
|
||||
# Named directly as well, so this does not rest on two snapshots matching:
|
||||
# an entry already present before the test would make them match anyway.
|
||||
grep -q evil.example <<< "$after_entries$after_conf" \
|
||||
&& { echo "evil.example.com reached the policy's record of what is permitted"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "node has no working sudo" {
|
||||
@@ -1504,6 +1557,29 @@ session_pid() { # [container], defaulting to the file-wide one
|
||||
# container.
|
||||
run dexec /usr/local/sbin/strip-privilege-bits --check
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# And once more outside those roots. --check walks the directories the
|
||||
# sweeper walks, which deliberately exclude /tmp, /run, /mnt and /media -
|
||||
# the sweeper has no reason to visit them, but an image that shipped
|
||||
# something there would go unnoticed by a check scoped to its own sweep.
|
||||
# Cheap because those directories are near-empty in an image.
|
||||
run dexec sh -c 'find /tmp /run /mnt /media -xdev -type f -perm /6000 2>/dev/null; getcap -r /tmp /run /mnt /media 2>/dev/null'
|
||||
[ -z "$output" ] \
|
||||
|| { echo "privileged bits outside the swept roots:"; echo "$output"; return 1; }
|
||||
|
||||
# File capabilities over the whole image filesystem, not a directory
|
||||
# union: a capability at a top-level path nobody enumerated - /data/bin
|
||||
# in some future layer - is asserted by nothing else, while setuid gets
|
||||
# the same whole-filesystem walk in the derived-image test below. -xdev
|
||||
# keeps this to the image: /proc, /sys and every bind mount are their own
|
||||
# filesystems, and a capability in the operator's mounted checkout is not
|
||||
# the image's to answer for. A missing getcap prints a marker instead of
|
||||
# nothing, because an empty result has to mean "none found", not "nothing
|
||||
# asked".
|
||||
run dexec sh -c 'command -v getcap >/dev/null || { echo getcap-missing; exit 1; }
|
||||
find / -xdev -type f -print0 2>/dev/null | xargs -0 -r getcap 2>/dev/null || true'
|
||||
[ -z "$output" ] \
|
||||
|| { echo "a file capability survived somewhere in the image (or getcap is gone):"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the session shell is bash, configured by the image, with history appended" {
|
||||
@@ -1795,20 +1871,36 @@ session_pid() { # [container], defaulting to the file-wide one
|
||||
|
||||
@test "every connection attempt is recorded with its verdict" {
|
||||
# A refused destination and a permitted one both have to leave a record, or
|
||||
# the log a supervisor reads proves nothing about what the session did.
|
||||
# the log a supervisor reads proves nothing about what the session did. And
|
||||
# each record's verdict has to be the right one, not merely present: the
|
||||
# verdict rides on which NFLOG group the rule named, so the two groups
|
||||
# swapped would log every refusal as "allowed" - and a check that only
|
||||
# asked for the field would stay green while it did.
|
||||
# The permitted half needs a destination nothing has to answer on: the allow
|
||||
# record is written when the first packet is accepted, not when a service
|
||||
# replies, and a test that waited for a reply would be testing the network.
|
||||
# A documentation address permitted as a literal is exactly that - in
|
||||
# @literal_v4, on any port, routed nowhere. Added live rather than declared
|
||||
# at launch so the file-wide pair keeps the permitted set a real one has.
|
||||
local allowed=198.51.100.1
|
||||
run dexec_filter /usr/local/sbin/egress-policy permit "$allowed"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
dexec curl -s -o /dev/null --connect-timeout 5 --max-time 8 https://192.0.2.1/ 2>/dev/null || true
|
||||
dexec curl -s -o /dev/null --connect-timeout 3 --max-time 5 "http://$allowed:9/" 2>/dev/null || true
|
||||
local found="" i
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null; then
|
||||
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null \
|
||||
&& dexec_filter grep -F "$allowed" /var/log/egress-policy/connections.ndjson 2>/dev/null | grep -q '"allowed"'; then
|
||||
found=yes; break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
[ -n "$found" ] \
|
||||
|| { echo "a refused connection left no record"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
|
||||
|| { echo "a connection left no record with its verdict"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
|
||||
run dexec_filter sh -c "grep -F '192.0.2.1' /var/log/egress-policy/connections.ndjson | tail -1"
|
||||
[[ "$output" == *'"action"'* ]] \
|
||||
|| { echo "recorded without a verdict field: $output"; return 1; }
|
||||
[[ "$output" == *'"blocked"'* ]] \
|
||||
|| { echo "a refused connection was not recorded as blocked: $output"; return 1; }
|
||||
}
|
||||
|
||||
|
||||
@@ -2254,49 +2346,69 @@ s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$bind_addr/$HOST_LISTENER_PORT"
|
||||
}
|
||||
|
||||
@test "a host service bound to loopback is unreachable from inside the container, one bound to the bridge gateway is reachable" {
|
||||
@test "the host is unreachable on every port by default, and on exactly the ports EGRESS_HOST_PORTS names" {
|
||||
# The regression test for the escape path bin/dejima-ttyd's
|
||||
# --interface 127.0.0.1 exists to close. The egress policy deliberately
|
||||
# ACCEPTs OUTPUT to the Docker bridge gateway, and a tmux client attached
|
||||
# through ttyd is a host shell - the prefix key opens a window outside the
|
||||
# container - so a ttyd the sandbox could reach would be a full escape.
|
||||
# Prove the containment property holds rather than trusting the flag.
|
||||
local container host_ip loopback_port gateway_port
|
||||
# --interface 127.0.0.1 exists to close, now one layer stronger. The host
|
||||
# entry used to be accepted on every port, so a ttyd bound to 0.0.0.0 - and
|
||||
# a tmux client attached through ttyd is a host shell, the prefix key opens
|
||||
# a window outside the container - was reachable from the session. It now
|
||||
# opens only the ports it was told to (#363).
|
||||
local container host_ip loopback_port named_port other_port
|
||||
local test_container="$(test_container_name hostports)"
|
||||
container="$(container_name)"
|
||||
|
||||
# Derived exactly as the egress policy derives the host address, so this is the one
|
||||
# host address the container's OUTPUT chain accepts at all.
|
||||
# Derived exactly as the egress policy derives the host address, so this is
|
||||
# the one host address the container's OUTPUT chain names at all.
|
||||
host_ip="$(docker exec "$container" sh -c "ip route | grep default | cut -d' ' -f3")"
|
||||
[ -n "$host_ip" ]
|
||||
|
||||
start_host_listener 127.0.0.1
|
||||
loopback_port="$HOST_LISTENER_PORT"
|
||||
start_host_listener "$host_ip"
|
||||
gateway_port="$HOST_LISTENER_PORT"
|
||||
named_port="$HOST_LISTENER_PORT"
|
||||
start_host_listener "$host_ip"
|
||||
other_port="$HOST_LISTENER_PORT"
|
||||
|
||||
# Setup check, from the host's own vantage point: the loopback listener's
|
||||
# port must answer on 127.0.0.1 only. bind(0) on two different addresses
|
||||
# can legally return the same port number, and if it had, the container
|
||||
# probe below would be hitting the gateway listener and failing for a
|
||||
# reason that has nothing to do with containment.
|
||||
# probes below would be hitting a gateway listener and failing for a reason
|
||||
# that has nothing to do with containment.
|
||||
run timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# Positive control, and the reason the negative one means anything: same
|
||||
# container, same docker exec, same timeout/bash /dev/tcp probe, same
|
||||
# gateway address - only the port differs. Without it a `status -ne 0` is
|
||||
# equally satisfied by docker exec failing (125), bash or timeout missing
|
||||
# from the image (127), or a container that can reach nothing at all, none
|
||||
# of which say anything about loopback.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$gateway_port"
|
||||
# The default, on the file-wide pair, which sets no EGRESS_HOST_PORTS: a
|
||||
# real listener on the gateway address, and the session cannot reach it.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# A pair told to open that one port, and nothing else about it changed.
|
||||
start_pair hostports "$SESSION_IMAGE" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e EGRESS_HOST_PORTS="$named_port"
|
||||
|
||||
# Positive control, and the reason the negatives mean anything: without it
|
||||
# a `status -ne 0` is equally satisfied by docker exec failing (125), bash
|
||||
# or timeout missing from the image (127), or a container that can reach
|
||||
# nothing at all, none of which say anything about the clamp.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Deliberately not asserting the exact status. 1 (the host RSTs, since
|
||||
# nothing is bound on the gateway address at this port) is what it should
|
||||
# be, but a host that DROPs on its own docker bridge instead yields 124
|
||||
# from timeout, and that is still containment.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
# The clamp itself: same container, same address, same kind of listener,
|
||||
# only the port differs from the one above.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$other_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# And the original containment property, which the clamp does not replace:
|
||||
# a host service bound to loopback is not on the gateway address at all.
|
||||
#
|
||||
# Deliberately not asserting the exact status on any of the negatives. 1
|
||||
# (the host RSTs) is what a refusal should look like, but a drop yields 124
|
||||
# from timeout, and that is still containment.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
|
||||
@@ -2588,3 +2700,68 @@ EOF
|
||||
[[ "$output" != *"dejima-notify"* ]]
|
||||
[[ "$output" != *"remote access is enabled"* ]]
|
||||
}
|
||||
|
||||
@test "an isolated launch's working directory is a volume the session user owns" {
|
||||
# The one thing about --isolated that a confined test cannot decide: that
|
||||
# the directory the launcher points a session at is usable. A volume at a
|
||||
# path the image created is seeded from it, ownership included, and if
|
||||
# that did not hold the session would start in a directory root owns and
|
||||
# fail on its first write.
|
||||
#
|
||||
# The volume is named for the run by bin/dejima and removed in its exit
|
||||
# trap, both of which the confined suite checks against the launcher. So
|
||||
# this does not re-check the reaping, and deliberately does not count the
|
||||
# host's volumes before and after: another job on a shared runner creating
|
||||
# one between the two reads would fail a test about something else
|
||||
# entirely.
|
||||
local vol="dejima-test-work-$(basename "$BATS_RUN_TMPDIR")"
|
||||
docker volume create "$vol" >/dev/null
|
||||
|
||||
run docker run --rm -v "$vol:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
|
||||
'cd /dejima/work && touch marker && stat -c "%U" /dejima/work'
|
||||
if [ "$status" -ne 0 ] || [ "$output" != node ]; then
|
||||
docker volume rm "$vol" >/dev/null 2>&1 || true
|
||||
echo "the working directory is not writable by the session user: $output"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# And nothing of the host is behind it, which is the claim the mode rests
|
||||
# on: a second volume at the same path starts empty rather than carrying
|
||||
# what the first container wrote.
|
||||
# Purpose before the suffix, like test_container_name: teardown's filter
|
||||
# is anchored on the suffix, so a name ending in anything else is one it
|
||||
# cannot sweep if this is interrupted between create and remove.
|
||||
local other="dejima-test-work2-$(basename "$BATS_RUN_TMPDIR")"
|
||||
docker volume create "$other" >/dev/null
|
||||
run docker run --rm -v "$other:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
|
||||
'ls -A /dejima/work'
|
||||
local rc="$status" out="$output"
|
||||
docker volume rm "$vol" "$other" >/dev/null 2>&1 || true
|
||||
[ "$rc" -eq 0 ] || { echo "$out"; return 1; }
|
||||
[ -z "$out" ] \
|
||||
|| { echo "a second session's working directory was not empty: $out"; return 1; }
|
||||
}
|
||||
|
||||
@test "the git shadow loads its policy in the image, and leaves other git commands alone" {
|
||||
# The decision lives in a file the Dockerfile copies in
|
||||
# (docker/guard-lib.sh), and every git command in a session goes through
|
||||
# the shadow that sources it - so a COPY line that stopped matching the
|
||||
# path would break git outright, in every session, and nothing in the
|
||||
# confined suites would notice: they point the guards at the checkout.
|
||||
#
|
||||
# The file-wide pair sets no DEJIMA_PUSH_HOSTS, which fails closed, so any
|
||||
# push is refused. Which refusal does not matter; that the guard is the one
|
||||
# speaking does.
|
||||
run dexec bash -c 'cd /tmp && git init -q -b feature r && cd r && git push origin feature'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Blocked by dejima's git guard"* ]] \
|
||||
|| { echo "the shadow did not reach its policy: $output"; return 1; }
|
||||
[[ "$output" != *"No such file"* ]] \
|
||||
|| { echo "the shadow could not load the policy file: $output"; return 1; }
|
||||
|
||||
# And the pass-through path: everything that is not a push has to reach
|
||||
# real git, or the shadow is a broken git rather than a guarded one.
|
||||
run dexec git --version
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == git\ version* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@@ -150,6 +150,42 @@ mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
|
||||
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch's share is its own, and the root is not reported as loose" {
|
||||
write_config
|
||||
# launch() takes a workspace, which --isolated refuses - so this one
|
||||
# drives the launcher directly with the same stub.
|
||||
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" --isolated
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
case "$(mounted_share)" in
|
||||
"$SHARE"/isolated/*) ;;
|
||||
*) echo "mounted '$(mounted_share)', not a directory under the isolated root"
|
||||
return 1 ;;
|
||||
esac
|
||||
# Not asserted to still exist: an isolated share that was never written
|
||||
# to is removed in the exit trap, so a stub that runs no session leaves
|
||||
# none. That it was created and mounted is project.bats' test, which has
|
||||
# a stub that writes into it.
|
||||
[ ! -d "$(mounted_share)" ] \
|
||||
|| { echo "an empty isolated share was left behind"; return 1; }
|
||||
# The root holding them is dejima's, like projects/ - so a launch must
|
||||
# not go on telling the operator about it every time as something that
|
||||
# was left behind by hand.
|
||||
[[ "$output" != *"no session can see"* ]] \
|
||||
|| { echo "the isolated root was reported as left behind: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-share list names each project and flags what is loose" {
|
||||
write_config
|
||||
share path "$WS" > /dev/null
|
||||
|
||||
+11
-4
@@ -30,6 +30,7 @@ list_scripts() {
|
||||
# these the last scripts that should go unchecked, not the first -
|
||||
# and leaving them out hid a real fault in the release gate itself.
|
||||
ls test/*.sh 2>/dev/null || true
|
||||
ls mirror/*.sh 2>/dev/null || true
|
||||
} | sort -u | while read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
# Sourced fragments are not executed, so they carry no shebang and the
|
||||
@@ -40,10 +41,16 @@ list_scripts() {
|
||||
# keeping the permitted set's classes from drifting between the
|
||||
# container that enforces them and the host command that adds to them.
|
||||
#
|
||||
# Matched by directory rather than by name, because a list is a thing
|
||||
# to forget to add to. Everything under docker/ is in the trusted
|
||||
# computing base; the -s bash below tells shellcheck what these are.
|
||||
case "$f" in bin/harness/*.sh|docker/*.sh) printf '%s\n' "$f"; continue ;; esac
|
||||
# Matched by pattern rather than by name, because a list is a thing to
|
||||
# forget to add to - and this one was forgotten anyway: bin/*-lib.sh
|
||||
# went unchecked, which is dejima-harness-lib.sh, the code deciding
|
||||
# which host paths may be mounted where. Everything under docker/ is in
|
||||
# the trusted computing base; the -s bash below tells shellcheck what
|
||||
# these are.
|
||||
case "$f" in
|
||||
bin/harness/*.sh|docker/*.sh|bin/*-lib.sh)
|
||||
printf '%s\n' "$f"; continue ;;
|
||||
esac
|
||||
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
|
||||
done
|
||||
}
|
||||
|
||||
+2504
-164
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,24 @@ so they cannot quietly stop running.
|
||||
The rest — gestures, the viewport manager, the toolbar — needs a real browser
|
||||
and a real phone. `docs/remote-access.md` has the manual checklist.
|
||||
|
||||
One of them is automated, against a real browser and a mock ttyd:
|
||||
|
||||
```sh
|
||||
node test/browser/sess-button.mjs
|
||||
```
|
||||
|
||||
It drives the built page in Chromium and proves the thing the unit tests
|
||||
cannot: that SESS reaches the picker on a new WebSocket, without the page
|
||||
being fetched or run a second time. Three seconds, and it needs Playwright,
|
||||
which a dejima sandbox has and this package deliberately does not depend on -
|
||||
a browser is a strange thing to install to inline two files.
|
||||
|
||||
Deliberately not part of `npm test`, which CI runs in a `node:20-bookworm`
|
||||
container with no browser in it — a test that cannot run there would have to
|
||||
be skipped, and a skipped test is one nobody is reading. What keeps it from
|
||||
rotting instead is [docs/release.md](../docs/release.md), whose manual list
|
||||
names it for any release that touches `web/`.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
|
||||
+21
-2
@@ -10,7 +10,7 @@
|
||||
//
|
||||
// Usage: npm ci && node build.mjs
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -41,8 +41,15 @@ const CLIENT_SOURCES = [
|
||||
// needed, since the first alone passes on a commit that rebuilds the manifest
|
||||
// and leaves the page behind.
|
||||
const MANIFEST_SOURCES = [
|
||||
// The builder itself. Without it, editing this file - the escaping, the fill
|
||||
// order, which sources get inlined - produces a different page with a
|
||||
// byte-identical manifest and stamp, so the staleness check passes on a page
|
||||
// nobody rebuilt. web/README.md defends committing the artifact precisely so
|
||||
// nobody has to run the build, which is what makes that check load-bearing.
|
||||
'build.mjs',
|
||||
'package-lock.json',
|
||||
'src/config.js',
|
||||
'src/favicon.png',
|
||||
'src/gestures.js',
|
||||
'src/keyboard.js',
|
||||
'src/keys.js',
|
||||
@@ -58,7 +65,18 @@ const MANIFEST_SOURCES = [
|
||||
'src/viewport.js'
|
||||
];
|
||||
|
||||
// The order above is hand-written because it is load-bearing, so the list
|
||||
// cannot be a glob. A src/*.js omitted from it is still hashed into the
|
||||
// manifest and still fails no test - it is simply never inlined, and the page
|
||||
// is missing a module. Reading the directory catches that at build time.
|
||||
const uninlined = readdirSync(join(here, 'src'))
|
||||
.filter((f) => f.endsWith('.js') && !CLIENT_SOURCES.includes(`src/${f}`));
|
||||
if (uninlined.length) {
|
||||
throw new Error(`src/*.js not in CLIENT_SOURCES, so never inlined: ${uninlined.join(', ')}`);
|
||||
}
|
||||
|
||||
const read = (relative) => readFileSync(join(here, relative), 'utf8');
|
||||
const readBytes = (relative) => readFileSync(join(here, relative));
|
||||
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
// A literal </script> inside an inlined script would end the element early.
|
||||
@@ -82,8 +100,9 @@ html = fill(html, 'PAGE_CSS', read('src/page.css'));
|
||||
html = fill(html, 'XTERM_JS', escapeForScript(read('node_modules/@xterm/xterm/lib/xterm.js')));
|
||||
html = fill(html, 'ADDON_FIT_JS', escapeForScript(read('node_modules/@xterm/addon-fit/lib/addon-fit.js')));
|
||||
html = fill(html, 'CLIENT_JS', escapeForScript(client));
|
||||
html = fill(html, 'FAVICON_B64', readBytes('src/favicon.png').toString('base64'));
|
||||
|
||||
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(read(file))} ${file}\n`).join('');
|
||||
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(readBytes(file))} ${file}\n`).join('');
|
||||
html = html.replace(
|
||||
'<!--BUILD_STAMP-->',
|
||||
() =>
|
||||
|
||||
+69
-3
@@ -10,7 +10,11 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>dejima</title>
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
|
||||
<!-- Inlined like everything else on this page: ttyd serves one file and answers
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
|
||||
<style>
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
@@ -1007,6 +1011,7 @@ body.mono-keys {
|
||||
// onStatus(string), size() -> {cols, rows}}
|
||||
function createClient(opts) {
|
||||
var urls = endpoints(opts.location);
|
||||
var location = opts.location;
|
||||
var encoder = new TextEncoder();
|
||||
var decoder = new TextDecoder();
|
||||
var socket = null;
|
||||
@@ -1104,9 +1109,45 @@ body.mono-keys {
|
||||
};
|
||||
}
|
||||
|
||||
// Point the connection at a different ttyd argument. ttyd reads --url-arg
|
||||
// off the WebSocket request rather than off the page load, so a different
|
||||
// command is a different socket and nothing else: the half a megabyte of
|
||||
// terminal this page is does not have to be fetched, parsed and run again
|
||||
// to reach one.
|
||||
//
|
||||
// The old socket's handlers are detached before it is closed, rather than
|
||||
// a flag being read inside them. onclose is delivered asynchronously, so a
|
||||
// handler left attached can arrive after the replacement socket exists and
|
||||
// null the field holding it - and the reconnect it would schedule is for
|
||||
// the url this call just replaced.
|
||||
function switchTo(search) {
|
||||
urls = endpoints({
|
||||
protocol: location.protocol,
|
||||
host: location.host,
|
||||
pathname: location.pathname,
|
||||
search: search
|
||||
});
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
backoff = RECONNECT_MIN_MS;
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
connect: connect,
|
||||
urls: urls,
|
||||
switchTo: switchTo,
|
||||
// A function, not the object: switchTo replaces it, and a copy taken
|
||||
// here would answer for the connection this client used to have.
|
||||
urls: function () { return urls; },
|
||||
isOpen: open,
|
||||
sendInput: sendInput,
|
||||
sendBinary: sendBinary,
|
||||
@@ -1875,6 +1916,31 @@ body.mono-keys {
|
||||
}
|
||||
});
|
||||
|
||||
// The SESS button: back to dejima-attach with no run id, which is the
|
||||
// picker. A new socket rather than a new page - ttyd takes the run id as a
|
||||
// WebSocket argument, so the command changes without the half a megabyte of
|
||||
// inlined terminal being fetched, parsed and run a second time. On a phone
|
||||
// that was most of the wait.
|
||||
//
|
||||
// The terminal is reset first, and the mode tracker with it. What the socket
|
||||
// carries away is the process, not the state it left in here: an alternate
|
||||
// screen, mouse reporting, a cursor key mode. The picker would otherwise
|
||||
// open into whatever the session it replaced had switched on, and the
|
||||
// gestures would still be sending mouse reports to something that is not
|
||||
// asking for them.
|
||||
//
|
||||
// replaceState rather than a history entry: the address bar has to stop
|
||||
// saying ?arg=<run-id> or a reload would reopen the session just left, and
|
||||
// going "back" to a session that ended is not a thing to offer.
|
||||
function goToPicker(search) {
|
||||
term.reset();
|
||||
mouse.modes.clear();
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', window.location.pathname + search);
|
||||
}
|
||||
client.switchTo(search);
|
||||
}
|
||||
|
||||
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||
// because the target is a phone: soft keyboards overwhelmingly report
|
||||
// keydown as "Unidentified" and deliver the real character through an input
|
||||
@@ -1934,7 +2000,7 @@ body.mono-keys {
|
||||
prefix: function () { return prefixByte; },
|
||||
notify: notify,
|
||||
search: function () { return window.location.search; },
|
||||
navigate: function (search) { window.location.search = search; },
|
||||
navigate: goToPicker,
|
||||
allowKeyboard: keyboard.allow
|
||||
});
|
||||
toolbar.attach();
|
||||
|
||||
+5
-3
@@ -1,15 +1,17 @@
|
||||
63b33d784dd94f6df18f63043a5f3734a306e1ea01d7ddbc285d53c1b7435cf2 build.mjs
|
||||
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
|
||||
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
|
||||
18a303a9b2bb95504e25e954c82e542da96702cf39a3499e9c9fd50e5160f39f src/favicon.png
|
||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
|
||||
05bc2e446c3baa57422c8fa036939a0df8cf35ac0cf02bb35233befafae5667f src/main.js
|
||||
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
||||
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
||||
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
||||
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
||||
2490ec55a050d993364a62f590b51e63945ea5b1276832821d6d1d0854fd5c81 src/protocol.js
|
||||
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
||||
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
|
||||
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
||||
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
|
||||
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
+26
-1
@@ -139,6 +139,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
// The SESS button: back to dejima-attach with no run id, which is the
|
||||
// picker. A new socket rather than a new page - ttyd takes the run id as a
|
||||
// WebSocket argument, so the command changes without the half a megabyte of
|
||||
// inlined terminal being fetched, parsed and run a second time. On a phone
|
||||
// that was most of the wait.
|
||||
//
|
||||
// The terminal is reset first, and the mode tracker with it. What the socket
|
||||
// carries away is the process, not the state it left in here: an alternate
|
||||
// screen, mouse reporting, a cursor key mode. The picker would otherwise
|
||||
// open into whatever the session it replaced had switched on, and the
|
||||
// gestures would still be sending mouse reports to something that is not
|
||||
// asking for them.
|
||||
//
|
||||
// replaceState rather than a history entry: the address bar has to stop
|
||||
// saying ?arg=<run-id> or a reload would reopen the session just left, and
|
||||
// going "back" to a session that ended is not a thing to offer.
|
||||
function goToPicker(search) {
|
||||
term.reset();
|
||||
mouse.modes.clear();
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', window.location.pathname + search);
|
||||
}
|
||||
client.switchTo(search);
|
||||
}
|
||||
|
||||
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||
// because the target is a phone: soft keyboards overwhelmingly report
|
||||
// keydown as "Unidentified" and deliver the real character through an input
|
||||
@@ -198,7 +223,7 @@
|
||||
prefix: function () { return prefixByte; },
|
||||
notify: notify,
|
||||
search: function () { return window.location.search; },
|
||||
navigate: function (search) { window.location.search = search; },
|
||||
navigate: goToPicker,
|
||||
allowKeyboard: keyboard.allow
|
||||
});
|
||||
toolbar.attach();
|
||||
|
||||
+38
-1
@@ -41,6 +41,7 @@
|
||||
// onStatus(string), size() -> {cols, rows}}
|
||||
function createClient(opts) {
|
||||
var urls = endpoints(opts.location);
|
||||
var location = opts.location;
|
||||
var encoder = new TextEncoder();
|
||||
var decoder = new TextDecoder();
|
||||
var socket = null;
|
||||
@@ -138,9 +139,45 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Point the connection at a different ttyd argument. ttyd reads --url-arg
|
||||
// off the WebSocket request rather than off the page load, so a different
|
||||
// command is a different socket and nothing else: the half a megabyte of
|
||||
// terminal this page is does not have to be fetched, parsed and run again
|
||||
// to reach one.
|
||||
//
|
||||
// The old socket's handlers are detached before it is closed, rather than
|
||||
// a flag being read inside them. onclose is delivered asynchronously, so a
|
||||
// handler left attached can arrive after the replacement socket exists and
|
||||
// null the field holding it - and the reconnect it would schedule is for
|
||||
// the url this call just replaced.
|
||||
function switchTo(search) {
|
||||
urls = endpoints({
|
||||
protocol: location.protocol,
|
||||
host: location.host,
|
||||
pathname: location.pathname,
|
||||
search: search
|
||||
});
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
backoff = RECONNECT_MIN_MS;
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
connect: connect,
|
||||
urls: urls,
|
||||
switchTo: switchTo,
|
||||
// A function, not the object: switchTo replaces it, and a copy taken
|
||||
// here would answer for the connection this client used to have.
|
||||
urls: function () { return urls; },
|
||||
isOpen: open,
|
||||
sendInput: sendInput,
|
||||
sendBinary: sendBinary,
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>dejima</title>
|
||||
<!-- Inlined like everything else on this page: ttyd serves one file and answers
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,/*FAVICON_B64*/">
|
||||
<!--BUILD_STAMP-->
|
||||
<style>
|
||||
/*XTERM_CSS*/
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Enough of ttyd to drive the real page: the three paths its HTTP handler
|
||||
// answers, and a WebSocket that spawns nothing but speaks the frame protocol
|
||||
// src/protocol.js was written against.
|
||||
//
|
||||
// Hand-rolled rather than a dependency, because web/ ships with two and both
|
||||
// are the terminal itself. The frames here are the ones this page sends and
|
||||
// receives and no others: a text handshake, masked client frames under 64KB,
|
||||
// and unmasked server frames.
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function accept(key) {
|
||||
return crypto.createHash('sha1').update(key + GUID).digest('base64');
|
||||
}
|
||||
|
||||
// A server frame: FIN set, binary opcode, no mask, two length forms.
|
||||
function frame(payload) {
|
||||
const len = payload.length;
|
||||
const head = len < 126 ? Buffer.from([0x82, len])
|
||||
: Buffer.concat([Buffer.from([0x82, 126]), Buffer.from([len >> 8, len & 0xff])]);
|
||||
return Buffer.concat([head, payload]);
|
||||
}
|
||||
|
||||
// One client frame off the front of a buffer, or null when it is not all here
|
||||
// yet. Only the opcodes this page sends: text, binary and close.
|
||||
function unframe(buf) {
|
||||
if (buf.length < 2) return null;
|
||||
const masked = (buf[1] & 0x80) !== 0;
|
||||
let len = buf[1] & 0x7f;
|
||||
let off = 2;
|
||||
if (len === 126) {
|
||||
if (buf.length < 4) return null;
|
||||
len = buf.readUInt16BE(2);
|
||||
off = 4;
|
||||
}
|
||||
const maskLen = masked ? 4 : 0;
|
||||
if (buf.length < off + maskLen + len) return null;
|
||||
const mask = masked ? buf.subarray(off, off + 4) : null;
|
||||
const body = Buffer.from(buf.subarray(off + maskLen, off + maskLen + len));
|
||||
if (mask) for (let i = 0; i < body.length; i++) body[i] ^= mask[i % 4];
|
||||
return { opcode: buf[0] & 0x0f, body, rest: buf.subarray(off + maskLen + len) };
|
||||
}
|
||||
|
||||
// opts.onConnect(url) -> the text this "session" prints. Returning different
|
||||
// text for the picker and for a run id is what lets a test tell which command
|
||||
// ttyd would have run.
|
||||
export async function startMockTtyd(indexPath, opts) {
|
||||
const index = fs.readFileSync(indexPath);
|
||||
const pageLoads = [];
|
||||
const sockets = [];
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ token: '' }));
|
||||
return;
|
||||
}
|
||||
if (path === '/' || path === '/index.html') {
|
||||
pageLoads.push(req.url);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(index);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.on('upgrade', (req, socket) => {
|
||||
sockets.push({ url: req.url, closed: false, input: '' });
|
||||
const mine = sockets[sockets.length - 1];
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||
'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
|
||||
'Sec-WebSocket-Protocol: tty\r\n' +
|
||||
'Sec-WebSocket-Accept: ' + accept(req.headers['sec-websocket-key']) + '\r\n\r\n');
|
||||
let buf = Buffer.alloc(0);
|
||||
let spawned = false;
|
||||
socket.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
for (;;) {
|
||||
const f = unframe(buf);
|
||||
if (!f) break;
|
||||
buf = f.rest;
|
||||
if (f.opcode === 0x8) { socket.end(); return; }
|
||||
// An input frame is '0' and then the bytes. Recorded so a test can see
|
||||
// that what is typed after a switch reaches the new command rather
|
||||
// than the socket that was replaced.
|
||||
if (spawned && f.body.length > 1 && f.body[0] === 0x30) {
|
||||
mine.input += f.body.subarray(1).toString();
|
||||
}
|
||||
// ttyd spawns the command when the first message - the one carrying
|
||||
// the token and the size - arrives, and not before.
|
||||
if (!spawned) {
|
||||
spawned = true;
|
||||
socket.write(frame(Buffer.concat([
|
||||
Buffer.from('0'), Buffer.from(opts.onConnect(mine.url))
|
||||
])));
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('close', () => { mine.closed = true; });
|
||||
});
|
||||
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}/`,
|
||||
pageLoads,
|
||||
sockets,
|
||||
stop: () => new Promise((r) => server.close(r))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// What the SESS button does, against the real page in a real browser.
|
||||
//
|
||||
// Not part of `npm test`: it needs Chromium, which the CI image does not have
|
||||
// and which `node --test` would fail on rather than skip. Run it by hand, on a
|
||||
// machine with Playwright:
|
||||
//
|
||||
// node test/browser/sess-button.mjs
|
||||
//
|
||||
// It proves the one thing the unit tests cannot: that pressing SESS reaches
|
||||
// the picker without the page being fetched and parsed a second time.
|
||||
import { startMockTtyd } from './mock-ttyd.mjs';
|
||||
|
||||
// Playwright is not a dependency of this package and is deliberately not
|
||||
// added as one: it would pull a browser into an install that exists to inline
|
||||
// two files. Resolved where it is rather than where this happens to have been
|
||||
// run - a dejima sandbox carries it at a fixed path, a host that has it has it
|
||||
// on the module path.
|
||||
async function loadChromium() {
|
||||
for (const spec of ['playwright', '/opt/node-tools/lib/node_modules/playwright/index.mjs']) {
|
||||
try {
|
||||
return (await import(spec)).chromium;
|
||||
} catch (e) { /* the next one, or the message below */ }
|
||||
}
|
||||
console.error('This check needs Playwright, which is not a dependency here.');
|
||||
console.error('Run it inside a dejima sandbox, which has one, or install it:');
|
||||
console.error(' npm i -g playwright && npx playwright install chromium');
|
||||
process.exit(2);
|
||||
}
|
||||
const chromium = await loadChromium();
|
||||
|
||||
const RUN_ID = 'deadbeefdeadbeef';
|
||||
const checks = [];
|
||||
function check(name, ok, detail) {
|
||||
checks.push({ name, ok, detail });
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' <- ' + detail}`);
|
||||
}
|
||||
|
||||
const ttyd = await startMockTtyd(new URL('../../index.html', import.meta.url).pathname, {
|
||||
onConnect: (url) => (url.includes('arg=' + RUN_ID)
|
||||
? 'SESSION ' + RUN_ID + ' attached\r\n'
|
||||
: 'dejima | live sessions\r\n')
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${ttyd.url}?toolbar=1&arg=${RUN_ID}`);
|
||||
await page.waitForFunction((id) => document.body.innerText.includes('SESSION ' + id), RUN_ID);
|
||||
|
||||
check('the run id reaches ttyd as a WebSocket argument',
|
||||
ttyd.sockets.length === 1 && ttyd.sockets[0].url.includes('arg=' + RUN_ID),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.url)));
|
||||
const loadsBefore = ttyd.pageLoads.length;
|
||||
// A marker on the window as well as the server's count: a reload served from
|
||||
// the browser's cache would never reach the server, and this page is one big
|
||||
// cacheable file.
|
||||
await page.evaluate(() => { window.__survivedTheSwitch = true; });
|
||||
|
||||
await page.click('[data-action="sessions"]');
|
||||
await page.waitForFunction(() => document.body.innerText.includes('live sessions'));
|
||||
|
||||
check('the page itself was never re-run',
|
||||
await page.evaluate(() => window.__survivedTheSwitch === true));
|
||||
check('the picker arrives without the page being fetched again',
|
||||
ttyd.pageLoads.length === loadsBefore,
|
||||
`${ttyd.pageLoads.length} loads, was ${loadsBefore}`);
|
||||
check('a second socket is opened, with no arg on it',
|
||||
ttyd.sockets.length === 2 && !ttyd.sockets[1].url.includes('arg='),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.url)));
|
||||
check('the first socket is closed, which is what ends the session left behind',
|
||||
ttyd.sockets[0].closed === true);
|
||||
check('the address bar stops naming the session, so a reload lands on the picker',
|
||||
!(await page.evaluate(() => window.location.search)).includes('arg='),
|
||||
await page.evaluate(() => window.location.search));
|
||||
// The session's own output must not still be on screen under the picker: the
|
||||
// socket carries away the process, not what it left in the terminal.
|
||||
// Typing has to reach the command the new socket spawned. The terminal's
|
||||
// handlers were wired to the client once, at startup, and the client is now
|
||||
// holding a different socket.
|
||||
await page.click('#terminal');
|
||||
await page.keyboard.type('2');
|
||||
await page.waitForFunction(() => true);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
check('what is typed after the switch reaches the new socket',
|
||||
ttyd.sockets[1].input.includes('2') && !ttyd.sockets[0].input.includes('2'),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.input)));
|
||||
check('the terminal is reset, so the picker is not drawn over the session',
|
||||
!(await page.evaluate(() => document.body.innerText)).includes('SESSION ' + RUN_ID));
|
||||
|
||||
await browser.close();
|
||||
await ttyd.stop();
|
||||
|
||||
const failed = checks.filter((c) => !c.ok).length;
|
||||
console.log(`\n${checks.length - failed}/${checks.length} checks passed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -44,3 +44,84 @@ test("the query string rides along, because ttyd's --url-arg reads it off the We
|
||||
'wss://h/ws'
|
||||
);
|
||||
});
|
||||
|
||||
// A WebSocket and a fetch just real enough for createClient: every instance is
|
||||
// recorded, so a test can see which url was opened and what was done to the
|
||||
// one before it.
|
||||
function stubTransport() {
|
||||
const sockets = [];
|
||||
globalThis.WebSocket = function (url, protocols) {
|
||||
this.url = url;
|
||||
this.protocols = protocols;
|
||||
this.readyState = 0;
|
||||
this.closed = false;
|
||||
this.sent = [];
|
||||
this.send = function (data) { this.sent.push(data); };
|
||||
this.close = function () { this.closed = true; if (this.onclose) this.onclose(); };
|
||||
sockets.push(this);
|
||||
};
|
||||
globalThis.WebSocket.OPEN = 1;
|
||||
globalThis.fetch = () => Promise.resolve({ json: () => ({ token: 't' }) });
|
||||
return sockets;
|
||||
}
|
||||
|
||||
function client(search, statuses) {
|
||||
return globalThis.DEJIMA.protocol.createClient({
|
||||
location: { protocol: 'http:', host: 'h', pathname: '/', search },
|
||||
size: () => ({ cols: 80, rows: 24 }),
|
||||
onOutput() {}, onTitle() {}, onPreferences() {},
|
||||
onStatus: (s) => statuses.push(s)
|
||||
});
|
||||
}
|
||||
|
||||
test('switchTo opens a new socket for a different arg, and keeps the page', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(sockets.length, 1);
|
||||
assert.equal(sockets[0].url, 'ws://h/ws?arg=0123456789abcdef');
|
||||
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
// The old one was closed - ttyd kills the command when the socket goes, so
|
||||
// this is what ends the session being left - and a second one opened on the
|
||||
// url with no arg, which is the picker.
|
||||
assert.equal(sockets[0].closed, true);
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[1].url, 'ws://h/ws');
|
||||
assert.equal(c.urls().ws, 'ws://h/ws');
|
||||
});
|
||||
|
||||
test('a switch is not reported as the session dropping', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
statuses.length = 0;
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
// "closed" is what puts "Disconnected. Reconnecting..." on the screen and
|
||||
// starts the backoff. A switch is neither.
|
||||
assert.deepEqual(statuses, ['connecting']);
|
||||
});
|
||||
|
||||
test('a switch while disconnected drops the pending reconnect', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
sockets[0].onclose(); // the session ended; a retry is now queued
|
||||
const queued = sockets.length;
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(sockets.length, queued + 1);
|
||||
assert.equal(sockets[sockets.length - 1].url, 'ws://h/ws');
|
||||
// And the queued retry must not arrive afterwards and reopen the session
|
||||
// that was just left.
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
assert.equal(sockets.length, queued + 1);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the remote when the work is complete and its tests
|
||||
pass. Do that WITHOUT asking first: this workflow's destination is known,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the remote, then run `{{pr_command}} create` from
|
||||
the worktree. Never push directly to {{blocked_branches}}, on any remote,
|
||||
|
||||
Reference in New Issue
Block a user