Compare commits
234
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 | ||
|
|
ebe63cfc30 | ||
|
|
39b7c9888d | ||
|
|
10aed998aa | ||
|
|
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"
|
||||
|
||||
+12
-10
@@ -287,13 +287,11 @@ valid_tarball_spec() {
|
||||
# anything that could split a header must not survive validation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A declared domain has to survive being written into a dnsmasq
|
||||
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
|
||||
# there is one definition and no pair of them to drift apart.
|
||||
# A hostname, label by label. The matcher is a trust boundary: what it accepts
|
||||
# becomes a dnsmasq `server=` line and an entry in the packet filter's address
|
||||
# sets, so it should accept only what it means to rather than only what it can
|
||||
# see a use for.
|
||||
# A hostname, label by label. Here rather than in dejima-project-lib.sh as well,
|
||||
# so there is one definition and no pair of them to drift apart. The matcher is
|
||||
# a trust boundary: what it accepts becomes a dnsmasq `server=` line and an
|
||||
# entry in the packet filter's address sets, so it should accept only what it
|
||||
# means to rather than only what it can see a use for.
|
||||
#
|
||||
# The previous form allowed an EMPTY label - `a..b.com` matched - because its
|
||||
# repeated group could consume a dot having matched no characters before it.
|
||||
@@ -302,9 +300,13 @@ valid_tarball_spec() {
|
||||
# came from a matcher accepting a string the resolver read differently.
|
||||
#
|
||||
# Per label: 1-63 characters, starting and ending alphanumeric, hyphens in
|
||||
# between. That also newly refuses a label with a leading or trailing hyphen,
|
||||
# which is not a hostname either. A single label with no dot stays legal - an
|
||||
# internal name resolved by the host's own resolver is a real case.
|
||||
# between. That also refuses a label with a leading or trailing hyphen, which is
|
||||
# not a hostname either.
|
||||
#
|
||||
# A single label stays legal here, because this also gates host-side consumers
|
||||
# where an internal name resolved by the host's own resolver is a real case. A
|
||||
# domain that becomes a permitted destination is narrower and has its own rule:
|
||||
# valid_permitted_domain in dejima-project-lib.sh, which requires two labels.
|
||||
valid_domain() {
|
||||
local label='[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
|
||||
[[ "$1" =~ ^$label(\.$label)*$ ]]
|
||||
|
||||
+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:
|
||||
|
||||
+302
-32
@@ -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; }
|
||||
@@ -224,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.
|
||||
@@ -272,7 +289,13 @@ build_entries() {
|
||||
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 ] \
|
||||
@@ -343,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
|
||||
@@ -353,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,
|
||||
@@ -393,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."
|
||||
}
|
||||
@@ -435,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
|
||||
@@ -637,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.
|
||||
@@ -757,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
|
||||
@@ -848,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
|
||||
}
|
||||
@@ -871,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
|
||||
@@ -939,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"
|
||||
@@ -954,9 +1173,13 @@ 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
|
||||
@@ -970,13 +1193,25 @@ verify() {
|
||||
# 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
|
||||
@@ -1229,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"
|
||||
@@ -1292,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"
|
||||
@@ -1306,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 '^$') \
|
||||
@@ -1325,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
|
||||
@@ -1412,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)"
|
||||
}
|
||||
@@ -1429,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")
|
||||
@@ -1449,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.
|
||||
|
||||
+187
-23
@@ -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
|
||||
|
||||
@@ -243,11 +256,28 @@ for them.
|
||||
A project can declare what it needs in two committed files:
|
||||
|
||||
```
|
||||
.dejima/allowed-domains # one domain per line
|
||||
.dejima/allowed-domains # one domain per line, fully qualified
|
||||
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
|
||||
# tarball:<name>@<sha256>:<url> release archive
|
||||
```
|
||||
|
||||
A domain has to name at least two labels. A permitted name covers everything
|
||||
under it, because the sandbox's resolver is configured per name and matches the
|
||||
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 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
|
||||
compare against reality and suggest a command. Approvals live outside the
|
||||
@@ -260,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
|
||||
```
|
||||
@@ -267,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.
|
||||
|
||||
@@ -787,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
|
||||
|
||||
@@ -938,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
|
||||
|
||||
@@ -1010,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
|
||||
@@ -1131,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`
|
||||
|
||||
+45
-36
@@ -19,15 +19,16 @@ run them.
|
||||
| `docker/firewall-up.sh` | Turns a launch's configuration into the permitted set `egress-policy` is given, then starts it. What a namespace needs, rather than what a container does |
|
||||
| `docker/filter-entrypoint.sh` | PID 1 of the filter container: runs `firewall-up.sh`, signals readiness, then holds the namespace open for the sandbox to join |
|
||||
| `docker/caps-lib.sh` | `drop_caps` and the `CAP_SETPCAP` precondition, sourced by both entrypoints so the two cannot drift |
|
||||
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
|
||||
| `docker/log-shipper.sh` | Tails the two records `egress-policy` produces - name lookups from `dns-lookup-log`, connection verdicts from ulogd2 - and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
|
||||
| `docker/entrypoint.sh` | PID 1 of the session container: runs as root to prepare the session's own state, points `resolv.conf` at the namespace's resolver, then execs the session's command as the sandbox user with an empty capability bounding set. The egress control is the filter container's to bring up, not this one's |
|
||||
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
|
||||
| `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
|
||||
|
||||
+36
-14
@@ -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. |
|
||||
@@ -138,7 +138,7 @@ operator can set, resolved the same way as any other config key
|
||||
| `host_keys` | `?list:identifier` | (empty) | Config keys (from `keys`) that hold a literal hostname - unioned by `forge_hosts` into the firewall allowlist and the push guard's allowed hosts. |
|
||||
| `url_keys` | `?list:identifier` | (empty) | Config keys that hold a URL - `forge_hosts` strips scheme and path and unions the bare host in the same way as `host_keys`. |
|
||||
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (`github` uses this - `github.com`, `ssh.github.com`, `api.github.com` are the same three hosts for every operator, so there's nothing to configure and nothing to get wrong). |
|
||||
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the open-class path, which permits a name on every port and exempts it from the check that refuses an answer pointing inside a private network - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the policy's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
|
||||
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the open-class path, which permits a name on every port and exempts it from the check that refuses an answer pointing inside a private network - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged like every other allowed connection. The startup self-check asserts the refusal when the opt-in is off, and skips that check when it is on, so a break in the opt-in plumbing shows up as a failed push rather than a container that will not start. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
|
||||
| `install` | `?list:installspec` | (empty) | The CLIs this forge puts on PATH in the image - the only manifest key naming something that actually gets copied and executed. Either a path under the forge's own directory, or `system:<name>` for a command the image already provides from a package. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build when a declared file is missing, when a declared `system:` command is not in the image, and when a forge ships a file under its `bin/` that it does not declare. Every command a forge names anywhere - `pr_command`, `issue_command`, `merge_block`, `disclose` - has to appear here, because a CLI that is not behind the shadow is one the guards cannot reach: the entrypoint refuses to start rather than leave a block or a disclosure looking enforced. |
|
||||
| `env_export` | `?list:export` | (empty) | `NAME=key` (the config key's resolved value) or `NAME=@key` (the *contents* of the file the config key names) pairs - the environment variables `bin/dejima` passes into the container for this forge. The `@` form is how a token reaches the container without its path being mistaken for the secret. |
|
||||
|
||||
@@ -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
|
||||
@@ -751,7 +770,10 @@ never needs port 22 at all. It opens it through core's existing GitHub push
|
||||
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
|
||||
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
|
||||
published ranges and nothing else, logged like every other allowed
|
||||
connection and verified by the firewall's own startup self-check. The
|
||||
connection. The startup self-check asserts that port 22 is refused when the
|
||||
opt-in is off, and skips that check when it is on, so a break in the opt-in
|
||||
plumbing shows up as a failed push rather than a container that will not
|
||||
start. The
|
||||
port restriction is an IPv4 statement; what makes port 22 shut *at all*
|
||||
without this grant is that the container blocks IPv6 as well, so an
|
||||
allowlist naming one address family is not the whole of it. Only when
|
||||
|
||||
+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.
|
||||
|
||||
+166
-15
@@ -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`:
|
||||
|
||||
@@ -1575,7 +1725,7 @@ Useful filters on top of those:
|
||||
|
||||
```logql
|
||||
# DNS lookups that got refused (not on the allowlist)
|
||||
{job="dejima-dns"} |= "REFUSED"
|
||||
{job="dejima-dns"} | json | outcome="refused"
|
||||
|
||||
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
|
||||
{job="dejima-net"} | json | action="blocked"
|
||||
@@ -1698,8 +1848,9 @@ is the same GitHub ranges the read-only path already allows on port 443, now
|
||||
also on port 22, logged like every other allowed connection.
|
||||
|
||||
The startup self-check covers the default. With the opt-in off it asserts port
|
||||
22 is refused and stops the container if it is not; with the opt-in on it
|
||||
checks nothing, and says so in the log. So a break in the opt-in plumbing
|
||||
22 is refused and stops the container if it is not; with the opt-in on it skips
|
||||
that one check, and says so in the log. Everything else it asserts still
|
||||
runs. So a break in the opt-in plumbing
|
||||
surfaces as a failed `git push` rather than as a container that refuses to
|
||||
start. `test/runtime.bats` asserts the open direction against a real container
|
||||
pair, which is where that property is covered.
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
+2336
-151
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