Compare commits
163
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 | ||
|
|
d180581293 | ||
|
|
f61795381d | ||
|
|
805d9a2c35 | ||
|
|
5884930592 | ||
|
|
4a9e789ba1 | ||
|
|
9b96d5781b | ||
|
|
bac3d45814 | ||
|
|
fa29e2ac00 | ||
|
|
b3f8252c73 | ||
|
|
720b91a569 | ||
|
|
3086bb884e | ||
|
|
02f5dd8b19 | ||
|
|
990ab94a40 | ||
|
|
940f632447 | ||
|
|
c8fd2dc605 | ||
|
|
ba2158e0e8 | ||
|
|
51c0a8bc65 | ||
|
|
d418de83df | ||
|
|
225abccece | ||
|
|
43081852ea | ||
|
|
9c1735de00 | ||
|
|
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.
|
||||
@@ -247,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
|
||||
@@ -295,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
|
||||
@@ -408,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
|
||||
@@ -564,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
|
||||
@@ -703,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
|
||||
|
||||
+850
-54
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"
|
||||
|
||||
@@ -424,6 +424,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||
% (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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
+120
-18
@@ -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
|
||||
@@ -1711,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
|
||||
@@ -1722,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
|
||||
}
|
||||
|
||||
@@ -1738,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
|
||||
@@ -1982,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:-}" ;;
|
||||
@@ -2157,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
|
||||
@@ -2217,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+216
-25
@@ -131,6 +131,7 @@ HOST_ADDR=${EGRESS_HOST_ADDR:-}
|
||||
# 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.
|
||||
@@ -143,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; }
|
||||
@@ -362,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
|
||||
@@ -372,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,
|
||||
@@ -412,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."
|
||||
}
|
||||
@@ -492,6 +558,40 @@ emit_host_clamp() {
|
||||
# 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
|
||||
@@ -706,15 +806,26 @@ 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
|
||||
|
||||
# The container's own host, as a single address, on the ports it was told
|
||||
# to open and no others.
|
||||
EOF
|
||||
emit_host_clamp host_v4
|
||||
|
||||
cat <<EOF
|
||||
|
||||
@@ -830,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
|
||||
@@ -921,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
|
||||
}
|
||||
@@ -944,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
|
||||
@@ -1012,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"
|
||||
@@ -1027,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
|
||||
@@ -1043,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
|
||||
@@ -1302,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"
|
||||
@@ -1365,8 +1532,14 @@ cmd_apply() {
|
||||
discover_upstreams
|
||||
|
||||
local host_addrs forge_host
|
||||
validate_host_ports
|
||||
# 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"
|
||||
@@ -1380,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 '^$') \
|
||||
@@ -1486,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)"
|
||||
}
|
||||
|
||||
+12
-2
@@ -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"
|
||||
@@ -309,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
|
||||
|
||||
@@ -136,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:
|
||||
@@ -154,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 &
|
||||
|
||||
+26
-4
@@ -100,6 +100,20 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
|
||||
--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))
|
||||
;;
|
||||
@@ -368,7 +382,9 @@ dejima_forge_verdict() {
|
||||
#
|
||||
# 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; `gh` declares all three, so on that path the
|
||||
# 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).
|
||||
#
|
||||
@@ -524,11 +540,17 @@ _dejima_forge_path_verdict() {
|
||||
# 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.
|
||||
case "$arg" in *'{'*) scopable=1; continue ;; esac
|
||||
path="${arg#/}"
|
||||
# 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%%/*}"
|
||||
|
||||
+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.
|
||||
|
||||
+154
-15
@@ -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` |
|
||||
@@ -277,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
|
||||
```
|
||||
@@ -284,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.
|
||||
|
||||
@@ -804,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
|
||||
|
||||
@@ -955,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
|
||||
|
||||
@@ -1027,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
|
||||
@@ -1148,11 +1284,14 @@ 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
|
||||
|
||||
+27
-22
@@ -27,7 +27,7 @@ run them.
|
||||
| `.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. 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: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), and a command aimed at another repository - to a reported command. Named for the merge block, which is the rule it was created for |
|
||||
| `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 |
|
||||
@@ -50,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 |
|
||||
@@ -59,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
|
||||
@@ -107,19 +108,20 @@ 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 on the host is unreachable
|
||||
@@ -180,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
|
||||
@@ -201,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
|
||||
|
||||
+26
-8
@@ -438,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
|
||||
@@ -509,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
|
||||
@@ -519,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
|
||||
|
||||
@@ -676,8 +689,13 @@ knowing about because they are what a second forge costs:
|
||||
as such, and a count of what was hidden. Everything else `gitea-pr` does has
|
||||
a `gh` subcommand already.
|
||||
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
|
||||
gitea CLIs refuse an origin that is not on the configured host, so they can
|
||||
only act on the repo the sandbox was launched against. `gh` takes `--repo`
|
||||
gitea CLIs resolve the repository from `origin` and refuse an origin that is
|
||||
not on the configured host, so there is no flag or variable to compare and
|
||||
nothing for this guard to declare. That is a narrower confinement than it
|
||||
sounds: `origin` lives in `.git/config`, inside the read-write project mount,
|
||||
and the gitea token is account-wide - so a session that rewrites `origin` to
|
||||
another repository **on the same host** reaches it. What bounds that is the
|
||||
token's scope, as it is for `gh`. `gh` takes `--repo`
|
||||
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
|
||||
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
|
||||
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
|
||||
|
||||
+71
-1
@@ -101,7 +101,7 @@ why each is not a test:
|
||||
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
|
||||
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
|
||||
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
|
||||
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
|
||||
| `web/` | `cd web && node test/browser/sess-button.mjs`, then load it on a phone; nothing here can see a soft keyboard |
|
||||
| `harnesses/` | Launch the harness against a real model |
|
||||
|
||||
## 5. The release pull request
|
||||
@@ -118,3 +118,73 @@ looks like the safety net is not one. Name the keys. A queued privileged job, a
|
||||
manual check that was skipped, a forge that has still never been launched -
|
||||
those are the things a reader of `main` is entitled to know, and the release
|
||||
PR is where they are still true and still remembered.
|
||||
|
||||
Worth its own line too: **what an existing config stops being able to do.** A
|
||||
release that tightens a boundary can refuse an arrangement that worked, with
|
||||
nothing in the operator's config changed - and the ones that fail closed are
|
||||
the ones to name, because failing closed is quiet. An observability path is
|
||||
the worst case: a log shipper that can no longer reach its endpoint stops
|
||||
shipping and nothing else happens. Name the arrangement, what it now needs,
|
||||
and where the refusal shows up.
|
||||
|
||||
## 6. The version, and the entry in the changelog
|
||||
|
||||
A release is a date, and the tag of that name on `main` is the only record of
|
||||
it. Two steps, both at merge time, both easy to forget because nothing fails
|
||||
without them:
|
||||
|
||||
```sh
|
||||
git tag 2026.10.12 <the merge commit on main>
|
||||
git push origin 2026.10.12
|
||||
```
|
||||
|
||||
and in the same pull request, rename `CHANGELOG.md`'s `## Unreleased` heading
|
||||
to that date and open a fresh empty one above it. The date is the day it
|
||||
merges, so it cannot be written before the merge is made - which is why the
|
||||
heading waits rather than being guessed.
|
||||
|
||||
**There is nothing else to bump.** `dejima --version` reads `git describe`, so
|
||||
the tag is the version: no file records it, and none can disagree with it. A
|
||||
checkout pulled past a release reports `2026.10.12+37`, which is the honest
|
||||
answer for a checkout on no release.
|
||||
|
||||
**A second release on the same day gets a letter** - `2026.10.12b`, then `c`.
|
||||
Rare, and the format otherwise has no answer: the date is the whole name.
|
||||
`sort -V` orders it after the bare date, which is what the update notice
|
||||
compares with, so nothing else has to know about the shape. A letter rather
|
||||
than a fourth dotted number, because four dot-separated numbers beginning
|
||||
with a year and a month read as a private IPv4 address.
|
||||
|
||||
What an entry is for is one question - should I update, and what will I be
|
||||
getting - so it names what an operator would notice rather than what changed.
|
||||
The pull requests carry the detail and are linked from it. A tag is a record
|
||||
of what shipped, not a version anyone may stay on; `CHANGELOG.md` says so at
|
||||
the top, and `SECURITY.md` is where that is binding.
|
||||
|
||||
## 7. The GitHub mirror
|
||||
|
||||
`mirror/github-mirror.sh` publishes `main` and the tags on it, for this
|
||||
repository and every other one in the organisation, to
|
||||
[github.com/dejima-org](https://github.com/dejima-org) from the operator's
|
||||
host, hourly, so a release needs no step here. Issues, pull requests and
|
||||
Actions are off there; all of that stays on the forge. A new repository is
|
||||
mirrored once it is added to `MIRROR_REPOS` in the script.
|
||||
|
||||
Each mirror's history is the forge's `main` with AI co-author trailers taken
|
||||
out of the commit messages, so GitHub credits no tool as a contributor, and
|
||||
with the values in the operator's scrub file replaced throughout. That file
|
||||
stays on the host, because the list is the thing it hides. The commits keep
|
||||
their authors and dates but not their SHAs, so a forge SHA does not resolve on
|
||||
GitHub. On top sits one commit that points repository links and clone URLs at
|
||||
GitHub, lets the README wordmark follow the reader's
|
||||
theme, and says at the top of the README that it is a mirror. A README change
|
||||
that breaks one of those rewrites stops that repository's mirror rather than
|
||||
publishing the forge's version. Here, `test/mirror.bats` fails on it first; in
|
||||
the other repositories, the hourly run fails and names it.
|
||||
|
||||
Setup is in the header of `mirror/dejima-github-mirror.service`. Once the
|
||||
GitHub repositories exist, `github-mirror.sh --settings` turns their features
|
||||
off, and `--dry-run` builds what a sync would publish without pushing it. A
|
||||
sync refuses a mirror whose `main` is not built on the history it would
|
||||
publish, which happens when the rewrite rules change or the scrub file gains a
|
||||
value; `--reset` force-pushes over it.
|
||||
|
||||
+161
-12
@@ -905,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.
|
||||
|
||||
@@ -1012,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.
|
||||
|
||||
+112
-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
|
||||
@@ -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
|
||||
@@ -982,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
|
||||
@@ -1014,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
|
||||
@@ -1338,13 +1410,37 @@ 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.
|
||||
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.
|
||||
|
||||
For anything else, `EGRESS_HOST_PORTS` on the filter container 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.
|
||||
`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
|
||||
|
||||
@@ -1547,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`:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -30,9 +30,13 @@ 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 a spelling
|
||||
# this file does not recognise is not a spelling that goes unchecked: it is argv
|
||||
# either way.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
+22
-7
@@ -47,15 +47,30 @@ forge_commands="$(dejima_forge_commands | sort -u)"
|
||||
# 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"
|
||||
[ "${#tokens[@]}" -gt 0 ] || continue
|
||||
self="$(basename -- "${tokens[0]}")"
|
||||
grep -qxF -- "$self" <<< "$forge_commands" || continue
|
||||
if ! dejima_forge_verdict "$self" "${tokens[@]:1}"; then
|
||||
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
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 ------------------------
|
||||
#
|
||||
|
||||
+385
-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,
|
||||
|
||||
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
+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
|
||||
|
||||
+1
-1
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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 ]
|
||||
}
|
||||
+607
-2
@@ -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" "$@"
|
||||
}
|
||||
|
||||
@@ -1310,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
|
||||
@@ -1383,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"
|
||||
|
||||
@@ -485,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.
|
||||
|
||||
@@ -174,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
|
||||
@@ -209,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
|
||||
@@ -226,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
|
||||
|
||||
+715
-4
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
+38
-6
@@ -60,7 +60,7 @@ suites_for() {
|
||||
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-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) \
|
||||
@@ -72,8 +72,13 @@ suites_for() {
|
||||
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' ;;
|
||||
forges/*|workflows/*|harnesses/*|tools/*) echo 'providers 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 -
|
||||
@@ -81,17 +86,35 @@ suites_for() {
|
||||
# file are read by nearly every suite that builds a launch.
|
||||
docker/*) echo ALL ;;
|
||||
hooks/*) echo 'static config providers project remote' ;;
|
||||
docs/*|README.md|*.md) echo static ;;
|
||||
.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).
|
||||
test/fixtures/*) echo 'proxy static' ;;
|
||||
# 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
|
||||
}
|
||||
@@ -226,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' \
|
||||
|
||||
@@ -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" {
|
||||
@@ -2648,6 +2701,47 @@ EOF
|
||||
[[ "$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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1400
-41
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 |
|
||||
|
||||
+65
-3
@@ -14,7 +14,7 @@
|
||||
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: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
|
||||
<style>
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
@@ -1011,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;
|
||||
@@ -1108,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,
|
||||
@@ -1879,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
|
||||
@@ -1938,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();
|
||||
|
||||
+2
-2
@@ -5,12 +5,12 @@ f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.j
|
||||
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
|
||||
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
||||
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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