Compare commits

..
162 Commits
Author SHA1 Message Date
branderson 31ffe8564b Merge pull request 'A session switcher that scales past a dozen sessions' (#405) from session-switcher into development
Reviewed-on: #405
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 17:48:42 -07:00
branderson cf8b3ee20f Merge remote-tracking branch 'origin/development' into session-switcher
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (mirror) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m38s
tests / confined (share) (pull_request_target) Successful in 2m26s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m23s
tests / confined (remote) (pull_request_target) Successful in 4m21s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / privileged (pull_request_target) Successful in 6m1s
tests / confined (config) (pull_request_target) Successful in 6m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
2026-10-16 17:22:09 -07:00
branderson 91befe91f1 Merge pull request 'Count every state in one pass, and say where the states live' (#412) from attach-counts into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m39s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m43s
tests / confined (audit) (pull_request_target) Successful in 4m24s
tests / confined (remote) (pull_request_target) Successful in 4m24s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / confined (static) (pull_request_target) Successful in 4m19s
tests / privileged (pull_request_target) Successful in 6m12s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
Reviewed-on: #412
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 15:23:40 -07:00
branderson d8b687f4e4 Merge pull request 'Refuse an oversized body before inviting it' (#413) from proxy-expect-100 into development
Reviewed-on: #413
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 14:54:52 -07:00
branderson ab74531b63 Call the state waiting, which is what everything else already called it
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (launch) (pull_request_target) Successful in 2m38s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m26s
tests / confined (remote) (pull_request_target) Successful in 4m25s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / privileged (pull_request_target) Successful in 6m7s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
The hook's event is `waiting`, the flag is --count-waiting, and the picker
said needs-you: three names for one thing, and the status line is where that
stops being an opinion - `waiting=1 working=0` is about as short as a status
can get and still say what it is.

One word now, in the picker as well as in --counts, so a number on a status
bar and a row on a screen still say the same thing. The fold from the two
harness events onto it is said once at each site that does it, because
`done|waiting) state=waiting` reads as a tautology without knowing that the
left side is an event name.
2026-10-16 13:03:12 -07:00
branderson 06018fdda4 Refuse an oversized body before inviting it
tests / confined (mirror) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m37s
tests / confined (share) (pull_request_target) Successful in 2m25s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 3m12s
tests / confined (audit) (pull_request_target) Successful in 4m12s
tests / confined (project) (pull_request_target) Successful in 4m52s
tests / confined (static) (pull_request_target) Successful in 4m1s
tests / confined (config) (pull_request_target) Successful in 5m57s
tests / privileged (pull_request_target) Successful in 6m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
BaseHTTPRequestHandler answers 100 Continue and returns True before do_* runs,
so the proxy invited the upload, read the declared length in _read_body,
refused it and closed mid-flight. Whether the client read the 413 or only the
100 was timing - a race the client cannot win and a test cannot pass
reliably, which is how it was found: an intermittently red proxy job on a pull
request that did not touch the proxy.

The cap itself was doing the thing that matters, in that nothing oversized was
read into host memory. What was wrong was the conversation.

The new test asks what the FIRST answer is and sends no body at all, which is
the question the race cannot reach: the one beside it passes either way when
the timing is kind.

Fixes #400
2026-10-16 11:28:17 -07:00
branderson 0e4717d7ed Count every state in one pass, and say where the states live
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (static) (pull_request_target) Successful in 4m24s
tests / privileged (pull_request_target) Successful in 6m1s
tests / confined (config) (pull_request_target) Successful in 6m23s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (share) (pull_request_target) Successful in 2m30s
tests / confined (audit) (pull_request_target) Successful in 4m29s
tests / confined (remote) (pull_request_target) Successful in 4m31s
tests / confined (project) (pull_request_target) Successful in 5m17s
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / confined (proxy) (pull_request_target) Failing after 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (launch) (pull_request_target) Successful in 2m41s
A status bar wanting both numbers paid for two invocations of the one thing
here that runs on a timer. --counts answers for all of them in the pass the
counter already makes, in the picker's words, so a number on a status bar and
a row on a screen say the same thing about the same session.

--state-dir is for a reader that is not dejima: the directory is under
XDG_RUNTIME_DIR where there is one and under /tmp where there is not, so an
outside tool either asks or re-derives that.
2026-10-16 00:31:14 -07:00
branderson 7da90f51e1 Merge pull request 'Reach the picker on a new socket, not a new page' (#411) from web-reconnect into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m46s
tests / runner-env (confined) (pull_request_target) Successful in 1m13s
tests / confined (remote) (pull_request_target) Successful in 4m34s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m0s
tests / confined (audit) (pull_request_target) Successful in 4m35s
tests / confined (static) (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
Reviewed-on: #411
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 00:03:51 -07:00
branderson 117622e144 Resolve Playwright where it is, and name the check in the release pass
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (launch) (pull_request_target) Successful in 2m44s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m22s
tests / confined (static) (pull_request_target) Successful in 4m13s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / privileged (pull_request_target) Successful in 6m6s
tests / confined (config) (pull_request_target) Successful in 6m13s
The import was an absolute path into the dejima image, so the command the
README gives worked in a sandbox and nowhere else. It tries the module path
first and says what to install when neither answers.

Nothing runs this check on a schedule, which is the cost of it living outside
npm test: docs/release.md's manual list is where this project already parks
checks that need a machine CI does not have, so a release touching web/ names
it.
2026-10-15 23:17:58 -07:00
branderson a26c10e8c6 Reach the picker on a new socket, not a new page
tests / confined (mirror) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / confined (audit) (pull_request_target) Successful in 4m24s
tests / runner-env (confined) (pull_request_target) Successful in 1m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m40s
tests / confined (remote) (pull_request_target) Successful in 4m21s
tests / confined (project) (pull_request_target) Successful in 5m14s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / privileged (pull_request_target) Successful in 6m39s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 4m18s
ttyd takes the run id as a WebSocket argument rather than as part of the page,
so pressing SESS only has to open a different socket. It reloaded the page
instead: half a megabyte of inlined terminal fetched, parsed and run again to
change one command, which on a phone was most of the wait between the key and
the picker. Measured in Chromium over loopback, where there is no network and
the CPU is a desktop's: 406ms before, 195ms after.

The old socket's handlers are detached before it is closed rather than a flag
being read inside them - onclose is asynchronous, so a handler left attached
can arrive after the replacement socket exists, null the field holding it, and
schedule a reconnect to the url that was just replaced.

The terminal is reset on the way, and the mode tracker with it: what the
socket carries away is the process, not the alternate screen or the mouse
reporting it left switched on in here.

web/test/browser/ is a mock ttyd and a Chromium check, outside npm test
because CI has no browser. It is what shows the page is neither fetched nor
re-run, which no unit test can see.
2026-10-15 22:16:27 -07:00
branderson 65caf4577b Say what a listing costs now, where the docs said one call per container
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / confined (mirror) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 1m48s
tests / confined (providers) (pull_request_target) Successful in 2m36s
tests / confined (launch) (pull_request_target) Successful in 2m51s
tests / confined (share) (pull_request_target) Successful in 2m22s
tests / confined (shellcheck) (pull_request_target) Successful in 1m57s
tests / confined (audit) (pull_request_target) Successful in 4m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (project) (pull_request_target) Successful in 5m3s
tests / confined (static) (pull_request_target) Successful in 4m4s
tests / privileged (pull_request_target) Successful in 6m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (remote) (pull_request_target) Successful in 4m31s
tests / confined (config) (pull_request_target) Successful in 6m10s
2026-10-15 19:38:14 -07:00
branderson 21eeaa5bf0 Ask docker and tmux once each for a listing, not once per session
A listing of eighteen sessions took 2.3 seconds before the picker drew, and
the web front door reaches it through a full page load, so that is the wait
after the SESS key. Measured against an eighteen-session fixture with a stub
answering in 60ms, which is what a local daemon costs: 2259ms before, 468ms
after.

Three things, in the order they cost:

docker inspect ran once per container. It runs once for all of them, with both
values as JSON so that one line per container is a property rather than a
hope - a newline in an environment value is escaped rather than printed, and a
container that went away takes its own line with it instead of shifting the
rest.

tmux list-panes ran once per session, through a grep. It runs once per listing
and the membership test is a case.

container_for, session_level, state_color and age_short were each called in a
command substitution per row, which forks. They answer in a variable, the way
tui_pick already does.
2026-10-15 19:20:35 -07:00
branderson d279c93e13 Fit the name column to the terminal, and mark what it cuts
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (proxy) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (launch) (pull_request_target) Successful in 2m41s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m43s
tests / confined (audit) (pull_request_target) Successful in 4m27s
tests / confined (remote) (pull_request_target) Successful in 4m27s
tests / confined (static) (pull_request_target) Successful in 4m17s
tests / privileged (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
tests / confined (config) (pull_request_target) Successful in 6m19s
A fixed width is wrong in both directions at once: a desktop has room for
every name and would cut them anyway, and a phone narrower than the guess
still overflows and wraps, which is the failure this layout exists to avoid.

The width is asked of the controlling terminal rather than of stdin, which is
where `tput cols` reads it and is a pipe wherever a choice is fed in rather
than typed; a pty nobody set a size on reports zero columns, which is an
unanswered question and not a width. A cut name ends in a `~`, in ASCII where
the rest of the glyphs are not, because this one sits inside a padded field and
bash measures a multi-byte character as its bytes wherever the locale is not
UTF-8 - which would take the state column two places left on that row alone.
2026-10-15 16:00:29 -07:00
branderson 439b9b2d8b Document the picker's three columns and the starting state
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / confined (audit) (pull_request_target) Successful in 4m26s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / privileged (pull_request_target) Successful in 6m10s
tests / confined (config) (pull_request_target) Successful in 6m12s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
2026-10-15 15:30:42 -07:00
branderson 6dcefc127d Cut the picker's row down to what a phone can read
Five columns made a row about eighty characters wide. On a phone it wrapped,
and the wrapped half - the state and the age - landed under the NEXT session's
number, which is the opposite of what a numbered list is for.

Three columns now. The name comes first, against the index, because those two
are what an operator matches up. The tmux label and the container id stay in
--list: the 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,
so a column of them said nothing. The one answer that column carried that was
not decoration - a session whose image is still building - moves into the
state, where it is read.

The age is minutes for an hour, then hours, then days: three characters, and
not a sum to do first.
2026-10-15 15:29:55 -07:00
branderson aff7f511e8 Stop an empty field shifting every column after it in the picker
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 shown neither in red nor with the word
stale anywhere a reader would look for it.

Every session on a host that upgraded mid-flight has such an entry, which is
every session until it is relaunched.

The rows stay tab-separated, because that is what --list promises; the picker
converts them to a separator that is not whitespace before reading.
2026-10-15 15:25:37 -07:00
branderson 02159d95e2 Merge pull request 'Mirror the organization profile repository, .github' (#410) from mirror-dotgithub into development
Reviewed-on: #410
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 14:53:06 -07:00
branderson 51dc5ddf5c Mirror the organization profile repository, .github
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (mirror) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m41s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (remote) (pull_request_target) Successful in 3m13s
tests / confined (audit) (pull_request_target) Successful in 4m18s
tests / confined (project) (pull_request_target) Successful in 5m3s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (config) (pull_request_target) Successful in 6m10s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / confined (shellcheck) (pull_request_target) Successful in 1m40s
tests / privileged (pull_request_target) Successful in 6m3s
2026-10-15 14:31:03 -07:00
branderson 44ac1e6216 Default the session name to the project, and suffix a collision
tests / confined (launch) (pull_request_target) Successful in 2m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (audit) (pull_request_target) Successful in 4m33s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / confined (static) (pull_request_target) Successful in 4m19s
tests / privileged (pull_request_target) Successful in 5m56s
tests / runner-env (privileged) (pull_request_target) Successful in 59s
tests / confined (config) (pull_request_target) Successful in 6m44s
tests / confined (mirror) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m36s
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / runner-env (confined) (pull_request_target) Successful in 1m12s
tests / confined (project) (pull_request_target) Successful in 5m20s
Most launches pass no --name, so the default is what this mostly is: the
project directory, which is what both front doors showed before.

A name already in use takes the first free -N rather than refusing the launch -
several sessions against one project is the case the name exists for, so
stopping over a collision would be a worse answer than two rows that agree.
The first free one, so a number a finished session gives back is used again
instead of the count climbing for the life of the host. Collisions come from
the live registry and the tmux window list, because each is blind to a case the
other sees, and this launch's own window is not a conflict with itself.

A derived name is cut down where an explicit one is refused. The difference is
who chose it: a directory was not named for this, so a launch that stopped
because a path was long would be refusing over something it can fix.
2026-10-15 14:23:40 -07:00
branderson fc34c165df Document the session name, the states and the live view
docs/remote-access.md's Attaching section is where the session list is
described, so the two columns that changed and the two new modes belong there.
The README gets the half that applies with remote access off: the tmux window
name.
2026-10-15 14:23:40 -07:00
branderson 5f13b4015b Add a live view and a count for the status line
--watch is the picker redrawn on a timer: which sessions are working and which
are waiting, in one window, with the same numbers to go to one. Deliberately
not a second tool - a monitor would grow its own list, its own columns and its
own idea of what a session is, and what an operator does after seeing which
session wants them is to go to it.

--count-waiting is the same question reduced to one number, for a tmux status
line that re-runs it every few seconds. It reads the registry, the pane list
and the levels and asks docker nothing: the point of the number is not having
to open anything, so it must not cost a round trip per container.

tui_pick grew an optional timeout rather than the caller growing its own read,
so the live list and the static one share one prompt and one range check.
2026-10-15 14:23:13 -07:00
branderson f99868fb7c Ask docker once per listing instead of once per session
container_for ran `docker ps` and then inspected containers one at a time
until it found its match, and it was called for every entry in the registry:
quadratic in the number of sessions, on the listing both front doors open on.
Twelve sessions cost twelve `docker ps` calls and up to a hundred and
forty-four inspects.

The map is built once and looked up. What keeps "no such container" apart from
"couldn't ask" is now a flag on the map rather than a local, with the same
reach: one unanswerable inspect anywhere makes every miss unverified, because
pruning on that answer unregisters a live session for good.
2026-10-15 14:23:13 -07:00
branderson f5f228f7ae Switch the client when the picker is already inside tmux
Everything in attach() is written for a client with no tmux of its own, a
phone or 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 every
session and then reach none of them.

switch-client rather than select-window, so a sandbox launched from a different
tmux session is reachable too. Nothing to create, verify or reap on this path,
and the window landed on is the real one.
2026-10-15 14:23:13 -07:00
branderson 3eacfd26e8 Say in the listing which sessions want attention
Every row said "attachable", which is true of all of them. The column now
carries the state the session reported when there is one - needs-you, working -
and the reachability answer when there is not, because a session that cannot be
attached to has nothing useful to say about whether it wants attention and the
two sets of words do not overlap.

Oldest first, by start time and deliberately not 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.
2026-10-15 14:23:13 -07:00
branderson 1a4ba0d192 Have a session report which state it is in, not only that it changed
The marker a session writes is consumed by the watcher that pushes it, so
after a notification there is nothing left to read - and with no watcher
running, nothing has read it at all. Neither front door can answer "which of
these sessions is waiting for me" from that.

The hook now also keeps one file in a level/ directory the host creates: the
state the session is in, overwritten rather than consumed. Still a name and no
content, still only ever about the session writing it. UserPromptSubmit
supplies the other half and pushes nothing, because the edge markers keep
their two-name enum.
2026-10-15 14:23:13 -07:00
branderson b12b303780 Name the tmux window the launch is sitting in
choose-window, the status line and the window list all show a window name, and
for a dozen sandboxes they showed a dozen copies of whatever automatic-rename
made of the container command. Nothing in tmux has to know dejima exists for
this to be the fix, which is why it is worth more than a picker column.

A window the operator named by hand is left alone: tmux turns automatic-rename
off on rename, so the option still being on is the evidence that the name is
tmux's guess. The option goes back on at exit, or the window keeps the name of
a session that has ended.
2026-10-15 14:23:12 -07:00
branderson 79853138fb Let a launch say what the session is for
Both front doors identify a session by the basename of its workspace, which
answers "which project" and nothing else. The case that needs answering is a
dozen sessions against one project, where every row in the picker reads the
same.

--name takes the operator's own words into the registry and the listing. The
picker spends the project's column on it rather than adding one, because that
column's width is what wraps a row on a phone.
2026-10-15 14:23:12 -07:00
branderson affb2ac903 Merge pull request 'Rewrite the mirrored history: no AI co-author trailers, no operator values' (#408) from mirror-strip-attribution into development
Reviewed-on: #408
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 19:40:15 -07:00
branderson dfdc720434 Give the mirror's ssh alias one multiplexed connection on port 443
tests / confined (mirror) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m34s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m28s
tests / confined (share) (pull_request_target) Successful in 2m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m39s
tests / confined (audit) (pull_request_target) Successful in 4m41s
tests / confined (remote) (pull_request_target) Successful in 3m22s
tests / confined (project) (pull_request_target) Successful in 6m10s
tests / privileged (pull_request_target) Successful in 6m17s
tests / confined (static) (pull_request_target) Successful in 5m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
tests / confined (config) (pull_request_target) Successful in 9m52s
2026-10-14 19:12:49 -07:00
branderson 34f8fb85c9 Record the mirror's rewritten history in the changelog 2026-10-14 18:58:25 -07:00
branderson 4395887fa9 Document the mirror's history rewrite, the scrub file and --reset 2026-10-14 18:58:19 -07:00
branderson 44a74ce187 Rewrite the mirrored history: no AI co-author trailers, no operator values
Commit messages lose Co-Authored-By trailers crediting Claude, so GitHub
lists no tool as a contributor, and the values in a host-side scrub file
are replaced in every file and message. The rewrite is deterministic, so
syncs still fast-forward; --reset replaces a mirror built before it.
2026-10-14 18:56:55 -07:00
branderson f6e2480d37 Merge pull request 'Release 2026.10.14' (#407) from release-2026.10.14 into main
Reviewed-on: #407
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 18:31:47 -07:00
branderson ab362e8d75 Refuse a symlinked README in the mirror rewrite
perl -i reads through the link and writes the target back as a regular
file, which the mirror commit would then publish.
2026-10-14 18:03:44 -07:00
branderson a808447747 End perl's options before the forge's filenames in the mirror rewrite
A tracked file named like -eBEGIN{...} was read as code, which ran on
the host holding the GitHub key.
2026-10-14 18:02:37 -07:00
branderson c6afe46220 Say in the security doc why the mirror script is watched and copied 2026-10-14 18:02:02 -07:00
branderson 11b1d7f726 Watch mirror/ in the exit audit, and install the mirror script as a copy
The timer runs it on the host with a key that can push to every
repository in the GitHub organisation, and a session working in this
checkout can edit it.
2026-10-14 18:01:55 -07:00
branderson 4289bb59d8 Date the release 2026.10.14 and record the GitHub mirrors 2026-10-14 17:56:04 -07:00
branderson e8a80ed3e8 Merge pull request 'Mirror every dejima repository's main and release tags to GitHub' (#406) from github-mirror into development
Reviewed-on: #406
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:49:24 -07:00
branderson 361a7413fe Treat a repository with no forge links as nothing to rewrite, not a failure 2026-10-14 16:57:07 -07:00
branderson 9586a464ea Document mirroring every repository in the organisation 2026-10-14 16:29:22 -07:00
branderson 3bdc5a664b Configure the mirror timer for every repository in the organisation 2026-10-14 16:29:13 -07:00
branderson f86ceb9441 Mirror every dejima repository, and rewrite forge URLs in code too
One run syncs dejima, dejima-badge and the three sister projects, each
in its own subshell so one failure does not stop the rest. The rewrite
now covers every text file, since clone URLs and systemd Documentation=
lines live in code, and Gitea's /raw/branch/ becomes GitHub's /raw/.
2026-10-14 16:29:04 -07:00
branderson 8b7b9cfff0 Insert the mirror notice once, so the transform is idempotent 2026-10-14 15:15:37 -07:00
branderson 6c5f652375 Check for the wordmark rewrite itself, not a word the comment also uses 2026-10-14 14:49:42 -07:00
branderson ae91766acc Test tag ancestry with an if, not an && under set -e 2026-10-14 14:31:18 -07:00
branderson cfc1b01b1f Document the GitHub mirror 2026-10-14 14:27:51 -07:00
branderson 078b0d814b Add a systemd user timer that runs the GitHub mirror hourly 2026-10-14 14:27:29 -07:00
branderson d8924693d5 Add a script that publishes main and its tags to a GitHub mirror
The mirror's main is one commit on top of the forge's, rewriting forge
repository links to dejima-org and making the README wordmark follow
the reader's colour scheme. --settings turns off issues, pull requests
and Actions on the GitHub repository.
2026-10-14 14:27:07 -07:00
branderson bd590edfea Accept either host in the sister-project link tests 2026-10-14 14:22:02 -07:00
branderson 1436bb2614 Merge pull request 'Release 2026.10.13' (#403) from release-2026.10.13 into main
Reviewed-on: #403
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 22:37:25 -07:00
branderson f679e5d59f Count a later word in the merge guard only when it names a path
That is where the two layers divide: a bare `gh` anywhere in a segment is
reached by executing what PATH resolves, so the shadow sees it, while a path
names the real binary and the shadow never runs. Checking every word regardless
also blocked `echo gh pr merge 3`, which the agreement test names as the one
case the two layers must differ on.
2026-10-13 21:38:46 -07:00
branderson d41483e073 Record what the release review changed, and what it left
The merge-guard and gh api fixes restore what the entry already claims, so
neither is an operator-visible change against the previous main; --config-env
and the empty-key warning are. The merge endpoint goes under what this release
does not do, with its ticket.
2026-10-13 21:21:18 -07:00
branderson 6bf63c8325 Carry git's --config-env into the push guard's resolution
It sets config the way `-c` does, with the value read from the environment,
so a pushurl set through it has to reach the URL this resolves. The separated
spelling also takes the next word: left unrecognised, `remote.origin.pushurl=VAR`
was read as the subcommand, and a subcommand that is not `push` ends the
verdict having checked neither the host allowlist nor the blocked branch.

It is the last of git's global options that takes a separate value and was
neither skipped in pairs nor refused.
2026-10-13 21:19:40 -07:00
branderson 0936ac80ac Say when an empty ssh_key leaves the operator's agent signing
A key file that exists but is empty loads nothing, so the withdrawal that a
configured key triggers does not fire and the forwarded agent stays mounted.
With a pushing workflow and an agent running, every other signal agrees with
the arrangement the operator asked for: the key is configured, the launch
starts, pushes work - and they work because the personal identities are doing
them.
2026-10-13 21:17:42 -07:00
branderson f35556ee0b Hold the gh api brace exemption to an endpoint's {owner}/{repo}
gh substitutes those two from the checkout, so a path carrying them names this
repository by construction. Any other brace says nothing about what the call
reaches, and the exemption accepted one anywhere in argv: `-f name={x}` or
`--template '{{.login}}'` asserted the whole command was repository-scoped,
which left `gh api orgs/other/repos -X POST` and `gh api user` reachable.
2026-10-13 21:14:39 -07:00
branderson 5f595de0ab Read every word of a segment in the merge guard, not the first
A forge CLI named by absolute path behind a prefix word - `env`, `nohup`,
`xargs`, `timeout` - reaches the real binary without passing the PATH shadow.
A check on the segment's first word leaves that combination with no layer
looking at it, so `env /usr/local/lib/dejima/forge/gh pr merge 7` was allowed.

The test asserted the path-qualified form but not a prefixed one, which is why
it kept passing.
2026-10-13 21:09:26 -07:00
branderson 31e7b869c3 Date the release 2026.10.13
The heading has to match the tag on main, and the tag is the day the merge
lands. Nothing derives one from the other, so they are set by hand together.
2026-10-13 21:01:03 -07:00
branderson 9acc280e03 List the changelog in the README's docs table
The changelog exists to be found by an operator deciding whether to update,
and the README table is where someone looks first.
2026-10-13 20:58:43 -07:00
branderson 55046cf863 Merge pull request 'Release blockers: N1-N6 and the two older Highs from the development to main pass' (#399) from release-blockers into development
Reviewed-on: #399
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 20:38:36 -07:00
branderson a88c994954 Cut the story from the same-day release note
Three sentences on which format was tried first, which test refused it, and
why the paragraph was written the way it was. One fact survives that: four
dot-separated numbers beginning with a year and a month read as a private
IPv4 address, which is why the suffix is a letter.
2026-10-13 17:30:14 -07:00
branderson 0ffdfa6da2 Correct what the changelog says this release does to an existing config
Three things an operator has to act on were missing from "what an existing
config stops being able to do", each new since main and each visible to a
session that worked before:

- a configured ssh_key now withdraws the forwarded agent from the container
  entirely, so a session that used the key for the forge and the agent for a
  second remote loses the second half;
- an empty ssh_key file counts as no key, so a pushing launch with one and no
  agent is refused where it used to start;
- under the github forge, gh api is held to this repository, which an agent
  using it for anything else will notice.

Two entries were wrong rather than incomplete. #386 did not fail every
launch: it failed when the ranges came from a cache file, which is how CI
supplies them, and an ordinary launch's URL always has a host. #391 is about
what dejima gives you without the sister projects around it, not without a
forge.

The clamp covering only the bridge gateway moves to a "Known limitations"
section. It is a thing this release does not do rather than something that
stops working, and mixing the two makes the list an operator must act on
longer than it is. #379 and #385 are test-suite reliability and fold into one
line: the changelog's own rule is that entries name what an operator would
notice.

And one clause in the threat model, which this release's own clamp change
made checkable: no port on the host is open *unless you named one*.
2026-10-12 23:21:33 -07:00
branderson 2bee0e8b70 Have --version use the version library, and name a same-day release
Two review nits.

--version reimplemented dejima_version inline because the library was not
loaded at that point. It is pure functions with no config and nothing behind
it, so sourcing just that one costs nothing and leaves one implementation
rather than a copy that can drift from what the banner and the update notice
print.

And docs/release.md says what a second release on one day is called, which
the format otherwise had no answer for: a letter, 2026.10.12b. `sort -V`
orders it after the bare date, which is what the update notice compares with.

A letter rather than a fourth dotted number, which was the first choice: four
dot-separated numbers starting with a year and a month read as a private IPv4
address, and the static test that keeps those out of the documentation
refused the example. Writing that collision down tripped the same test, which
is why the paragraph describes the shape instead of showing it.
2026-10-12 23:21:33 -07:00
branderson a822e19fd0 Run the mount guard over the second seed list instead of arguing it away
The pull request's case for the second load was that what a plugin adds
there is a path dejima made this run. True of the one plugin that does it,
and not a property of the mechanism: harness_seed runs again in full, so a
plugin deriving a seed from state prepare_config changed would reach the
mount assembly on that argument alone.

So the rule is applied again, over a list rebuilt after the hook and still
before anything is mounted. Two loops over lists that are already built,
which is cheaper than the argument it replaces.

Driven at the function, since no shipped plugin exercises it - with the
adjacency of the call asserted separately, because the behavioural half
would not notice the call being deleted. The first version of that assertion
used a two-line `grep -F` pattern, which reads as two alternatives and
matched either line, so it passed with the call removed.
2026-10-12 23:21:15 -07:00
branderson 449ba3023d Stop throwing away the stage directory's path before cleanup reads it
My own bug from two commits ago, and the comment on it and the changelog
entry were both false as merged. The block that initialises trap-owned names
to empty runs AFTER the mktemp in straight-line order, so it replaced the
real path with "" and cleanup() removed nothing. Every launch, on every
harness, left a dejima-harness-stage directory in TMPDIR - 372 of them on
this machine from the test runs alone.

The line is gone rather than moved. SANDBOX_SETTINGS_FILE and
SANDBOX_CLAUDE_JSON_FILE are mktemp'd in the same region and deliberately
not in that block, for the reason that applies here too: they are bound
above the trap, so there is no window in which cleanup() could see an
inherited value, and resetting them afterwards is the only way to break
them.

Nothing caught it because nothing looked after exit. A launch test does now,
with its own TMPDIR, for both harnesses - cline is the one that writes a file
into it, so a leak there carries content rather than being an empty
directory - and for the two staging paths that were never broken, which is
what this one should have looked like.
2026-10-12 23:21:14 -07:00
branderson 3cf12910f3 Configure an isolated cline session from the staging directory
Fixes #393, so --isolated is supported on every shipped harness and the
release does not have to carry an exception.

Cline reads its provider configuration from a JSON file inside its own data
directory, and dejima wrote that file onto the host and let the state mount
carry it in. An isolated launch has no state mount - so the file went to a
host directory keyed on the container-local working directory, shared by
every isolated cline launch, and was 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 written into the per-run staging directory now and seeded in, so
entrypoint.sh installs a copy the session owns. Two things follow. It arrives
without a state mount, which is what makes the mode work for this harness.
And nothing the session writes to it reaches the host, which is what the
comment above that function already wanted - "a repointing cannot persist
into the next session" - and which writing into a mounted directory only
approximated by rewriting the file each launch.

An ordinary launch ends up where it did: that one has a state mount, so the
seeded copy lands inside it.

The existing cline tests read the file out of the state directory, which is
no longer where it is; their fixture passes the staging directory core now
passes and reads it from there. The test that asserted the refusal asserts
the configuration arrives and the host state directory is untouched, with an
ordinary launch beside it so neither can pass by nothing being mounted.
2026-10-12 19:24:44 -07:00
branderson 54f0dac269 Give the hook that produces per-run files somewhere to put them
harness_prepare_config exists to produce what a launch needs and throws away
afterwards, and it had exactly one place to write: the single settings-override
file core passes it. A harness whose configuration is a file inside its own
state directory had nowhere, so it wrote onto the host and relied on the state
mount to carry it in.

So it gets a per-run directory as well, made beside that file and removed by
cleanup() with every other staging path.

And core loads the seed list twice. The early load is what the mount guard
runs against - a guard after the mount it would refuse is not a guard - and
the second, after prepare_config, is what the mount assembly reads: a hook
whose job is to make a file has to run before the list that mounts it. The
loader resets rather than appends, so a plugin emitting the same entries in
both passes gets them once. What a plugin adds in the second pass is a path
dejima made this run rather than one an operator could have pointed a mount
at, which is why the guard's early pass is still the whole guard.
2026-10-12 19:24:44 -07:00
branderson 075fcd1def Test the version, the update notice and the changelog's own claims
One commit for three because the tests are one block in test/static.bats,
and the alternative was scattering them across the file so the history could
have them separately.

What they hold. The release comes from git and not from a file, driven over
every state a checkout can be in - untagged, on a tag, past a tag, a
non-release tag present, and no git at all - because "unreleased" versus a
date versus a date+N are three different things to tell an operator. The
notice names both releases and the changelog, reads the remote's tags through
the same hardened invocation as the branch tip, and asks for them only when
there is news. The changelog says which version is supported and points at
the binding statement, and has an Unreleased heading to write the next entry
into.

There is deliberately no sweep for words like "backport". The first version
of one failed on the changelog's own sentence saying there are no backports:
a pattern cannot tell a promise from its denial, and a test that matches the
shape of prose rather than its claim is one that gets edited until it passes.
2026-10-12 17:14:58 -07:00
branderson d111bf0aed Write down what each release changed, and how to cut one
CHANGELOG.md, newest first, for one question: should I update, and what will
I be getting. Entries name what an operator would notice; the pull requests
carry the detail and are linked.

The risk a changelog introduces is the expectation that comes with it - a
list of releases reads as a list of things one may be running - so the file
says at the top that no release here is supported and points at SECURITY.md,
which is where that is binding. SECURITY.md's own "Supported versions"
section now says where to see what changed, since that is the question
someone arrives there with.

The first entry is this release: 119 commits, PRs #360-#391 and #399. It has
a section for what an existing config stops being able to do, because two
things in this one refuse an arrangement that worked and one of them - a
host-resident Loki configured by name - fails silently. The clamp covering
only the bridge gateway is named there too, with the issue.

`## Unreleased` is where the next entry goes before its date is known, since
the date is the version and cannot be written before the merge is made.
docs/release.md §6 is the two steps that produce a release - tag `main`,
rename the heading - both easy to forget because nothing fails without them.
2026-10-12 17:14:58 -07:00
branderson 6aacfbd8dd Name the release an outdated checkout is missing, and the changelog
The update notice said a branch has commits this checkout does not, which
answers "is there something newer" and nothing else. An operator deciding
whether to update wants to know how far behind they are and what they would
be getting - which is what releases are for, so the notice names both sides
and points at CHANGELOG.md rather than leaving the file to be discovered.

Both sides 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.

The remote's newest release comes from its tags, through the same hardened
invocation as the branch tip and for the same reason - the checkout's own git
config must not name a command this runs. Release-shaped tags only, and asked
for only when there is something to report, so an up-to-date checkout still
pays one request rather than two. The cached answer grew a third field; a
cache written before it existed reads as an unknown release, which is what a
failed lookup also gives.
2026-10-12 17:14:58 -07:00
branderson 53a448bc43 Say which release a checkout is on
A release is a date - 2026.10.12 - and the git tag of that name 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, and it tells the
truth about a checkout between releases: 2026.10.12+37 is on no release.

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 SECURITY.md explicitly does not make. The
tip of main is the only supported version; a tag is a record of what shipped.

Two surfaces: `dejima --version`, answerable with no config read for the same
reason --help is, and the release beside the wordmark on the startup banner,
where an operator is already looking. The --help line for it is earned by
covering both flags on one.

Four states, each a different thing to tell someone: the release, the release
plus a distance, `unreleased` for a checkout with no release tag yet, and
`unknown` for no git at all. Only tags that look like a release count, so a
marker pushed for something else cannot become the answer.

Tests for this and the two commits after it arrive together, two commits
along: all three sit in one block of test/static.bats, and splitting them
would mean scattering them across the file for the sake of the history.
2026-10-12 17:14:58 -07:00
branderson 6bf9a1b60f Decide which host candidates are the host in the filter, not the launcher
The round-1 fix compared a configured destination's address against
EGRESS_HOST_ADDR in the launcher. That looked equivalent to deciding
residency and was not: the variable is the operator's override for when the
policy's own derivation from the default route is wrong, so it is normally
unset - and with it unset nothing was ever derived.

Which means the clamp going first, which is the fix this branch exists for,
refused a host-resident Loki or self-served model at the gateway address on
any ordinary install. The model fails loudly, because the session cannot
reach it. The Loki case fails silently: the shipper is best-effort, so
network logging just stops.

Split the decision instead, because neither process knows both halves. The
launcher offers candidates it is sure of - an explicit port on a literal
address - as EGRESS_HOST_CANDIDATES. The policy, 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, which is what both sides were
already refusing to do.

The folding runs after discovery and before validation, so a candidate's
port is held to the same check as an operator's rather than trusted for
having crossed a process boundary - with its own refusal naming the variable
it came from.

What is still a behaviour change on upgrade: a host-resident service
configured by NAME needs EGRESS_HOST_PORTS set by hand, since a name is a
destination neither process will resolve to decide what to open. It fails
closed. docs/release.md now asks the release pull request to name that class
of change, because failing closed is quiet, and an observability path is the
worst case.

Also the review's nit: the summary line is why nothing can reach a
host-resident service, the session and the shipper beside it.
2026-10-12 01:57:33 -07:00
branderson cbdac96133 Drive the credential-mount check through a launch, not over the source
The P1 test greps the flattened `elif` for five substrings, which is better
than the line-number comparison it replaced and would still pass on a
restructuring that tested the same words in a different combination.

So it is driven now, the way the release pass reproduced it: a backend whose
credential kind is none - cline with a self-served endpoint, since no API
claude-code speaks has one - with a real-looking credential sitting on the
host, and the docker argv asserted to carry no mount of it. With the
arrangement the mount IS for asserted the other way, so the test cannot pass
by nothing ever being mounted.

The grep stays. It says which conditions the branch tests, which argv
cannot.
2026-10-11 18:57:02 -07:00
branderson 0eb418266c Derive a host port only where the launcher can be sure of the host
The N1 fix introduced this, and it is the sharper half of what that fix was
about. The derivation added the port of every configured destination
"whether or not it turns out to be host-resident" - so a remote Loki at
https://loki.example.com, which has no port of its own, added 443 and opened
the host's own 443 to every session. On a box following the reverse-proxy
arrangement in docs/remote-access.md that is the proxy in front of the web
terminal: the thing the clamp exists to keep out of reach, opened by the
change that was meant to close it.

Three conditions now, and each is load-bearing because what the list opens
is a port on the host rather than on the destination: an explicit `:port`,
a literal address rather than a name, and that address being the one
EGRESS_HOST_ADDR names. Outside that nothing is derived, so a host-resident
service configured by name needs EGRESS_HOST_PORTS set by hand - which the
docs now say, where they claimed the launcher handled it.

Three URL shapes also produced a value that is not a port, which the policy
then refused by naming a variable the operator never set: userinfo whose
password starts with a digit, a query with no path, and a bracketed IPv6
address. Userinfo, query and fragment are stripped before the split, an
unbracketed IPv6 address is read as having no port because every colon in
it is the address, and a port has to be all digits.
2026-10-11 18:57:02 -07:00
branderson a53936b10c Fix the documentation the release pass found wrong
Section 4 of the pass, each one a claim the code contradicts.

Security properties stated too strongly. `[identity] ssh_key` is not a
section that exists - it is `[identity.<name>]` or the deprecated
`[core] ssh_key`. The isolated table said the forwarded agent is "not
mounted" flatly, where it is withdrawn when the workflow does not push OR a
usable key is configured - so the docs' own example, a pushing workflow on a
host with no key, forwards it. "The table above is the whole of what
crosses" was false: LOKI_URL still reaches the session, and the model
credential follows its own rules whatever the mode. And
`a session sees its own project's history and no other's` is true of the
per-project state and not of the four shared read-write directories this
release's own comment calls shared (#380).

Claims about the code's mechanism. The README listed "the Docker host" as
allowed, where it now opens the ports a host-resident service was
configured on and no others. providers.md described merge-guard.sh by a
regex mechanism it no longer uses, and said an empty DEJIMA_MERGE_BLOCK
makes it inert - it stays active for the disclosure and for repository
scoping, neither of which is the workflow's to turn off.
EGRESS_HOST_PORTS is set in the launching environment, not "on the filter
container", and the launcher now contributes to it.
configuration.md claimed nothing on the command line turns a setting back
on, which is true of the `--no-*` family and not of `--ro`, `--rw`,
`--include`, `--allow-github-push` or a `--forge` the config did not name.

Things that were simply missing. `--isolated` was not in the README at all,
and the refusal of a project path was undocumented. An empty `ssh_key` file
counting as no key is how a launch keeps the setting and forwards an agent
instead, which nothing said.

Smaller: a sentence orphaned by the multi-domain insertion now names its
two subjects, ci.md loses a container count that drifts, and
development.md's merge-guard row describes what the hook does rather than
what it was named for.
2026-10-11 17:30:14 -07:00
branderson 505c6f0623 Test the host clamp's ordering and the ports the launcher derives
The two tests for the host-port clamp change earlier in this branch. They
are a commit of their own rather than part of it because the hunk selection
that built these commits left them behind, and moving them back would mean
rewriting six commits that are otherwise correct.

What they assert: that the clamp is emitted before the open class and after
the forge clamp, which is what decides the matter since nft takes the first
match - and that the launcher derives a port from each configured
host-resident URL, with the derivation itself driven over the shapes it has
to read. A wrong port is the same as no port once the clamp is first.
2026-10-11 17:30:14 -07:00
branderson 7776aa3298 Remove an isolated session's share when it is still empty
From the release pass's lows. The share is made before most of the launch's
refusals, so a refused isolated launch left an empty directory under the
isolated root for an operator to find and wonder about - and a session that
handed nothing back left one too, in a root that grows by one per launch
with nothing to prune it (#389).

`rmdir` in the exit trap, which only removes an empty directory, so the
check is the removal. A project's share is untouched: that one is expected
to outlive its sessions.

The two tests that asserted the directory still exists afterwards now assert
the opposite, and one of them drives a stub that writes into the share -
which is what shows the launch creates and mounts it rather than only
computing a path, the thing the old assertion was really standing in for.

Refs #389
2026-10-11 17:29:44 -07:00
branderson 2a9606d873 Refuse --isolated written after the project path
From the release pass's lows, fixed here rather than ticketed because of
which way it fails. The flag loop stops at the first positional, so
`dejima ~/proj --isolated` passed the flag to the harness and mounted the
project read-write: the operator asked for the one thing that silently did
not happen, and nothing in the output said so.

Refused unless `--` says the rest is deliberately the harness's, which is
the one reading where passing it through is what was meant.
2026-10-11 17:29:43 -07:00
branderson 885423a0a8 Warn about every guarded store inside the workspace, not three of six
P2 from the release pass, reframed. The finding was that the workspace mount
bypasses the guarded-store refusal. It does - and that is a deliberate
decision with three tests pinning it: warn_if_inside_workspace tells the
operator, with each store's own consequence, rather than refusing a launch
against a directory that happens to contain one. Refusing would make
`dejima ~` impossible on a host whose config lives under it, which is a
policy change for the operator to make rather than a release fix.

What was not deliberate is which stores it covered. Three 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 sat inside the project: the epitaph
store, every project's share root, and the isolated share root this release
added. Each has its own consequence and now says it - the epitaph one is the
sharpest, since that store exists to not be readable by a later session.

A static test holds the two lists in step by name, in both directions, so
the next store added to one is added to the other. The generic refusal text
said "even read-only" for a mount that is read-write by definition; it says
"mounted at all", which is true of every caller.

Whether the workspace should be refused rather than warned about is a real
question and not this one: #398.

Refs #398
2026-10-11 17:29:31 -07:00
branderson bec542ca48 Mount the real Claude credential only when it is this backend's own
P1 from the release pass, older than this release and worth fixing before it
because docs/security.md says the opposite.

The proxy branch requires a credential kind other than none, the proxy on,
and the credential usable. Everything else fell through to
`elif [ -f ~/.claude/.credentials.json ]`, which mounted the host file
read-write. That covers a backend needing no credential at all - a model you
serve yourself, the arrangement the docs call the one with nothing in the
container to steal - and any backend whose own credential file is elsewhere
and missing.

The branch names every condition that makes this the documented downgrade:
there is a backend, its credential is not none, the proxy is off for it, and
its credential file IS this file. The last is what distinguishes "the
operator turned the proxy off for the credential in this file" from "the
proxy branch did not take, so mount whatever is lying there".

The unusable-with-proxy-on case cannot reach it either way: that is already
a refusal, by design, a few lines above.
2026-10-11 17:27:43 -07:00
branderson 402fac5d5d Refuse an isolated cline launch rather than writing its config to the host
N4 from the release pass. harness_prepare_config writes providers.json into
the state directory, and the session reads it through the state mount - which
--isolated skips, because that hook also creates a slug directory on the
host. So an isolated cline launch wrote a file to the host, keyed on the
container-local working directory and shared by every isolated cline launch,
did not mount it, and started Cline with no model configured. That last part
is the failure providers.json's own updatedAt comment describes: Cline falls
back to its hosted endpoint, which the firewall was never asked to allow.

Refused in the plugin, before anything is created, because the harness is
what knows it has no way in. #393 is the way in - a per-run mount, the same
shape as the settings override.

Refs #393
2026-10-11 17:27:43 -07:00
branderson 2d462ea53c Make every reader of an isolated launch's settings give the same answer
N3 and N6 from the release pass, together because both are the same block
and splitting it is what a reader would have to undo to see either.

N3: `_override_set core.forge` is true for any `--forge`, `none` included -
so `dejima --isolated --forge none`, the launch that said most clearly that
it wanted no forge access, was the one handed core's GitHub credential. The
GitHub token now follows the resolved forge's capabilities instead: a forge
that can do nothing has no use for a credential, which is the actual rule,
and it names no forge, which core may not.

N6: the banner worked the epitaph out from [epitaph] enabled alone and
printed "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
overrides now, where the flag is read, so the banner, --print-config, the
epitaph's save and the shipper's start all read one answer. The two mode
checks those last two carried are gone. The audit keeps its own, having no
key to override.

The test called "reaches no forge unless the same command names one" never
named a forge - only a workflow. It covers both, in both orders, with the
other setting asserted to stay at the mode's default.
2026-10-11 17:27:23 -07:00
branderson 8bea036701 Narrow the projects map on the proxy-off path too, not just the first one
N2 from the release pass. With the proxy off for a backend whose credential
IS ~/.claude.json, the staged copy was re-seeded with `cp` so the credential
would survive - and that put back the whole `projects` map #381 narrowed:
every project's path, a sample of each one's filenames, and what each had
cost. It ran after the credential scan had read the narrowed copy, so
nothing ever looked at what came back.

The narrowing is one function with the credential as a parameter now, so
there is no second expression to forget. The two call sites differ in that
one respect and nothing else.

docs/security.md said the copy was "seeded from the real file" with only the
credential removed, in two places. It describes both halves now, including
that this path keeps the credential and narrows the map exactly as the
other does.
2026-10-11 17:27:02 -07:00
branderson 8b9c94bfc5 Clamp the host's own address before the rules that would widen it
N1 from the release pass. The clamp said the right thing and never saw the
packets: the open class permits on every port, and the host's address lands
in it by several ordinary routes - a host-resident LOKI_URL, a self-served
model's base_url, `dejima-allow <gateway>`. The permit was emitted first, so
the host was open on every port it publishes, sshd and the remote-access
front door included, while docs/security.md said configuring one host
service is how that service becomes reachable.

Moved ahead of the open class and still behind the forge clamp, so a
host-resident forge is read as a forge rather than as the host.

On its own that would refuse what the clamp is for. EGRESS_HOST_PORTS has no
config key - it comes from the environment the operator launches from - so a
host-resident Loki or self-served model worked only because the open permit
answered first. The launcher now adds the port of each host-resident
destination it configured, which is what makes "that service, not the whole
host" true rather than aspirational. A port is added whether or not the
destination turns out to be host-resident, because deciding that means
resolving a name and the launcher does not.

One bug found writing the derivation, worth knowing: in a single `local`, a
later initialiser does not see an earlier name from the same statement, so
every URL read as its scheme's default port. Declared separately now.
2026-10-11 17:26:29 -07:00
branderson 43ac721355 Merge pull request 'Say what dejima on its own gives you' (#391) from standalone-posture into development
Reviewed-on: #391
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 03:32:00 -07:00
branderson 60d0b8ec97 Merge pull request 'Run a session with no past and no future: dejima --isolated' (#390) from isolated-mode into development
Reviewed-on: #390
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 03:29:21 -07:00
branderson f0513208a1 Stop inviting the isolated mode to be used for untrusted work
The pointer to security.md said it was worth reading "before using this for
anything you would not run against your own checkout", which reads as an
offer the threat model does not make: its stated premise is that the
project is one you already trust, and nothing about this flag changes that.
It narrows what a session can read of the host and what credential it
carries, which is not the same claim.
2026-10-11 01:01:49 -07:00
branderson a6e1de5f30 Keep the credential argument in one place
The explanation of what crosses into an isolated session and why was in
both documents, nearly verbatim. security.md keeps it: why a forge token
is the widest thing a session holds, why core's GitHub credential counts
as one, why the agent and the key get opposite answers, and why this is a
narrower blast radius rather than a stronger sandbox.

configuration.md keeps the table, the two commands, and the two rows that
surprise people - the GitHub token being withheld though it is not a forge
setting, and the key being mounted though the agent is not - then links to
the argument. The division is that configuration.md owns what the mode
does and how to ask for it, security.md owns what boundary it moves.

One duplication stays, in the other direction: security.md says in one
sentence that the conversation log, the epitaph and the audit are off,
where configuration.md has the full version and the issue number. Each
page carries its own argument whole and one sentence of the other's.
2026-10-11 00:34:56 -07:00
branderson 60d6736e37 Narrow the comment above the isolated forge default to the key
It said the ssh key or the forwarded agent is untouched, which was true
when it was written and stopped being true in the same commit: the
workflow this sets pushes nothing, and a workflow that never pushes
already gets no agent socket. Only the key is untouched, and #392 is where
that is being decided.
2026-10-11 00:34:56 -07:00
branderson b36e7c935f Withhold the GitHub token from an isolated session, and get the agent right
Two halves of one question - what credential an isolated launch carries -
which the round-1 change answered for the forge's own token and got wrong
for the other two.

Core fills GH_TOKEN from [github] token_file for every session whatever
forge is selected, so narrowing the forge did not narrow it. Read-only
across an operator's private repositories is still account-wide reach, so
an isolated launch that named no forge no longer gets it. The filter
container still does: that token authenticates dejima's own fetch of
GitHub's address ranges, a request about the allowlist made where no
session can reach it, so the session reads a separate variable. Gated on
whether the command named a forge, captured before the mode's own default
answers for it.

The docs had the ssh agent backwards. A workflow that never pushes gets no
agent socket and local-only is one, so an isolated launch forwards no
agent; a configured key crosses whatever the workflow. They now state the
pair, with a table row each.

The test for it was worse than incomplete. project.bats' launch helper
hardcodes SSH_AUTH_SOCK empty, so no launch it drives could have forwarded
an agent and asserting its absence proved nothing - and a configured key
withdraws the agent independently of the workflow, so even with a socket
the fixture would have shown the right answer for the wrong reason. The
helper now takes LAUNCH_SSH_AUTH_SOCK for the one test about that path,
and that test empties the key file so the push rule is the only thing left
that can withhold the socket, with a pushing workflow as the positive
control.

Withholding the key as well, for any launch whose workflow never pushes,
is #392: wider than this flag and not free.

Refs #392
2026-10-11 00:03:58 -07:00
branderson ebf3b1dfe1 Name the runtime test's second volume so teardown can sweep it
Purpose before the run suffix, like test_container_name: teardown's
filter is anchored on the suffix, so a name ending in anything else is one
it cannot reach if the test is interrupted between creating the volume and
removing it.
2026-10-11 00:03:33 -07:00
branderson 84ed923f85 Reach no forge from an isolated launch unless the command names one
The flag narrows, and the forge credential is the widest thing a session
gets: a token is commonly account-wide rather than per repository, so a
session holding one can read and write everything its operator can.
Carrying that by default into the mode meant for a session nobody is
watching had the host narrowed to nothing and the forge left at full
reach.

So --isolated sets forge to none and workflow to local-only for that
launch. Naming either on the same command line is how an operator asks
for a session that clones and opens a pull request, which is a real use
of this mode. The defaults are applied after the argument loop rather
than inside it, so the two orders agree.

The ssh key and a forwarded agent are untouched. That requirement belongs
to the launch rather than to this flag, so what this removes is the forge
token and the instruction to push, not the ability to reach a host over
ssh. Both documents say so, and a test exists to fail if it stops being
true rather than letting the claim go stale.

The briefing's closing paragraph said work leaves "pushed or left in
~/shared", which is loose when the default workflow pushes nothing.

Also here, because they sit inside the same hunk: the history-volume
assertion in project.bats now says "sets nothing" rather than naming a
volume, which is the previous commit's reasoning applied to the test that
had the same weakness.

And two things the review raised that are not this: the remote-access
registry writes `workspace=isolated`, so `dejima-attach --list` stops
showing `work` - dejima's own scratch path - for every isolated session;
and the launch notice names the forge and workflow it resolved, since
both are now part of what the mode is.
2026-10-10 22:06:41 -07:00
branderson 770e8778be Give the isolated working directory a named volume, reaped like the rest
It was an anonymous volume, asked for with a colonless mount spec, which
left the reaping to whatever `compose run --rm` does with one - a question
the runtime test could not answer, because it exercised plain `docker
run`. The guarantee never depended on it: an anonymous volume is as
unreachable as a named one. What did depend on it is that the volumes do
not pile up, once per launch, and this is the one holding everything the
session cloned or wrote.

So it is a volume declared beside the other four, named for the run and
added to the set cleanup() removes. One rule for all five, and nothing
left resting on an unverified property of compose.

The runtime test now asserts what a confined suite genuinely cannot: that
a volume at that path is writable by the session user, since it is seeded
from the image and a wrong owner would break a session on its first
write, and that a second session's starts empty. It no longer compares
`docker volume ls | wc -l` before and after, which another job on a shared
runner creating a volume in between would have failed. runtime.bats'
teardown sweeps this suite's volumes too, for the run interrupted between
making one and removing it.
2026-10-10 22:06:21 -07:00
branderson ba3615ec4b Keep the shell-history volume pointing at the volume it already was
My own regression from the commit that made these names variable. The
compose project is `dejima` and the key is `dejima-bashhistory`, so an
entry with no `name:` gets `dejima_dejima-bashhistory` - and the default I
wrote was the key alone, a different volume. Every ordinary launch after
this branch merged would have started from an empty history with the real
one orphaned.

The test is rewritten rather than corrected. Pinning the right string
leaves a test that gets edited to match whatever the file says, which is
what the old one did and why it passed. It now derives the expected name
from the compose project and each volume's key, and checks every declared
volume, so a default that merely looks plausible fails and a volume added
later cannot skip the check.
2026-10-10 22:06:06 -07:00
branderson 83f9fcfe7f Document isolated sessions, and where a flag's reasoning lives
A section in configuration.md with the table of what differs, why the
launch directory decides nothing, and what the mode gives up. Two notes
beyond the mechanics: that there is no config key in any spelling, and
that this is a narrower blast radius rather than a stronger sandbox - the
firewall, the credential boundary and the capability drops are unchanged,
and a container escape is exactly as serious.

security.md gets the short version beside the section on the mounted
project being writable, which is the class this removes rather than
bounds.

providers.md lists the briefing placeholder whitelist, which a test holds
to the code, so the two new ones are named there with what they are for.

The narrowing-flags paragraph cut from `dejima --help` lands here in full,
which is what earned the line the flag needed.
2026-10-10 20:26:39 -07:00
branderson a687d1793b Test what an isolated launch hands the container
Driven through the real launcher against a stubbed docker, because every
claim here is about what `compose run` is given and a grep over the source
passes just as well on a line that no longer takes effect.

The one about an attack rather than about tidiness is that a launch from
inside an approved project gets neither its domains nor its dependency
layer, with an ordinary launch from the same directory asserted to get
both - so it cannot pass on a fixture whose approvals were never recorded.

Two independent things in bin/dejima hold that, and the test fails only
when both are reversed. Worth saying because a mutation run looks like a
miss otherwise: reversing either alone still passes, which is what defence
in depth looks like from the outside.

Seventeen other mutations each fail the test named for them. The runtime
one covers the single assumption a confined suite cannot decide - that
Docker reads a colonless mount spec as an anonymous volume, writable by
the session user, with no host path behind it.
2026-10-10 20:26:38 -07:00
branderson 537ab56b04 Stop telling an isolated session things that are not true of it
Four claims in the core preamble an isolated launch makes false: that the
workspace is the real project, that ~/shared is shared with every 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 work for a resume that cannot
happen.

The two paragraphs carrying them become placeholders with a variant each,
which is the mechanism the preamble already uses, so the ordinary briefing
comes out byte-identical apart from two reworded clauses. The harness
fragment, which is what promises the resume, is replaced rather than
dropped: a session told nothing would assume the usual arrangement.

The worktree block's whole rationale is that the bind-mounted project is
the only path backed by host storage, which inverts here. Both worktree
workflows also stated its first bullet identically in their own fragments,
leaving one argument in three places and only two of them substitutable -
it is core's now, so a workflow says where the block goes and nothing
about what it says.

"outside the project directory" became "outside your working directory",
which is true in both modes rather than vacuous in one.
2026-10-10 20:26:25 -07:00
branderson a72ea3dcda Turn off what reads a transcript, and say so at launch
The epitaph, the conversation shipper and the exit-time audit all key on a
workspace that an isolated launch has no host side of. Each would
otherwise run against /dejima/work, find nothing, and look like a feature
that is present.

So all three are skipped, and the launch prints what it is, what is off,
and where its share directory is - whatever [ui] banner says and whether
or not stderr is a terminal, unlike the banner. The share path is the only
route back to what the session hands over, and a log that is quietly empty
is worse than one the operator knows they are not getting.

Making either of the first two work in this mode is #388.

Refs #388
2026-10-10 20:26:25 -07:00
branderson 006a2319e7 Give an isolated launch a share directory of its own
The one thing that outlives such a session, so it has to be a real host
directory - and it must never be the one another session had.

A sibling of the per-project root rather than a slug inside it, because
an isolated session has no project to be keyed by. mktemp -d rather than
a name built from a timestamp: two launches in the same second would
otherwise be handed one directory, which is the single thing this layout
cannot do.

The root joins the guarded stores, so no ordinary launch can name it in
[mounts] readonly and be handed every isolated session's handoff files -
the pile the per-project split removed. The loose-entry notice learns
about it too, or it would report dejima's own directory as something left
behind by hand at every launch.
2026-10-10 20:26:02 -07:00
branderson c9064053ab Mount none of the host's writable state for an isolated launch
What crosses in, and what outlives the container.

The harness state hook is not called. It is what mounts the transcripts
and the per-project memory, and it creates the directory for the
workspace's own slug before mounting it - so skipping the call is also
what stops an isolated launch leaving a directory behind.

Config directories are filtered on the mode the harness already declares.
The read-only ones are the operator's hooks, skills and commands, which no
session can write; the writable ones are shared state every project's
sessions append to (#380). They are not pre-created either, since a
directory nothing mounts needs no mount source.

Both nvim's volumes and the shell history are named for the run. Every
isolated launch has the same working directory, so a slug would give them
all one set - and the history volume is shared by every project even when
the nvim ones are not, which makes it the one surface where an isolated
session would read what was typed in any session before it. What keeps
them unreachable is the name carrying this run's id; the removal in
cleanup() is tidiness on top of that.

The [mounts] lists are dropped with a line naming them, rather than
refused: they are a standing preference about ordinary work, and refusing
would mean an operator with one could never use this flag.

Refs #380
2026-10-10 20:25:50 -07:00
branderson 57317e14ec Add --isolated, a launch with no project behind it
The flag, the working directory it runs in, and the one thing that makes
the directory it was started from irrelevant.

/dejima/work is container-local and outside $HOME on purpose: a few
host-side reads take this path, and ~/work is a path an operator might
plausibly have. The compose file takes the whole mount spec from one
variable because an isolated launch needs one with no colon in it - an
anonymous volume - and a list entry cannot be left empty.

The approval store is not resolved. resolve_project_store walks upwards
from the path it is given, so a launch from $HOME on a machine whose
dotfiles were once approved as a project would otherwise be handed that
project's allowed domains and its dependency layer.

--ro and --rw are refused rather than dropped: a path typed into this
command cannot be quietly ignored. A project path is refused for the same
reason, since nothing afterwards would contradict it.

The --help line is earned rather than taken: the page was at its budget,
so the two-line note on narrowing flags became one and moved to
docs/configuration.md in full.
2026-10-10 20:25:34 -07:00
branderson dd9fc844df Link the security notes' laptop caveat to what still holds there
Refs #387
2026-10-10 20:21:03 -07:00
branderson b41097c253 Point the README's stack table at the standalone answer
Refs #387
2026-10-10 20:07:14 -07:00
branderson f9f7a3f60f Say what dejima on its own gives you, and what to do about the rest
Refs #387
2026-10-10 20:07:09 -07:00
branderson af6913ebbe Merge pull request 'Wait for what teardown killed before removing its directories' (#385) from remote-teardown into development
Reviewed-on: #385
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:49 -07:00
branderson e2a7dcd4dd Merge pull request 'Stage only the launching project's entry in ~/.claude.json' (#381) from claude-json-scope into development
Reviewed-on: #381
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:10 -07:00
branderson a6585dd050 Merge pull request 'Take several domains at once in dejima-allow, capped and confirmed' (#383) from allow-multi into development
Reviewed-on: #383
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:08:25 -07:00
branderson b142c31b22 Merge pull request 'The self-check retry that missed its own failure, and deadlines that scale with the load' (#379) from reliability into development
Reviewed-on: #379
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 22:49:53 -07:00
branderson c5b3c5d222 Wait for what teardown killed before removing its directories
remote.bats failed intermittently on `rm: cannot remove ...: Directory not
empty` in its own teardown - a red check on a test that had passed. Seen in CI
on #374 and again locally on a branch touching neither this file nor
dejima-attach, which is what said it was the teardown rather than the test.

`kill` sends a signal; it does not wait. The attach client these tests
background - and the script(1) and tmux processes under it - takes a moment to
go, and `tmux kill-server` likewise returns once the server has been told. In
that window `rm -rf` lists a directory, deletes what it saw, and something
still exiting creates another entry before the rmdir.

So teardown waits for each thing to be gone rather than sleeping or hoping: the
client until its pid stops answering, the server until it stops answering
has-session. The removal then retries as a backstop for anything else that
writes on its way out, and reports what is left rather than failing - a leaked
temp directory costs disk, where a red check on a passing test costs a re-run
and some trust in the suite.

Three consecutive full-gate runs clean, with no occurrence of the message. That
is evidence rather than proof for a race, and the suite's own history says so:
#295 called three clean runs "rarer, not impossible".
2026-10-09 21:51:45 -07:00
branderson 3d2e3d2699 Stage only the launching project's entry in ~/.claude.json
That file keys per-project state by absolute path, and dejima copied the whole
map in. So a session was told the name of every project on the host, a sample
of each one's filenames (exampleFiles), what each had cost and when each last
ran - from inside a sandbox whose whole claim is that it cannot learn what you
do elsewhere (#380).

Narrowed for the reason #104 narrowed the projects MOUNT: the whole map is
every project's. The project's own path and anything under it are kept, because
a session's cwd is routinely a worktree inside the project and Claude Code keys
that separately. Compared as strings, which is meaningful here rather than a
guess about layout: both sides of this mount agree on the path by construction.

A `projects` key holding something other than an object is left alone rather
than narrowed. Narrowing it would error, and the staging treats a jq failure as
fatal - so an odd but parseable file would have become a launch that will not
start, over a key with nothing in it to narrow.

The other three directories #380 names are untouched: scoping backups/, tasks/,
plans/ and sessions/ is a behaviour change for the host side, and this one is
not.
2026-10-09 21:51:45 -07:00
branderson 2d8c86ea44 Take several domains at once, up to twenty, confirmed before any is added
An agent that needs a toolchain's hosts asks for them together, and this took
one domain per command - so the operator ran it four times for four hosts, or
the agent asked four times.

Up to twenty now. One still goes through as it always did: that is a decision
the operator made by typing it, and a prompt on every ordinary use is a prompt
nobody reads. More than one is printed back one per line and confirmed first,
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 - so twenty, which is enough for the real case and short enough
to take in at once.

Every name is validated before any of them is shown, and one bad entry refuses
the whole command. The list being confirmed has to be the list that gets added,
or the operator approves names and a prefix of them lands; and the printout can
only be read if no entry carries a carriage return or an escape sequence that
rewrites the lines above it, which the pattern already forbids. All the invalid
ones are named, because fixing a list of ten should not mean running it ten
times to find the second mistake.

Confirming needs a terminal, so a script does them one at a time - which is
what it did before and still does for one. Repeats collapse, case-folded, so
the count the operator reads is honest.

$domain is only set inside the per-domain loops now, so the container picker's
prompt takes a label for the whole request instead. Under set -u that one
reference was fatal rather than merely wrong, which is how it was found.

The help page stayed within its thirty lines by earning them: the picker is
described once under the scope that finds it rather than twice, and two
paragraphs that both said a scope flag is an approval for later became one.
2026-10-09 21:51:45 -07:00
branderson 503addfc8b Scale a bounded wait by the concurrency the run asked for
The suites 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. One that is right on an idle box is wrong under the gate,
which runs every suite at once, and raising it until both pass means picking a
number that is wrong in one of them. #296 reduced that by budgeting the
concurrency; it did not remove it, and the same mistake arrived in a new
fixture three days later, where an idle bound went from sixty seconds to two
hundred and forty.

So each runner says how much concurrency it asked the machine for -
run-confined.sh the budget it divided among the suites, the CI job what it
passes bats - and the fixtures multiply their bounds by how oversubscribed that
makes it. Neither number is a guess about load: it is what that runner asked
for.

Nothing gets shorter than it is today. The multiplier floors at 1, so an idle
run and a run with the variable unset behave exactly as before, and it is
capped because a wrong value here turns a test that should fail in seconds into
one that hangs for the length of the job.
2026-10-09 21:51:45 -07:00
branderson a279460318 Make two tests look at what they are named for
"The permitted set cannot be amended from the session container" checked only
the exit status, so an amendment that landed and then failed on something later
would have passed it - non-zero, with the destination permitted. It now re-reads
what a permit for a name actually writes: the entries file and the resolver
configuration. Not the address sets, which only ever hold addresses, so
grepping those for a hostname is a check that cannot fail.

The negative-control loop in the self-check test iterated the output of a grep,
so renaming those helpers would have left it asserting that none of zero lines
is retried. It counts them.
2026-10-09 21:51:45 -07:00
branderson 76a5c5202e Retry the lookup failure the retry was written for
resolve_one returned 0 when dig itself failed, so retry_positive saw an answer
on the first attempt, printed no retry line, and the caller died on an empty
result. A resolver a moment behind the container is exactly that case - dig
fails rather than answering empty - so the transient the retry exists to absorb
was the one it did not cover (#345).

It returns non-zero for both ways of having no address now. Negative checks
read it the same way either way: they want non-zero, and a lookup that did not
come back is not a name that resolved.

The test guarding this was satisfied by the `return 1` already in that function
for the empty-answer case, so it passed while the property was false. It drives
resolve_one against a stubbed dig instead - failing, empty, and answering - and
the third case matters as much as the first two: a lookup that reported failure
on a good answer would spin the retry on every name and fail the boot on all of
them.
2026-10-09 21:51:45 -07:00
branderson 60a319b3ba Merge pull request 'Give the resolver checks a permitted name when the meta URL has no host' (#386) from egress-subject into development
Reviewed-on: #386
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 21:49:44 -07:00
branderson c0bc131fd4 Read the workflow for the sh -n sweep without PyYAML
The sweep added in #382 parsed .gitea/workflows/tests.yml with PyYAML,
which is not in the test image: in CI the test died with
ModuleNotFoundError having checked nothing, and failed confined (static).

It is an indentation scan over the two shapes the file uses now, with
nothing imported from outside the standard library. Bodies come from the
file as written rather than from the copy keys are normalised in, so a
shell line beginning with a dash is not rewritten on its way to sh -n,
and a job setting defaults: is refused rather than guessed at.

A hand-rolled scan that meets a shape it was not written for finds
nothing and reports no failures, so the count it read is checked against
a plain grep of the run: keys in the file.
2026-10-09 21:43:48 -07:00
branderson 00a2df3b0c Give the resolver checks a permitted name when the meta URL has no host
tests / confined (audit) (pull_request_target) Successful in 3m55s
tests / confined (config) (pull_request_target) Successful in 5m21s
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / confined (launch) (pull_request_target) Successful in 2m32s
tests / confined (project) (pull_request_target) Successful in 3m37s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Failing after 2m45s
tests / privileged (pull_request_target) Successful in 7m53s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
The cached-ranges fixture points EGRESS_FORGE_META_URL at a file:// path, and a
file URL has no host - so checks 3 and 4, which resolve "the meta URL's host",
were handed an empty string. Check 3 asked the resolver to look up "" and
failed the container's boot on it, which took down every privileged run as soon
as the cache started working.

The property those checks state is about the policy: that a permitted name
resolves through the local resolver, and that its address lands in that name's
own set. The meta URL's host was an instance of such a name, not the point of
it - so the subject falls back to a permitted forge-class name, which keeps
checks 3, 4 and 6 doing exactly what they claim. Forge-class specifically,
because check 4 looks the answer up in the forge class's own set and check 6
asserts port 22 is refused on it.

Nothing at all to check is a refusal naming that, rather than a lookup of "".

Check 5 is skipped for a file source, and says so in the log. It asserts that a
permitted destination is reachable THROUGH the policy, and a local file is not
one: reading it again would pass while proving nothing. That is a real
reduction on the runs that use the cache, so it is a line an operator can see
rather than a silent pass - restoring it means choosing a permitted destination
cheap enough to request once per container, which is a separate decision.
2026-10-09 20:54:18 -07:00
branderson e7769bb3d6 Merge pull request 'Write the ranges-cache step in the shell the runner actually gives it' (#382) from ci-sh-fix into development
Reviewed-on: #382
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 18:25:43 -07:00
branderson e346ad3ae2 Write the ranges-cache step in the shell the runner actually gives it
tests / privileged (pull_request_target) Failing after 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 2m6s
tests / confined (providers) (pull_request_target) Successful in 3m7s
tests / confined (launch) (pull_request_target) Successful in 3m29s
tests / confined (share) (pull_request_target) Successful in 2m43s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 4m16s
tests / confined (project) (pull_request_target) Successful in 4m47s
tests / confined (audit) (pull_request_target) Successful in 5m11s
tests / confined (static) (pull_request_target) Failing after 4m12s
tests / confined (config) (pull_request_target) Successful in 6m34s
The step used a bash array. The runner's default shell is `sh -e`, where that
is a syntax error - so the whole step died before any of it ran, and the
privileged job failed on every pull request from the moment #378 merged.

Rewritten as POSIX sh: two curl branches rather than one with a built-up
argument list, and the ranges read once into a variable so the success test and
the count are the same answer. Driven under real dash in all four paths - a
good fetch, a failed fetch, a 200 carrying a rate-limit message instead of
ranges, and no token - with the cache written only in the first.

The sweep that would have caught it goes in beside it: every run: block in the
workflow that does not name its own shell has to parse under `sh -n`. A YAML
parse says only that the string is a string, and nothing local runs the
workflow, so this reached CI green from a machine where the gate passed.
2026-10-09 17:44:17 -07:00
branderson 3f5dae88b5 Merge pull request 'Fetch the forge ranges once per run, and wait out a rate limit when one happens' (#378) from meta-cache into development
Reviewed-on: #378
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 14:15:22 -07:00
branderson 5569e8d2fe Put these tests beside their relatives, not at the end of the file
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 1m58s
tests / confined (providers) (pull_request_target) Successful in 2m45s
tests / confined (launch) (pull_request_target) Successful in 3m7s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 4m6s
tests / confined (audit) (pull_request_target) Successful in 4m28s
tests / confined (static) (pull_request_target) Successful in 3m34s
tests / confined (config) (pull_request_target) Successful in 5m51s
tests / privileged (pull_request_target) Successful in 12m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
Appending to test/static.bats is why four branches in a row have had to resolve
a conflict that was only ever about position. The fetch tests go with the rest
of the fetch tests, and the one that reads the workflow file goes next to the
other one that reads it - which is a better home for both and one nothing else
is competing for.
2026-10-09 13:38:36 -07:00
branderson e900c51e20 Say in docs/ci.md why the ranges are fetched once per run
The secondary rate limit is the part that surprises: it is not the hourly
ceiling a token raises, so a valid credential does not prevent it, and the
failure arrives as one container failing to boot rather than as anything about
the API. It sits beside the note on the token, which is where a reader meets
the endpoint.
2026-10-09 13:37:29 -07:00
branderson e91b5771f6 Fetch the forge ranges once per CI run instead of once per container
test/runtime.bats starts twenty-seven container pairs, and every filter fetched
GitHub's /meta for itself - roughly twenty-seven requests in five minutes. That
is well inside the hourly ceiling a token buys, and exactly the burst the
endpoint's SECONDARY limit answers with a 403, which takes one container's boot
down and the job with it (#357).

The privileged job now fetches once into the directory it already shares with
the containers it starts, and runtime.bats points every filter at the result.
One request cannot be a burst.

Best-effort on both sides. The job writes the file 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 fail every container instead of one; and the suite falls
back to per-container fetches when the file is absent, which is what happens
outside CI. The worst case is the behaviour this replaces, and each side says
which of the two the run is getting.

egress-policy refuses a file:// source unless SANDBOX_TEST_MODE is set. Those
ranges decide which addresses the forge class reaches, so a launch that could
name its own source could name its own ranges - and that variable is set by
this suite and by nothing in bin/dejima. The mount is read-only and goes to the
filter alone; the session half has no business holding a path that names them.
2026-10-09 13:37:29 -07:00
branderson 3b17604178 Wait as long as a rate limit asks, and say which failure it was
Five attempts over twenty seconds is short for a limit measured in tens of
seconds: the whole sequence could sit inside one window and exhaust itself
without ever reaching the other side (#357).

A rate limit is now recognised from the status - 403 or 429 - or from the body,
because the endpoint will report one with a message and no ranges over a 200.
Those back off at attempt*15 rather than attempt*2, so three attempts cover 45
seconds instead of six. A connection failure keeps the short backoff, because no
wait fixes it.

Retry-After beats both, being the only number here that is not an estimate, and
only in its numeric form: the header may carry an HTTP date instead, and parsing
one to compare against a clock is a wrong answer away from a sleep nobody chose.
A cap stops a value nobody expected turning a boot into a hang.

And the message names the failure. An HTTP error from this endpoint carries a
body saying why - "API rate limit exceeded" rather than "curl exit 0, http 403"
- which was being discarded in favour of curl's stderr, where there is nothing.
curl's own message is what is left when no response carried a body.
2026-10-09 13:37:29 -07:00
branderson 6511a0537c Merge pull request 'Ctrl-Z does not pause the agent, and eleven other documented facts that had drifted' (#377) from review-docs into development
Reviewed-on: #377
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 13:34:20 -07:00
branderson 023efb3171 Merge pull request 'The suite map's blind spot, a test that only skipped, and three cleanups' (#376) from review-tests into development
Reviewed-on: #376
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 17:01:28 -07:00
branderson 23462a3c71 Merge pull request 'The host ssh-agent, a half-landed permit, and four things around them' (#375) from review-sec into development
Reviewed-on: #375
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 16:54:42 -07:00
branderson 2ddcf964c3 Start the log shipper again, and catch the shape that stopped it
tests / confined (audit) (pull_request_target) Successful in 3m57s
tests / confined (config) (pull_request_target) Successful in 5m20s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / runner-env (confined) (pull_request_target) Successful in 1m6s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / privileged (pull_request_target) Successful in 6m46s
`env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH drop_caps ...` cannot run: drop_caps
is a shell function from caps-lib.sh and env can only exec a binary, so the line
was exit 127. The `&` kept that from set -e, so the filter container came up
reporting ready with no shipper and Loki logging went dead without a word.

The prefix is gone. What covers the shipper is order, which is what the test now
asserts: both names are out of this script's environment before the line that
starts it, the raw token before the policy run and the header straight after it.

And the error class has its own test, because the one I wrote only grepped the
start line for the prefix's text - it would have passed on a line that could not
execute. It flattens continuations first, since the prefix was written across two
lines and a per-line grep did not see it, and drops comments after that, or the
line explaining why there is no prefix matches itself. Both spellings verified
to fail it.

runtime.bats already asserts the shipper is running when LOKI_URL is set, which
is the check that would have caught this in the privileged job.
2026-10-08 00:24:05 -07:00
branderson faeafa7f2d Merge pull request 'Three forms that walked past the push and api guards' (#374) from guard-boundary into development
Reviewed-on: #374
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 00:18:26 -07:00
branderson 2cc6057c88 Reflow the two paragraphs this branch left long
tests / confined (audit) (pull_request_target) Successful in 4m53s
tests / confined (config) (pull_request_target) Successful in 6m29s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (project) (pull_request_target) Successful in 4m1s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (proxy) (pull_request_target) Successful in 1m35s
tests / confined (remote) (pull_request_target) Successful in 3m0s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / runner-env (confined) (pull_request_target) Successful in 1m1s
tests / runner-env (privileged) (pull_request_target) Successful in 59s
tests / privileged (pull_request_target) Successful in 6m47s
Both were edited in place without rewrapping, so a line in each ran past the
column the rest of the file keeps to. The link stays on one line rather than
being wrapped mid-label.
2026-10-07 23:15:05 -07:00
branderson aa40e0c455 State what the spellings have in common, not what one used to do
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m37s
tests / confined (project) (pull_request_target) Successful in 3m44s
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (static) (pull_request_target) Successful in 4m49s
tests / privileged (pull_request_target) Successful in 6m18s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (config) (pull_request_target) Successful in 9m33s
The comment above the refused forms explained the `+` case by what the guard
used to compare. What a reader needs is why every form in the list names the
same branch, which is the two rules the loop exists to cover.
2026-10-07 23:11:57 -07:00
branderson d180581293 Keep the credential from the one daemon the policy does not start
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Failing after 42s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (audit) (pull_request_target) Successful in 4m46s
tests / confined (remote) (pull_request_target) Successful in 3m14s
tests / confined (project) (pull_request_target) Successful in 3m58s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 4m50s
tests / confined (config) (pull_request_target) Successful in 9m50s
tests / privileged (pull_request_target) Failing after 6m37s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
log-shipper.sh is started by firewall-up.sh rather than by egress-policy, so
NO_CREDENTIAL never reached it. The unset above it took the raw token away and
left EGRESS_FORGE_META_AUTH exported - and that header carries the same token
in another wrapper, so the shipper held the credential after all.

It now starts under the same `env -u`, and the header is dropped as soon as the
policy run that needed it returns. The raw token still goes before that run,
which is why the test asserts one on each side of it.
2026-10-07 23:11:27 -07:00
branderson f61795381d Stop the README's Ctrl-Z line contradicting the sentence after it
tests / confined (audit) (pull_request_target) Successful in 4m48s
tests / confined (config) (pull_request_target) Successful in 8m56s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / confined (project) (pull_request_target) Successful in 4m0s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (remote) (pull_request_target) Successful in 2m58s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / privileged (pull_request_target) Successful in 6m31s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / confined (static) (pull_request_target) Successful in 2m39s
"Backgrounds all of dejima" and "the container keeps running" cannot both be
read as written. The host side is what backgrounds, which is also the
distinction the paragraph exists to draw.

And docs/configuration.md said "this script", which was lifted from
dejima-tty-suspend's own header and has no referent in a document.
2026-10-07 20:45:06 -07:00
branderson 452d49394f State the --repo rule as a property rather than as a correction
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (config) (pull_request_target) Successful in 9m24s
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (launch) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (proxy) (pull_request_target) Successful in 1m28s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (static) (pull_request_target) Successful in 4m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / privileged (pull_request_target) Successful in 6m44s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
tests / confined (remote) (pull_request_target) Successful in 1m46s
The comment explained git's precedence and then described the reading that had
it backwards. A reader debugging this line needs the rule and what it decides
about an invocation, which is what it says now.
2026-10-07 20:43:00 -07:00
branderson 1cda465898 Read a destination the way git resolves it, not only its full spelling
A refspec destination that does not start with refs/ is matched against the
remote's refs by git's rev-parse rules, so `heads/main` and `main` both name
refs/heads/main. Only the full prefix was stripped, which left
`feature:heads/main` naming a branch no policy entry matched - and
`+feature:heads/main` with it.

Both prefixes come off now, in that order. Every strip has to leave an
unprotected destination alone, which the test asserts for each of them: a
stripping that refuses is not a policy that refuses.
2026-10-07 20:42:59 -07:00
branderson cacb74fb28 Refuse a graphql call whose body is not in the command
--input takes the whole request body from a file, or from stdin with `-`, so
the document is not in argv at any spelling: not as a field value the mutation
check can read, and not in the `=@` shape the file-indirection check catches.
The positional graphql beside it still counted as a readable target, which made
this the most direct spelling left on the path #365 closed for inline bodies.

Refused under the declared subcommand only, in both the separate-value and
attached forms. Outside it, --input is a flag this has nothing to say about,
which the test asserts - otherwise the refusal is about the word rather than
about the body.
2026-10-07 20:42:42 -07:00
branderson 4a9e789ba1 Drop a test call that asserted nothing, and two comments that narrate history
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (audit) (pull_request_target) Successful in 4m41s
tests / confined (project) (pull_request_target) Successful in 3m54s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m3s
tests / confined (remote) (pull_request_target) Successful in 3m1s
tests / confined (static) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 9m36s
tests / privileged (pull_request_target) Successful in 6m32s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
The empty-key test ran a launch with no key and never looked at the result; the
direction it was standing in for is its own test above it.

permit_address's comment said what the code used to leave standing. It now
states the property - the two adds are not one transaction - and says why the
unconditional delete is the safe direction when the element came from
somewhere else: the record has already rolled back, so a narrower live set
agrees with what `entries` reports.
2026-10-07 20:39:33 -07:00
branderson 9b96d5781b Document that a configured key withdraws the forwarded agent
docs/configuration.md said a key means a forwarded agent "is no longer required
at all", which is now an understatement: it is not mounted. An operator who
also relied on the agent inside the session - a second remote, a private
submodule over ssh - loses it, and that belongs where they choose the key
rather than in a commit message.

docs/security.md says the same in the two places it describes the key as an
alternative to the agent, because "alternative" now means the other one is
closed.
2026-10-07 20:39:33 -07:00
branderson bac3d45814 Scrub the credential where each daemon starts, not upstream of one call path
The unset in firewall-up.sh only covered `apply`. bin/dejima starts the filter
container with -e GH_TOKEN, so the token is in that container's configured
environment - and `dejima-allow` reaches the policy through `docker exec`,
which gets that environment. permit_name and rollback_entries both restart the
resolver, so the first approval of a name put the credential back into dnsmasq.
The ordering test could not see it, because it only read the apply path.

All three daemon starts now carry `env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH`,
so the property holds wherever they were reached from and is visible on the
line that starts them. The post-fetch unset stays, but is no longer the only
thing standing there.

The test asserts the start lines rather than their order, which is what makes
it independent of the call path, and counts them so a daemon dropped from the
sweep is not a daemon that passed it.
2026-10-07 20:39:33 -07:00
branderson fa29e2ac00 Correct eleven documented facts that had drifted from the code
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (static) (pull_request_target) Successful in 3m31s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (project) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 3m53s
tests / confined (config) (pull_request_target) Successful in 5m13s
tests / privileged (pull_request_target) Successful in 6m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
Each was verified against what ships rather than taken from the report:

docs/development.md said a harness manifest declares eleven keys; the schema
has eighteen. The count is gone rather than corrected, since nothing checks it
and it was wrong within a release. The same file's no-Docker suite list omitted
launch.bats and share.bats and put ci-runner.bats among the suites that run
anywhere, when it needs a real runner of either tier; the list now says
run-confined.sh discovers the set, which is why one is not kept there. It also
listed bin/dejima-color-lib.sh twice, with two different descriptions.

docs/configuration.md described the push guard as refusing a remote that is not
the Gitea host. It reads DEJIMA_PUSH_HOSTS and DEJIMA_BLOCKED_BRANCHES, both
resolved from the selected forge and workflow, so it is the same rule whichever
forge is in use.

docs/ci.md said none of the runner posture is checked by anything.
ci-runner.bats checks what a job can see from where it stands - which tier it
landed on, the docker socket in one tier and not the other, the egress rules in
front of the confined job. What it cannot see is the runner's own configuration
file, and that is now what the bullet says.

docs/security.md cited a README section that no longer exists for the forge
token's scopes, and pointed Loki readers at the README's Prerequisites, which
say nothing about logging.

install.sh wrote a config header describing the every-key-commented format it
retired - twelve lines below a comment explaining that defaults.conf is the
complete reference now and this file holds only what the operator decided.

README.md omitted jq from its prerequisites, which install.sh hard-requires and
exits over.

docs/remote-access.md named /run/dejima-notify as the marker root, while its
own diagram twenty lines up has it right: ${XDG_RUNTIME_DIR}/dejima/notify. It
also attributed a "scriptable" claim to the README; it lives in
dejima-attach's own help.

The audit's time-window caveat was stated twice in consecutive sentences in the
README; the one that stays is the one static.bats pins.

The report also called the README's GitHub-push rationale a duplicate of
docs/security.md's, under the "one place" rule. It is not, and the test named
for it says why: the README has to name both properties the port-22 claim rests
on, because the claim stays readable only if a reader can see what would have
to stop being true. Left as it was.
2026-10-07 19:36:31 -07:00
branderson b3f8252c73 Stop two documents saying Ctrl-Z pauses the agent
bin/dejima-tty-suspend's own header says what happens: everything host-side is
in one process group - the script, the container run, the credential proxy, the
conversation shipper - so the kernel stops and continues all of it together.
The container is not in that group. It keeps running, with its mounts, its
firewall grants and its forge token.

docs/security.md says so. The README said "it makes no progress while
suspended" and docs/configuration.md said "a suspended session is stopped, so
it makes no progress until you resume it" - and the operator most likely to
reach for Ctrl-Z is one uneasy about what the agent is doing, which is the
reader those two sentences were worst for.
2026-10-07 19:21:01 -07:00
branderson c8fd2dc605 Treat an empty ssh_key file as no key, rather than refusing it
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m56s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / privileged (pull_request_target) Successful in 6m42s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
Refusing it outright was the first shape of the commit above and it broke most
of the suite: a great many fixtures use an empty file as a stand-in for a path,
which is a configuration dejima used to wave through.

An empty file is not a key - it loads nothing in the container - so it cannot be
what withdraws the forwarded agent either. It now counts as no key at all, which
costs nothing for a workflow that never pushes and lands in the refusal that
already exists for having neither, with the file named.

The fixtures write a byte into those files, because what they were describing
is a path to a key and an empty one no longer is.
2026-10-07 18:44:40 -07:00
branderson ba2158e0e8 Say what the gitea CLIs confine, and what the push hook does not see
"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" overstates it by one
step. origin lives in .git/config, inside the read-write project mount, and the
gitea token is account-wide - so a session that rewrites origin reaches another
repository on the same host. What bounds that is the token's scope, as it is
for gh.

And the push hook's comment claimed git's own options were the only thing a
spelling could vary. A segment's first word still has to be git, so a
`FOO=bar git push` prefix is a push this does not see - the PATH shadow does,
which is the division of labour the pair exists for rather than a gap in it.
2026-10-07 18:44:33 -07:00
branderson 51c0a8bc65 Render the briefing as the session user, and test the pty tty(1) reports on
The renderer reads the project's own .dejima.md and asks the package managers
what is installed, with its cwd inside the workspace a session can write.
Nothing about that needs root - and root got the answer wrong too: the
dependency layers install npm and cargo packages as the sandbox user, into that
user's HOME, so root's cargo home held none of them and every declared cargo
dependency was reported missing. hooks/sandbox-context.sh already reaches the
renderer as that user, since a hook runs inside the session; this is the other
route agreeing with it. The file it writes stays root-owned and read-only,
which is the half that must not be deprivileged.

The pty chown guarded on stdout and then asked `tty`, which reports on stdin.
With one attached and the other not, the path came back empty and the chown
silently did nothing - the nvim clipboard fallback failing with nothing to
point at.
2026-10-07 18:05:24 -07:00
branderson d418de83df Scrub the forge credential before the daemons that inherit it start
The published-ranges fetch is the only thing in the firewall path that needs
GH_TOKEN, and the header derived from it. Everything started after it - the
restricted resolver, the two log readers, the shipper - parses content the
session's own traffic decides, and inherits the environment of the process that
started it.

Both are unset as soon as the request that uses them is made. The test asserts
the ORDER rather than the presence of the unset, because an unset after the
daemons start is the same as none at all - and it also pins that the header is
still there for the fetch, so the scrub cannot pass by breaking the request.
2026-10-07 18:02:43 -07:00
branderson 225abccece Take back the any-port element when a forge-class permit half-lands
permit_address puts a forge-class address in literal_v4 and then in forge_v4.
If the second add failed, the entries file rolled back and the command died -
but the first element stayed, so the address the forge class says is HTTPS-only
was permitted on every port for the rest of the session, and neither `entries`
nor `status` showed it, because the record of it had been undone.

The element goes back too. If that removal itself fails there is nothing left to
try, so it says so and names the restart rather than reporting a narrowing that
did not happen.

Driven with a stub nft, since the real one needs a namespace to act on: the
half-landed case, the whole-success case which must undo nothing, and the
open-class case where there is no second set and so nothing to take back.
2026-10-07 17:58:32 -07:00
branderson 43081852ea Unmount the operator's ssh-agent when a dedicated key is configured
The forwarded agent was mounted whenever the workflow pushes, and entrypoint.sh
merely re-pointed SSH_AUTH_SOCK at a fresh agent holding the sandbox key. The
host agent stayed connectable at the path docker-compose.yml names, so
`SSH_AUTH_SOCK=/ssh-agent ssh ...` signed with the operator's personal
identities for the whole session.

Choosing a dedicated key has to mean the personal agent is not in the container,
not that it is merely not the default. bin/dejima now blanks SSH_AUTH_SOCK when
a key is configured, the same way it does for a workflow that never pushes, so
${SSH_AUTH_SOCK:-/dev/null} mounts nothing.

An empty key file is refused rather than accepted: it passes the is-a-file check,
loads nothing in the container, and is now what stops the agent being mounted -
the three together would be a pushing session with no credential at all.

Both the key spelling and [identity.default] ssh_key are covered, since
config_get resolves the identity's value for that key.
2026-10-07 17:57:28 -07:00
branderson 4adfe9b36c Read a GraphQL document that arrives attached to its flag
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m15s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m7s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m58s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m19s
tests / privileged (pull_request_target) Successful in 6m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
The mutation check and the file-indirection check ran after the loop had
skipped every dash-prefixed argument, so a flag carrying its value attached put
the document somewhere neither looked: `--field=query=mutation{...}` and
`-Fquery=mutation{...}` passed, while the positional `graphql` beside them
still counted as a readable target. With the github forge the push token is
write-capable, so that is the escalation the inline spelling was closed against.

Both checks now run over every argument. Neither needs to know which of the
CLI's flags take a separate value, which is what lets them look at all of them
- unlike the repository scoping below, which does, and which still sees only
the positionals.
2026-10-07 17:23:36 -07:00
branderson 12a222cace Let a positional remote beat --repo, the way git documents it
git: "this option is equivalent to the <repository> argument. If both are
specified, the command-line argument takes precedence." This read it the other
way round, so `git push --repo=origin other-remote feature` resolved and
approved origin's URL, treated other-remote as a refspec, and let git push to a
host nothing had checked against DEJIMA_PUSH_HOSTS.

The library's own header says a disagreement between this parser and git's has
to fail as a refusal. This one failed as an unchecked push - the test for it
shows ssh reaching the unvetted host when the precedence is put back.

`git push --repo=origin main` is a push to a remote called "main" under that
rule, which is how git reads it too, so it is now refused as an unresolvable
remote rather than by the branch policy.
2026-10-07 17:21:33 -07:00
branderson af0fce3326 Read the force marker as part of the refspec, not of the branch
`git push origin +main` put the destination through the branch policy as
"+main", which matched no entry, so the one spelling that escaped was the
destructive one. `+refs/heads/main` escaped the same way, since the prefix also
stopped the refs/heads/ strip from landing.

One leading `+` now comes off before the destination is read. A `+` on a branch
nothing protects is still an ordinary push, and the test says so - otherwise the
strip could be doing the refusing rather than the policy.
2026-10-07 17:19:20 -07:00
branderson 9c1735de00 Merge pull request 'Release: the egress filter and the container image, both rewritten clean-room' (#347) from development into main
Reviewed-on: #347
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 02:15:55 -07:00
branderson 16025633a1 Merge pull request 'Release: the egress filter in a container the session cannot reach' (#337) from development into main
Reviewed-on: #337
Reviewed-by: branderson <brad@branderson.io>
2026-09-11 15:36:37 -07:00
branderson 1005257774 Merge pull request 'Release: the harness and backend axes, and a credential proxy that is neither's' (#321) from release-pass-base into main
Reviewed-on: #321
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 18:19:28 -07:00
branderson 110925c44a Merge pull request 'Release: a second harness, identities, and a capability floor' (#259) from release-2026-08-27 into main
tests / confined (providers) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 49s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (audit) (pull_request_target) Successful in 3m7s
tests / confined (conversation) (pull_request_target) Successful in 50s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / privileged (pull_request_target) Successful in 3m30s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (share) (pull_request_target) Failing after 40s
tests / confined (static) (pull_request_target) Successful in 1m38s
Reviewed-on: #259
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 17:24:04 -07:00
branderson c7797d05ac Merge pull request 'Release: harness axis, Cline, forge parity, the web toolbar, and a release pass' (#207) from release-2026-08-24 into main
Reviewed-on: #207
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 08:16:05 -07:00
branderson e04131064c Merge development into main for release
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / confined (providers) (pull_request_target) Successful in 2m24s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 3m55s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 4m46s
tests / confined (project) (pull_request_target) Successful in 5m25s
tests / confined (config) (pull_request_target) Successful in 7m13s
tests / confined (audit) (pull_request_target) Successful in 9m53s
Made on a branch off main rather than by merging main into development
first. The reconciliation the cherry-pick in #173 made necessary has to
happen somewhere, and doing it here means it happens once, in a pull
request with a diff someone can read - the other order produces a merge
with no file changes at all, which is a confusing thing to ask anyone to
approve.

Three files conflicted, all of them main's cherry-picked state against
what development made of it. Resolved to development in each case: the
harness hook list is a strict superset, the entrypoint's seed loop is the
same loop relocated, and the guard descriptions were reworded when the
PATH shadows stopped depending on the harness.

The merged tree is byte-identical to development's, which is the property
a release should have and the one worth checking rather than assuming.
2026-08-24 03:43:55 -07:00
branderson c57c6ba778 Merge pull request 'Bring the plugin fix and the launch optimisation to main, without Cline' (#173) from main-without-cline into main
Reviewed-on: #173
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 01:44:54 -07:00
branderson 1e6711d1b2 Carry the two static-test helpers the plugin change depends on
tests / confined (conversation) (pull_request_target) Successful in 58s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / confined (remote) (pull_request_target) Successful in 3m31s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m48s
tests / confined (audit) (pull_request_target) Successful in 8m22s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 53s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / privileged (pull_request_target) Successful in 3m51s
tests / confined (config) (pull_request_target) Successful in 6m7s
Both came from the Cline branch, which is not on this branch, and the plugin
change was written against them.

lib() runs a function from the provider library in a subshell, the way
providers.bats does. Its absence here is not only a missing convenience: the
test added with the briefing work already calls it, so `lib manifest_get
harness claude-code briefing_args` ran as a command that does not exist, `run`
captured the 127 and its message as output, and the assertion that the output
is non-empty passed on that. The test has been asserting nothing on this
branch since it landed.

The other is the rule that a plugin function whose name starts with an
underscore is the plugin's own helper rather than a misspelled hook. The
plugin change adds _plugin_version_mounts, which without this reads as a hook
core never calls.

Both are byte-identical to the versions on development, so the merge that
eventually brings Cline across sees the same content on both sides rather
than two spellings of it.
2026-08-22 01:23:36 -07:00
branderson 8d61332742 Let a session update a plugin, by seeding the tree instead of mounting it
Cherry-picked from #172, which landed on development after the Cline harness.
Cline is deliberately not carried across with it: it has not been exercised
beyond its own tests yet, and holding it back is the point of taking this
route rather than merging development whole.

Three hunks needed adapting, all of them places where the Cline branch had
added something adjacent: the harness hook list gains harness_seed without
Cline's two, docker-compose passes SANDBOX_HARNESS_SEED without the
briefing_path variable that only Cline uses, and entrypoint.sh keeps the
argument-only briefing block this branch already had rather than the
two-route version. Nothing else differed.

The change itself is unmodified. ~/.claude/plugins was mounted read-only,
which meant Claude Code could neither materialise a plugin update nor record
it, so the plugin was left at a version the catalogue no longer matched -
presenting as a plugin that would not load, fixable only by running Claude
Code on the host and restarting. What the harness rewrites is now seeded as a
writable copy it owns, and each already-installed version stays a read-only
mount of the host's.
2026-08-22 01:23:36 -07:00
branderson d356191859 Merge commit 'efc5d63' into main-without-cline 2026-08-22 01:09:15 -07:00
branderson 86e1a5be71 Merge pull request 'Release: the harness-decoupling programme, and a briefing that actually arrives' (#168) from development into main
Reviewed-on: #168
Reviewed-by: branderson <brad@branderson.io>
2026-08-21 20:12:04 -07:00
branderson fce3dbc2ac Merge pull request 'Release: epitaph, the threat model, and the development branch process' (#132) from development into main
Reviewed-on: #132
Reviewed-by: branderson <brad@branderson.io>
2026-08-13 17:53:32 -07:00
65 changed files with 7636 additions and 509 deletions
+58 -1
View File
@@ -89,6 +89,7 @@ jobs:
- providers
- conversation
- share
- mirror
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
@@ -171,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,
@@ -432,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 }}
+237
View File
@@ -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.
+3
View File
@@ -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
+39 -14
View File
@@ -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 &nbsp; 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 |
+4
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+221 -103
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+27
View File
@@ -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 = []
+8
View File
@@ -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
View File
@@ -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"
}
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+49
View File
@@ -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%%+*}"
}
+15 -9
View File
@@ -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
View File
@@ -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"
}
+8
View File
@@ -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
+37 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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 &
+67 -20
View File
@@ -78,7 +78,7 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
local repo_dir="$1"; shift
local argv=("$@") i=0 subcommand="" branch
local -a config_overrides=() refspecs=()
local remote="" push_all=0 push_mirror=0 repo_override=0
local remote="" repo_flag="" push_all=0 push_mirror=0 repo_override=0
DEJIMA_GUARD_REASON=""
DEJIMA_GUARD_PUSH=0
@@ -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))
;;
@@ -125,11 +139,15 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
fi
# What comes after `push`. --repo names the remote in git's own legacy form
# and wins over a positional, so it is read rather than treated as an
# opaque flag: skipping it would leave `git push --repo=origin main`
# looking like a push to a remote called "main", which resolves to no URL -
# and passing that through unchecked is a push to origin/main that nothing
# looked at.
# and is read rather than treated as an opaque flag, because skipping it
# would leave `git push --repo=origin main` looking like a bare push and
# resolve a remote the command never named.
#
# It LOSES to a positional, which is what git documents: "this option is
# equivalent to the <repository> argument. If both are specified, the
# command-line argument takes precedence." So in `git push --repo=origin
# other-remote feature` the remote is other-remote, and checking origin
# instead would approve a host this push never goes to.
#
# Every positional after the remote is a refspec, not just the first.
# `git push origin feature main` checked `feature` alone and let `main`
@@ -137,10 +155,10 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
i=$((i + 1))
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
--repo=*) remote="${argv[$i]#--repo=}" ;;
--repo=*) repo_flag="${argv[$i]#--repo=}" ;;
--repo)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && remote="${argv[$i]}"
[ "$i" -lt "${#argv[@]}" ] && repo_flag="${argv[$i]}"
;;
--all) push_all=1 ;;
--mirror) push_mirror=1 ;;
@@ -158,6 +176,7 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
esac
i=$((i + 1))
done
[ -n "$remote" ] || remote="$repo_flag"
[ -n "$remote" ] || remote=origin
# --push, because that is the URL the push will use. Without it this reads
@@ -227,12 +246,22 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
fi
local refspec
for refspec in "${refspecs[@]}"; do
# One leading `+` is the force marker and belongs to the refspec rather
# than to the branch, so it comes off before the destination is read -
# `git push origin +main` names main, and the force spelling is the
# destructive one to be wrong about.
refspec="${refspec#+}"
case "$refspec" in
*:*) branch="${refspec##*:}" ;;
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
*) branch="$refspec" ;;
esac
branch="${branch#refs/heads/}"
# A destination that does not start with refs/ is matched against the
# remote's refs by git's rev-parse rules, so `heads/main` and `main`
# both name refs/heads/main. Stripping only the full prefix left
# `feature:heads/main` naming a branch no policy entry matched.
branch="${branch#refs/}"
branch="${branch#heads/}"
if _dejima_branch_blocked "$branch"; then
DEJIMA_GUARD_REASON="direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
return 1
@@ -353,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).
#
@@ -469,13 +500,19 @@ _dejima_forge_path_verdict() {
segment="${spec#*:}"
scopable=0
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
# A GraphQL document whose operation is a mutation. Matched
# anywhere in the argument, because the document arrives as the
# value of a field flag - `-f query=mutation{...}` - rather than as
# an argument of its own. Matched before the brace test below,
# which a document would otherwise satisfy.
#
# Over EVERY argument, including the dash-prefixed ones, because a
# flag can carry its value attached: `--field=query=mutation{...}`
# and `-Fquery=mutation{...}` put the document inside one word that
# the scoping loop below skips. This check needs no flag table to
# read it, which is what lets it look at all of them - unlike the
# repository scoping, which does.
#
# Both boundaries are anything that cannot continue an
# identifier, rather than whitespace and a brace. GraphQL treats
# commas and comments as ignored tokens, so `mutation,{...}` and
@@ -488,22 +525,32 @@ _dejima_forge_path_verdict() {
DEJIMA_GUARD_REASON="a GraphQL mutation reaches any repository and any write the token allows, and no repository scoping can be read out of it. Use the subcommands, and leave a merge to the operator."
return 1
fi
# A field value read from a file keeps the document out of argv
# entirely, so there is nothing here to tell a query from a
# mutation. Refused under this subcommand only; the document is one
# argument, so passing it inline is the fix.
# A body this cannot read is a body it cannot tell a query from a
# mutation in. Two spellings put one out of reach: a field value
# read from a file, and --input, which takes the whole request body
# from a file or from stdin. Refused under this subcommand only;
# the document is one argument, so passing it inline is the fix.
case "$arg" in
*=@*)
DEJIMA_GUARD_REASON="'$1' reads a field value from a file, so the document it sends is not in the command for this to read. Pass it inline."
*=@*|--input|--input=*)
DEJIMA_GUARD_REASON="'$1' takes its body from a file or from stdin, so what it sends is not in the command for this to read. Pass the document inline."
return 1
;;
esac
# Everything from here needs to know which argument is the
# endpoint, so the flags go.
case "$arg" in -*) continue ;; esac
[ "$arg" = graphql ] && { scopable=1; continue; }
path="${arg#/}"
# gh substitutes {owner} and {repo} from the checkout, so a path
# carrying them names this repository by construction and comparing
# the literal braces would refuse it.
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`:
+77
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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"
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+50
View File
@@ -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
+9
View File
@@ -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
+282
View File
@@ -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
+385 -20
View File
@@ -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,
+9 -3
View File
@@ -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():
+43
View File
@@ -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()
+11 -3
View File
@@ -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
View File
@@ -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)"
}
+245
View File
@@ -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
View File
@@ -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"
+39
View File
@@ -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.
+715 -4
View File
@@ -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"
}
+14 -3
View File
@@ -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) \
@@ -78,6 +78,7 @@ suites_for() {
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 -
@@ -105,13 +106,14 @@ suites_for() {
# 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.
# 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' ;;
docs/*|README.md|*.md) echo 'static config providers remote mirror' ;;
*) echo ALL ;;
esac
@@ -247,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' \
+94
View File
@@ -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
+36
View File
@@ -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
+1
View File
@@ -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
+1442 -36
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+115
View File
@@ -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))
};
}
+94
View File
@@ -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);
+81
View File
@@ -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);
});
-4
View File
@@ -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,
-4
View File
@@ -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,