Compare commits

...
Author SHA1 Message Date
branderson 37cda6ce5c Clear the same variables in providers.bats
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 49s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / privileged (pull_request_target) Successful in 3m46s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (static) (pull_request_target) Successful in 4m50s
tests / confined (project) (pull_request_target) Successful in 5m2s
tests / confined (config) (pull_request_target) Successful in 6m41s
tests / confined (audit) (pull_request_target) Successful in 8m40s
Same leak, second suite. One of these failed inside a sandbox and the
one after it passed for the wrong reason: its assertion was satisfied by
the session id the stub context carries anyway, so it would have passed
without the briefing file it exists to test being read at all.
2026-08-23 03:09:19 -07:00
branderson 75519c886e Make the release pass a thing that is run, not a thing that is remembered
Moving development to main is where the checks no test performs are the
only checks left, and until now they lived in whoever happened to
remember them. docs/release.md writes them down and says why each one is
not a test; test/release-pass.sh runs what can be run and prints the
rest, chosen from what the release actually changes.

It reports a gate it could not run as not run, rather than as passing.
That distinction is the whole point of the script: the privileged suite
queues silently when its runner is down, and shellcheck was missing from
this image entirely, and both of those look like green from a distance.

shellcheck is declared in .dejima/dependencies now. CI installs its own,
so a sandbox was the one place the gate could not be run at all - and it
had been passing here only because a base image happened to carry it,
which stopped being true.
2026-08-23 02:58:14 -07:00
branderson 3801d3d359 State what the sandbox-context tests need, instead of inheriting it
Seven of them set DEJIMA=1 and assumed nothing else was set. Inside a
dejima sandbox that is false: the briefing-delivery variables are
exported, because that is how the session running the tests got its own
briefing, and the hook then deliberately emits only the session id. So
the tests passed in CI and failed on the machine this project is worked
on - six of them, on a change that touched none of it.

Third time this has happened (proxy.bats, remote.bats, now these), and
the remedy .dejima.md already names: state the precondition with env -u
rather than leaving it to whatever the environment happens to be. Routed
through one helper so the next test to pipe into this hook inherits the
statement rather than the assumption.
2026-08-23 02:32:20 -07:00
branderson c877b8c92d Refuse a body file the CLI would have refused, rather than sending it empty
The shadow reads --body-file and hands the CLI a temp file, which always
exists - so the CLI's own check on that argument can never fire again,
and whatever the shadow read is what gets published.

Two ways that went wrong, both of them shapes the CLIs already refused
before the shadow stood in front of them. A mistyped path produced a
pull request whose entire body was the disclosure marker, created rather
than refused, exit 0. And --body-file /dev/zero filled a temp file until
the disk did, where gitea-pr rejects a non-regular file outright.

Checked here now, before the CLI runs at all, because here is the only
place left that can.
2026-08-23 02:17:10 -07:00
branderson af0bc2f7d9 Merge pull request 'Make Ctrl-Z background the whole launch instead of stranding the session' (#193) from ctrlz-suspend into development
Reviewed-on: #193
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 01:56:11 -07:00
branderson 28fbc1973c Say what Ctrl-Z does, and what it is not
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 49s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / privileged (pull_request_target) Successful in 3m43s
tests / confined (remote) (pull_request_target) Successful in 3m45s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 4m59s
tests / confined (project) (pull_request_target) Successful in 5m17s
tests / confined (config) (pull_request_target) Successful in 7m0s
tests / confined (audit) (pull_request_target) Successful in 9m0s
Suspending is not detaching: a suspended session makes no progress and
belongs to the terminal it started on. Someone who wants the other thing
should be pointed at tmux and dejima-attach rather than finding out the
difference by leaving a session suspended overnight.
2026-08-23 01:32:56 -07:00
branderson de7f541d00 Test Ctrl-Z through a real terminal, and prove the test can fail
Sending SIGTSTP would test nothing: the question is whether the terminal
turns the keystroke into a signal at all, which is exactly what raw mode
decides. So the harness drives a real pty with a real interactive shell
on it, suspends a real job and brings it back. It needs the shell,
because the kernel discards SIGTSTP sent to an orphaned process group,
which is what a launcher started any other way ends up in.

The container run is stood in for rather than run: Docker is not
available here, and what is under test is the terminal handling on this
side of it.

The second test is the control. This one is made of timing and terminal
state, the two things most likely to quietly stop testing anything, so
it runs the same harness without the fix and fails if the bug does not
reappear - byte for byte, 0x1a arriving in the container.
2026-08-23 01:32:56 -07:00
branderson 0ae85e04ee Run the terminal's suspend handling for the length of a session
Backgrounded into dejima's own process group, so the kernel stops and
continues it with the container run and everything else host-side;
nothing forwards signals. Started immediately before the run and stopped
in cleanup(), which also puts the terminal back - the container run
restores what it saved when it attached, but a run that was killed
rather than exited restores nothing, and what that would leave behind is
a terminal with no interrupt character.
2026-08-23 01:32:56 -07:00
branderson 0503680fb1 Take Ctrl-Z back from the container while a session runs
A container run holds the terminal in raw mode, and raw mode has ISIG
off - so Ctrl-Z is not a signal, it is the byte 0x1a, delivered to
whatever is running inside. A harness that implements Ctrl-Z itself then
restores the terminal and stops, which is why the reported symptom is
the launcher's own output reappearing, and nothing in a container can
continue it: there is no job-control shell in there. The host never saw
a thing, so there was no job to bring back either.

So the one character that means suspend is taken back for the host, and
only that one. intr and quit stay undefined, because Ctrl-C is how a
harness expects to be interrupted and that has to keep reaching it.

On resume the terminal has to be put back by hand. Both bash and zsh
restore their own modes when they take the terminal at the prompt and
put those same modes back on fg, not the ones the job was using, so
without this the session returns line-buffered and echoing. The SIGCONT
trap re-applies the session's modes and pokes the process group with
SIGWINCH, which is what makes a full-screen harness paint over whatever
the shell wrote while it was away.
2026-08-23 01:32:56 -07:00
branderson 9354287b1c Merge pull request 'Give every CLI a --help that explains its arguments' (#178) from cli-help into development
Reviewed-on: #178
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:53:48 -07:00
branderson 3e5fc982bb Merge pull request 'Say which forge is the worn path, and which is not' (#192) from forge-maturity-doc into development
Reviewed-on: #192
2026-08-23 00:51:24 -07:00
branderson 106eec8b8b Merge pull request 'Disclose agent authorship for every forge, not just the ones dejima wrote a CLI for' (#191) from forge-disclosure into development
Reviewed-on: #191
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:48:56 -07:00
branderson 88a485a51d Merge pull request 'Let the github forge start, and stop a flag walking past the merge block' (#190) from forge-github-shadow into development
Reviewed-on: #190
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:48:06 -07:00
branderson 6f9485733a Merge branch 'forge-disclosure' into forge-maturity-doc
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / privileged (pull_request_target) Successful in 3m36s
tests / confined (remote) (pull_request_target) Successful in 3m33s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (project) (pull_request_target) Successful in 5m2s
tests / confined (static) (pull_request_target) Successful in 4m20s
tests / confined (config) (pull_request_target) Successful in 6m48s
tests / confined (audit) (pull_request_target) Successful in 8m57s
2026-08-23 00:35:07 -07:00
branderson 8eda94f997 Merge branch 'forge-github-shadow' into forge-disclosure
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / privileged (pull_request_target) Successful in 3m43s
tests / confined (remote) (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m18s
tests / confined (static) (pull_request_target) Successful in 4m35s
tests / confined (config) (pull_request_target) Successful in 7m2s
tests / confined (audit) (pull_request_target) Successful in 8m52s
2026-08-23 00:28:02 -07:00
branderson b31c6aa540 Stub a forge's system commands in the tests, so CI is not the machine
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (remote) (pull_request_target) Successful in 3m44s
tests / privileged (pull_request_target) Successful in 3m44s
tests / confined (static) (pull_request_target) Successful in 4m18s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / confined (config) (pull_request_target) Successful in 6m49s
tests / confined (audit) (pull_request_target) Successful in 8m45s
The tests that run install-forge-bins.sh took the machine's word for
whether a declared system: command exists. Inside a dejima container gh
does, on the CI runner it does not, so every one of these passed here
and failed there.

Two of the five expected a nonzero exit, which is worse than the one
that failed: they would have passed on the runner for entirely the
wrong reason. Both now check what the failure actually says.

The stub names come from the manifests rather than a list here, so a
forge declaring another one later needs no change to this.
2026-08-23 00:27:53 -07:00
branderson c941abb1a4 Say which forge is the worn path, and which is not
tests / confined (audit) (pull_request_target) Successful in 9m7s
tests / confined (config) (pull_request_target) Successful in 7m5s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / privileged (pull_request_target) Successful in 3m43s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Failing after 4m26s
Two forges in one table reads as two forges that got the same
consideration. The harness section already makes this point for the
harnesses; the forges need it more, because the untested one is the
public one an operator is more likely to already be on.
2026-08-22 23:35:03 -07:00
branderson d9a99a0285 Move the authorship marker out of the CLIs dejima happens to have written
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / runner-env (confined) (pull_request_target) Successful in 32s
tests / confined (shellcheck) (pull_request_target) Successful in 46s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / confined (remote) (pull_request_target) Successful in 3m24s
tests / privileged (pull_request_target) Successful in 3m45s
tests / confined (static) (pull_request_target) Failing after 4m5s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (project) (pull_request_target) Successful in 4m55s
tests / confined (config) (pull_request_target) Successful in 6m40s
tests / confined (audit) (pull_request_target) Successful in 8m40s
The disclosure existed because this repo wrote two of the three forge
CLIs, not because anything had decided which artifacts get marked. So a
session under one forge opened PRs, filed issues and left comments that
said an agent wrote them, and under another it opened, filed and left
exactly the same things saying nothing - with the public forge being the
one that said nothing.

The wording is unchanged, and so is what gets marked. This only changes
where it happens, which is now somewhere both forges pass through.

Two behaviours follow from that and are intended. A command carrying no
body has none invented for it. And running a forge CLI on the host marks
nothing, because there the person running it wrote the body.
2026-08-22 23:26:29 -07:00
branderson 60e95a4659 Say which commands write an authored body, and pass it in
Not conditional on the workflow, unlike the merge block: a block is a
policy about what a workflow permits, this is a statement about who
wrote the text, which holds for every session whatever it may do.

The entrypoint now shadows every CLI named rather than the single one
whose merge is blocked - a forge can have a CLI per artifact, and one
with nothing blocked can still have something to disclose.
2026-08-22 22:40:39 -07:00
branderson 6dbcf4a6a7 Mark an agent-authored body in the shadow, for every forge
dejima wrote two of the three forge CLIs, so a disclosure that lives in
the tools is one that depends on which forge an operator picked. The
shadow is the one place they all pass through, and it already compares
argv, which is what finding a body argument needs.

Handles the four spellings both CLIs accept, including --body-file -,
where reading stdin here is the only chance to prepend to it at all.
Rewritten into a temp file rather than turned into --body, which would
put a whole body in argv against a 128KB per-argument limit. A command
with no body argument is passed through: there is nothing to mark.
2026-08-22 22:38:13 -07:00
branderson 7a57989a69 Let a forge declare which of its commands write an authored body
The marker itself stays core's: a forge says which commands carry a body
a person did not write, and what kind of artifact each one is. What
dejima then says about agent authorship is one wording in one place,
rather than one per forge that happens to have a CLI of its own.
2026-08-22 22:36:08 -07:00
branderson 4b73b370a8 Stop a flag before the subcommand walking past the merge block
tests / confined (audit) (pull_request_target) Successful in 8m58s
tests / confined (config) (pull_request_target) Successful in 7m0s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (project) (pull_request_target) Successful in 5m15s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / privileged (pull_request_target) Successful in 3m56s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Failing after 4m18s
gh --repo owner/name pr merge N reached the real gh. The match was
anchored at the first non-flag argument, which for that command is
owner/name, so the guard gave up there and handed the merge over. That
is the ordinary way to name a PR from outside its checkout, and it
mattered the moment gh got behind the shadow at all.

The sequence is now looked for as a run of consecutive non-flag
arguments, anywhere in them. Knowing which flags take a separate value
would mean knowing each CLI, which is the coupling this guard exists to
avoid. The cost is that a command passing the blocked words as
consecutive values is refused; that is loud, and the other way round is
silent.

hooks/merge-guard.sh has the same hole and is not fixed here: it is
handed a command string rather than argv. It is also redundant now -
the shadow is linked whenever anything is blocked, so every session has
it, and only a harness with hooks has both. See #189.
2026-08-22 22:33:36 -07:00
branderson fec440f326 Hold every forge to naming a command that exists
The invariant #184 broke: a forge naming a command nothing installs is
not a weaker guard, it is a container that does not start. Checked
against install-forge-bins.sh's real output rather than against the
manifest, because what matters is where a command ends up.

The two tests that changed had encoded the old fact - that github ships
nothing - and are updated to the new one rather than relaxed. gh is
stubbed in both, so neither says anything about the machine it runs on.
2026-08-22 22:22:34 -07:00
branderson 8cbdb79f86 Declare gh, so the github forge can start
forge = github with workflow = worktree-pr never got past the
entrypoint: worktree-pr is the only workflow that blocks a merge, the
block names gh, and the entrypoint refuses to start when the command it
is meant to shadow is not behind the shadow. That refusal is right - a
merge block nobody can enforce must not look enforced - but gh had no
way to get there, so github failed in the one mode its capabilities
advertise and worked only in the modes where it never opens a PR.
2026-08-22 22:05:17 -07:00
branderson 2a1ab6e530 Let a forge declare a CLI the image already provides
A forge CLI has to sit behind the shadow whether or not this repo wrote
it: that is the fixed path forge-guard.sh execs the real command
through, and the entrypoint refuses to start when the command a workflow
blocks is not there. A forge whose CLI comes from a package had no way
to say so.

Declared in the manifest rather than special-cased in the image build,
so it stays true that reading a forge's manifest tells you everything
that forge puts on PATH.
2026-08-22 22:03:42 -07:00
branderson cedf3614d3 Merge pull request 'Actually keep the keyboard down when a tap is not meant to raise it' (#182) from webui-toolbar into development
Reviewed-on: #182
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 21:28:19 -07:00
branderson 2de990e2a6 Merge pull request 'Say which config line is broken instead of blaming the harness' (#183) from install-config-diagnosis into development
Reviewed-on: #183
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 21:25:28 -07:00
branderson d33be93a27 Merge remote-tracking branch 'origin/development' into webui-toolbar
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 1m56s
tests / confined (remote) (pull_request_target) Successful in 3m16s
tests / privileged (pull_request_target) Successful in 3m32s
tests / confined (static) (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 4m43s
tests / confined (config) (pull_request_target) Successful in 6m27s
tests / confined (audit) (pull_request_target) Successful in 8m31s
2026-08-22 20:38:43 -07:00
branderson b9e65838c5 Say which config line is broken instead of blaming the harness
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m50s
tests / privileged (pull_request_target) Successful in 4m6s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (project) (pull_request_target) Successful in 5m6s
tests / confined (config) (pull_request_target) Successful in 6m47s
tests / confined (audit) (pull_request_target) Successful in 8m54s
A config that does not parse leaves config_get empty for every key, so
the harness read came back empty and install.sh reported that no harness
was set - to an operator looking straight at the harness line they had
just written, which was correct. The reason was on stderr the whole time
and thrown away.

The most likely way to arrive here is a config written for a newer dejima
than the checkout, since an unrecognised key is exactly what that looks
like from the older side.
2026-08-22 20:37:51 -07:00
branderson 3707cc2f52 Actually keep the keyboard down when a tap is not meant to raise it
The policy was hung on the textarea's focus event, which misses the case
it most needed to catch: focusing an element that is already focused
fires no focus event, while a tap on it still raises the keyboard. And
the terminal is focused as a matter of course, because the page focuses
it on every connect - before the preferences that decide the policy have
even arrived. So on a phone the keyboard came up on load and every tap
brought it back, with the handler never once running.

Three things now hold it down. The tap itself is watched, not only the
focus, with the blur deferred so xterm's own handler runs first. Focusing
on connect is skipped when the policy says the keyboard stays down.
Applying the policy drops a focus that is already standing.

A keyboard dismissed by a system gesture leaves its element focused and
fires no blur, which would have let one KBD unlock taps for the rest of
the session, so the visual viewport - the only thing that sees a keyboard
at all - re-locks when the keyboard goes.

Moved out of main.js on the way, because main.js is wiring and has no
tests, and this is logic that was wrong in a way tests would have caught.
The listeners now attach once and read a variable, rather than being
re-added on each policy change: preferences arrive again on every
reconnect.
2026-08-22 20:24:13 -07:00
branderson 2d615481d6 Merge pull request 'Add an Enter key and a way back to the picker, and stop taps raising the keyboard' (#181) from webui-toolbar into development
Reviewed-on: #181
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 20:02:18 -07:00
branderson 678c8104ac Stop a tap on the terminal from raising the keyboard
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 48s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / privileged (pull_request_target) Successful in 3m32s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m0s
tests / confined (config) (pull_request_target) Successful in 6m36s
tests / confined (audit) (pull_request_target) Successful in 8m33s
xterm.js focuses its textarea on every click, which is right on a desktop
and wrong on a phone: tapping is also how you scroll and select, so each
of those taps summoned the soft keyboard and cost a second tap to put it
away. KBD is now the only thing that asks for it.

Done by letting the focus land and immediately blurring, rather than by
intercepting taps. Focus arrives from several paths inside xterm.js and
catching each would mean knowing about each; this needs to know about
none, and leaves selection and scrolling alone. The toolbar never goes
through the textarea, so its keys work with the keyboard down.

A desktop is unaffected, since the default follows the pointer type. Both
overrides exist for the cases where the pointer is not the question:
?focus=1 / ?focus=0 for one client, and remote-access.ttyd_focus_on_tap
for every one of them. Unset stays unset all the way through rather than
becoming a host-wide default, because the pointer type is a property of
the client that the host cannot see.
2026-08-22 18:57:40 -07:00
branderson 6d2bd73efc Give the toolbar a way back to the session picker
Switching sandboxes from a phone meant editing the URL or closing the
tab, because the run-id reaches dejima-attach through ttyd's --url-arg on
the WebSocket URL and so can only change on a new connection. SESS
reloads the page without it, which is the picker, and keeps every other
parameter, since those configure the page rather than the command.

It sits between W-right and ZOOM, and leaving a session this way does not
disturb it: each client already gets its own tmux session grouped onto the
real one, with destroy-unattached set on the view rather than the sandbox.
2026-08-22 18:49:34 -07:00
branderson 3652af7c6a Give the toolbar an Enter key
Enter is the key a harness session asks for most, and every soft keyboard
puts it in the corner furthest from a thumb. It goes at the right of the
top row, where the arrow keys already are.

The row was a grid with `repeat(7, 1fr)` in it, 7 being how many buttons
there were, so an eighth wrapped the row. It is now a flex row of
equal-basis children, which divides itself however many buttons there
are, and the label size is clamped so eight of them still read on a
narrow phone.
2026-08-22 18:45:34 -07:00
branderson 1d60e9222f Merge pull request 'Fix the shipper's argv overflow, let cline be told its context window, and say what 'supported' does not mean' (#180) from ship-argv into development
Reviewed-on: #180
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 17:18:33 -07:00
branderson db99d0c986 Say plainly that the two harnesses are not equally proven
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (remote) (pull_request_target) Successful in 3m45s
tests / privileged (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 4m20s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / confined (config) (pull_request_target) Successful in 7m6s
tests / confined (audit) (pull_request_target) Successful in 9m12s
"Supported" means the same thing for both - a manifest, a plugin, tests, and
the capability bar met or declared unmet. It does not mean they have had the
same amount of use, and a reader who sees them listed side by side could
reasonably conclude otherwise.

Claude Code is what dejima was built around; nearly every mechanism here was
designed against it and generalised afterwards. Cline was built deliberately
and is tested, but a test only covers what someone thought to check: the first
run against a live model turned up a discarded provider entry, and the first
real session turned up an argv overflow in a component Cline does not own.
Neither is a sign of a bad integration - it is the normal rate of discovery for
something this new, which is exactly why the section says to expect more.

No counts in it. "Sessions run under it: thousands" is wrong within a month and
nothing checks it, which .dejima.md already rules out; what is stated instead
are properties that stay true - which harness the mechanisms were designed
against, which guards each can host, and what the first real use found.

The cline config keys join the table in configuration.md at the same time. They
were documented in prose but missing from the list of keys, which is the place
someone looks first.
2026-08-22 16:09:30 -07:00
branderson 2152bf7a11 Let cline be told the context window of a model it cannot recognise
Cline budgets against its own model catalogue. For a model that is not in it -
every model an operator serves themselves - it has no budget at all, so it
grows the conversation until the SERVER rejects the request rather than
compacting:

  rpc error: request (16465 tokens) exceeds the available context size (16384)

[harness.cline] context_window sets it. With a value, Cline compacts before it
reaches the limit; without one it behaves as before, which is right for a model
its catalogue does know.

Deliberately no default. The number belongs to the model being served: too high
changes nothing, too low compacts constantly. The docs say to set it a little
under the server's limit, because Cline's accounting is approximate and a
compaction does not always land under target when a single tool result is
large.

Where the value goes took three attempts to establish and is worth writing
down: contextWindow belongs directly in the provider's settings. A modelInfo
object beside or inside settings is accepted and silently ignored.
2026-08-22 16:09:30 -07:00
branderson dde5d78ec6 Stop passing the Loki payload as a command-line argument
"jq: argument list too long", and the batch dropped. A single argument is
capped at 128 KiB (MAX_ARG_STRLEN), and both the intermediate --argjson value
and the finished body were passed that way.

Not a remote possibility. The transform clips a message at sixteen kilobytes
and a batch holds twenty, so those numbers put the ceiling at well over the
cap by design - any harness whose tool results are large produces this as a
matter of course, and Cline's are. It went unnoticed because the harness the
shipper grew up with produces smaller ones.

The payload is now built in one jq pass and handed to curl on stdin with
-d @-. A shell variable is bounded by memory rather than by MAX_ARG_STRLEN, so
holding it is fine; passing it is not. sandbox-context.sh already documents
this exact trap for its own jq call, which is where the fix comes from.

The regression test ships five sixty-kilobyte lines and looks for a marker at
the start of the last one, so finding it means the whole oversized batch was
accepted rather than one small line arriving beside a dropped one.
2026-08-22 16:09:30 -07:00
branderson 0f38edccb1 Assert -h prints the same page as --help, not merely that it exits 0
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / privileged (pull_request_target) Successful in 3m38s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (project) (pull_request_target) Successful in 5m1s
tests / confined (static) (pull_request_target) Successful in 3m51s
tests / confined (config) (pull_request_target) Successful in 6m39s
tests / confined (audit) (pull_request_target) Successful in 8m54s
The check was status-only, which a tool that ignored -h and happened to
succeed would pass while printing nothing like help. It now compares the
output of both, so the short form has to reach the same page rather than
merely reach an exit code.

All eleven already agreed; this stops one drifting later.
2026-08-22 15:32:35 -07:00
branderson 535460e337 Keep the new help pages inside two rules they were breaking
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 49s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (remote) (pull_request_target) Successful in 3m37s
tests / privileged (pull_request_target) Successful in 3m44s
tests / confined (static) (pull_request_target) Successful in 3m52s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m14s
tests / confined (config) (pull_request_target) Successful in 6m48s
tests / confined (audit) (pull_request_target) Successful in 8m52s
Two suite failures, both real.

bin/dejima's help listed the shipped forges by name. Core is deliberately
forge-agnostic - the set comes from the provider directories, and a static test
holds that core never names one, in prose as much as in code. The flag
description now says what the flag does without enumerating what it accepts,
which is the docs' job anyway.

dejima-notify-watch treated --help as an unrecognized argument on purpose: an
existing test asserts anything it does not know exits 2 with usage rather than
falling into the watch loop, which for an interactive invocation looks like a
hang. --help was named in that test as an example. #64 supersedes that for
--help specifically, so it moves off the bad-argument list; the property the
test is about is untouched and still covered by the typos beside it.
2026-08-22 15:18:13 -07:00
branderson 2c38562d8d Point at --help from the configuration docs
Placed next to the line about --print-config, since the two answer adjacent
questions: --help says what a flag means, --print-config says which one won.
The page itself is about the config file behind them.

Refs #64
2026-08-22 15:10:06 -07:00
branderson dfe1b42678 Hold every CLI to a --help that fits on a screen
Four properties, over a list discovered rather than written down: every
executable under bin/ that is not a library. A tool added later is covered the
day it lands, which is the only version of this that stays true.

Every one supports --help and -h, exits 0, prints something, and opens with its
own name. That last check is on the FIRST line rather than anywhere in the
page, and the difference matters: nearly every help mentions its own name
incidentally further down, in a "started by systemd/..." line, so "appears
somewhere" is satisfied by a page with the wrong title on it. Mutation testing
found that - the first version of the check passed a help page retitled for a
different tool.

The 30-line budget is asserted rather than aspired to, with a floor as well, so
a page cannot come in under budget by saying nothing.

And --help works with no config at all, which is exactly when someone reaches
for it. Every tool dispatches before sourcing a library or reading config, so a
missing or malformed config cannot stop it printing.

Bare invocation is checked too, on the tools that take a required argument: it
still prints a terse usage to stderr and exits nonzero, which is the half of
#64 that asks for nothing to change.

Refs #64
2026-08-22 15:09:51 -07:00
branderson dde767ba0d Say what the service tools are, for anyone who runs one by hand
None of these three is driven by an operator: two are started by systemd units
and one by bin/dejima around a session. Running one by hand mostly produces
silence, and the help now says why - each names what starts it.

Each also states the condition that makes it a no-op, because that is the
likeliest reason someone is reading the help at all: ttyd serves nothing until
remote access is enabled, notify-watch needs a notify URL, and the conversation
shipper needs both loki_url and the separate conversation opt-in. That second
switch exists because session content is a different sensitivity class from DNS
and firewall logs, which is worth one sentence where someone is looking for it.

And each says why it runs where it runs. ttyd binds loopback because attaching
gives a host tmux session; notify-watch is host-side because it is the trust
boundary between a session's markers and a phone outside the sandbox; the
shipper is host-side because a log a session can reach is a log it can silence.

Refs #64
2026-08-22 15:07:23 -07:00
branderson d362e2d5b8 Document dejima-attach's three invocation forms
The picker, a run-id, and --list, which are already described well in the
file's own header; this is compression rather than authorship.

The part worth adding is what attaching gets you: a tmux session on the HOST,
not a shell in the container. That is the point of the tool, and it is also
the reason exposing it is exposing host access - the prefix key opens a window
outside the sandbox. Someone deciding how to expose remote access should meet
that fact in the help rather than in docs/remote-access.md afterwards.

Refs #64
2026-08-22 15:06:39 -07:00
branderson 78f1afadda Document what the audit and epitaph tools do
Both are about what a session left behind, and both are usually run by
bin/dejima rather than by hand - which their help now says, because someone
running 'dejima-audit snapshot' expecting output deserves to know why there
isn't any.

The audit help names its four subcommands and what --full does, and says what
is actually watched: the part a container boundary cannot protect. It also
states the rule that decides severity, since CRITICAL is otherwise just a
colour: a finding is CRITICAL when it names something that runs unsandboxed on
the host.

The epitaph help says why the store is never mounted, which is the same reason
the audit store is not: a session must not be able to read what an earlier one
concluded. It also says that keeping nothing is a normal outcome rather than a
failure, because a harness whose transcript survives compaction has nothing to
preserve.

Refs #64
2026-08-22 15:06:39 -07:00
branderson 55edbadb98 Explain the approval CLIs' scopes in --help
The three share a vocabulary - --global, --project [path], --container - and
now describe it the same way, because an operator who has read one should not
have to re-learn it at the next.

dejima-allow's help says what happens with NO scope flag, which #64 asks for
by name and which is the least obvious part of it: the grant goes to the
running container and lasts as long as that container, found automatically if
exactly one is up. It also says that --project and --global do not touch a
session already running, since that is the mistake the split invites.

dejima-dep and dejima-project both say that declaring is not granting: the
declaration files live in the workspace, which the session can write, so
nothing takes effect until an operator approves it into a store the container
cannot reach. That is the reason the two commands exist rather than one, and
it is not obvious from either usage line.

Refs #64
2026-08-22 15:05:47 -07:00
branderson 08263f101f Give dejima a --help that explains its flags
Every flag, what the positional argument and -- mean, and that each flag
overrides the config file for one launch rather than changing it. The tail
points at --print-config, which answers the question a help page cannot: not
what the flags are, but which one won.

Dispatched before the libraries are sourced and before the config is parsed.
The moment someone reaches for --help is often the moment the config is
missing or wrong, and a help page that needs a working config to print is no
help at all.

stdout and exit 0, where usage() stays on stderr and exits nonzero. That split
is what #64 asks for: bare invocation keeps its terse line, --help explains.

Refs #64
2026-08-22 15:04:54 -07:00
branderson d35124adf4 Merge pull request 'Write the timestamp Cline discards a provider entry without, and settle #170' (#176) from cline-provider-updatedat into development
Reviewed-on: #176
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 14:57:49 -07:00
branderson 7aa17484fd Merge pull request 'Let a launch pick a harness without changing the default' (#177) from harness-flag into development
Reviewed-on: #177
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 14:54:57 -07:00
branderson b8c67a547f Let a launch pick a harness without changing the default
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (remote) (pull_request_target) Successful in 3m42s
tests / privileged (pull_request_target) Successful in 3m50s
tests / confined (static) (pull_request_target) Successful in 3m52s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m15s
tests / confined (config) (pull_request_target) Successful in 6m58s
tests / confined (audit) (pull_request_target) Successful in 9m5s
--forge and --workflow could be overridden per launch; --harness could not, so
a host with more than one harness configured had to edit its config file to
reach the other one and edit it back afterwards.

Everything else this needs was already there, which is why the change is one
line beside its two siblings. Harnesses configure side by side under their own
[harness.<name>] sections, [core] harness names the default, and the image is a
layer keyed on the harness's own name - so switching switches images rather
than accumulating into one. That was designed for from the start; only the flag
was missing.

The value goes through the same override chain the other two use, so it is
validated against the provider set: a typo fails at the flag with the key's own
message rather than later at plugin load.

Three tests. That it selects for one launch and leaves the config file alone,
that an unknown harness is refused, and that the selection reaches the composed
briefing - the last because selecting a harness for the container while
briefing the session about a different one would be worse than not having the
flag.
2026-08-22 14:22:01 -07:00
branderson 9a01a092b6 Settle what a Cline compaction leaves behind: nothing to keep
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 48s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (remote) (pull_request_target) Successful in 3m31s
tests / privileged (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m45s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (project) (pull_request_target) Successful in 5m2s
tests / confined (config) (pull_request_target) Successful in 6m34s
tests / confined (audit) (pull_request_target) Successful in 8m51s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
Epitaphs were left out of the Cline integration because no compaction had been
observed, and guessing at a record shape would have produced a store that
either kept nothing or kept the wrong thing under dejima's promise that an
epitaph is what the session wrote. One has now been observed.

Against a locally served model with the context window set small enough to
force it: four completed auto_compaction cycles, each dropping the working
context from five messages to three. The persisted record was untouched by
every one of them - all five file reads still in .messages.json in full, tool
results included, at close to twenty kilobytes apiece, which is exactly the
content compaction had just removed from what was being sent to the model.

So compaction here is a working-context operation and nothing else. Epitaphs
exist because Claude Code's compaction destroys context that survives only as
the summary written into its transcript, and the container takes everything
else with it. Cline destroys nothing on disk. harness_extract_epitaphs stays
undefined because that is the answer, not because the work is outstanding.

Also noted for anyone revisiting it: the summary Cline builds during compaction
is real but lives only in the event stream, and a run with too small a budget
reports compaction_budget_emergency and falls back from agentic_summary to
plain trimming. Neither path writes to the session record.

The configuration docs gain the practical corollary. Cline has no context
budget for a model it has no catalogue entry for, so against a self-served
endpoint it grows the conversation until the server rejects it rather than
compacting. dejima does not set contextWindow: that belongs to the model being
served, and a wrong value is worse than none.
2026-08-22 14:00:05 -07:00
branderson f7299d88ba Write the timestamp Cline discards a provider entry without
Cline drops a provider entry that has no updatedAt and falls back to its own
hosted endpoint, silently. The run reports provider "cline" and a model nobody
configured, and the first visible symptom is a connection failure to a host the
firewall was never asked to allow - which reads as a network problem rather
than a configuration one.

The plugin never wrote the field. Every test that exercised providers.json
wrote the file by hand and happened to include it, so nothing caught this until
the harness was pointed at a real endpoint and came back talking about
stealth/ox-alpha.

Both branches now carry it, stamped with the launch's own time, which is what
it is: the moment dejima wrote that configuration. The file is rewritten every
launch anyway.

Tests assert the field on both the proxied and the local-endpoint branch, and
that it is an ISO-8601 instant rather than merely present - the shape is what
Cline writes itself, and a value it cannot parse would fail the same silent
way.
2026-08-22 14:00:05 -07:00
branderson cdbc447e16 Merge pull request 'Let cline's local endpoint be a host the operator allowlisted' (#174) from cline-local-endpoint into development
Reviewed-on: #174
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 12:56:39 -07:00
branderson 63bd318d18 Merge pull request 'Let a project declare that its history is evidence' (#175) from corpus-declaration into development
Reviewed-on: #175
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 12:54:50 -07:00
branderson e4f94fa640 Name branch deletion, not squashing, as what destroys the record
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (remote) (pull_request_target) Successful in 3m44s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (static) (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / confined (config) (pull_request_target) Successful in 6m55s
tests / privileged (pull_request_target) Successful in 3m49s
tests / confined (audit) (pull_request_target) Successful in 9m4s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / runner-env (confined) (pull_request_target) Successful in 36s
The commit-granularity bullet said not to squash the step commits before
merging, and that the forge squashing the branch itself was fine. That is too
permissive in the direction that matters: it reads as "squash-merging is safe",
and a project can follow every other rule perfectly and still yield nothing.

Verified here rather than taken on report. A branch with two step commits,
squash-merged into main, then cloned fresh: with the branch retained both step
commits are reachable, because a clone fetches remote branches and the branch
ref still points at them. With the branch deleted before the clone, neither is.
So the destructive act is deleting the branch, and squashing at merge time is
survivable on its own.

That distinction is worth the words because of which one is a default. Squashing
is a choice someone makes per merge and notices; "delete branch on merge" is a
forge setting several offer and some turn on, and nothing about a squash-merged
main looks wrong afterwards.

The briefing now names deletion, and tells the agent to say so if this project's
forge does it rather than assume the record survived. docs/configuration.md
gains the operator's half, since the merge policy is theirs to set and the
briefing only reaches the agent.

The test follows: asserting the paragraph mentions squashing was satisfied by
the wrong version of it.

dejima's own history is unaffected - it merges with merge commits.
2026-08-22 06:23:26 -07:00
branderson ffb2fae05f Let a project declare that its history is evidence
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 54s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (static) (pull_request_target) Successful in 3m43s
tests / confined (remote) (pull_request_target) Successful in 3m46s
tests / confined (config) (pull_request_target) Successful in 6m54s
tests / confined (providers) (pull_request_target) Successful in 2m22s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / confined (audit) (pull_request_target) Successful in 9m6s
A project that wants its history to stay legible as a record of how the work
was done can now declare .dejima/corpus with the rule commit-per-step. Once
the operator approves it, the session briefing gains a paragraph requiring one
commit per plan step that writes a file, rather than one commit per task, and
says why.

The reason is recoverability, not tidiness. A step is only legible afterwards
if the file's state immediately before it and immediately after it can both be
read back from history: before from the commit's parent, after from the commit.
One commit carrying several steps that touch the same file destroys that,
because no step in it can be isolated any more, and reconstructing one would
mean building a file state the repository never held. Nothing recovers that
later, so it has to be recorded while the work happens. Measured rather than
assumed: two repositories with the same author and the same planning workflow,
the one committing per step yielded usable material and the one committing per
task yielded none.

Off unless declared AND approved, and the approval is the security half rather
than ceremony. .dejima/ is in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the next
session's system prompt - the channel #104 is about. It goes through the same
declare-then-approve path as allowed-domains and dependencies, and bin/dejima
reads the approval store. The static test holding that bin/dejima never reads a
declaration as a grant caught the first version of this, which read the
workspace directly; it was right to.

The rule vocabulary is a fixed set rather than a free identifier. A rule
nothing implements would approve cleanly, sit in the store and do nothing,
which reads as being in force when it is not.

Resolved with WORKSPACE_DIR rather than beside the other approval reads, which
happen after --print-briefing has composed and exited. A briefing printed for
inspection that disagreed with what a session gets is the failure DEJIMA_RW_MOUNTS
already had once, so there is a test pinning this one against it.

dejima declares it for itself, which is the point of it being a feature rather
than a paragraph in .dejima.md: the rule has one canonical wording, projects
opt in, and nobody copies prose between repositories to get it.

What the briefing does not do is say what the history is for or who collects
it. That is the operator's business; dejima states the rule the project already
adopted and stops. A test holds that, because a briefing arguing the case would
be doing it in every session, for every user of that project.
2026-08-22 05:31:33 -07:00
branderson 158667a8c7 Let cline's local endpoint be a host the operator allowlisted
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (remote) (pull_request_target) Successful in 3m42s
tests / confined (static) (pull_request_target) Successful in 3m54s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / privileged (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 6m46s
tests / confined (audit) (pull_request_target) Successful in 9m6s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
The base_url check accepted only a literal loopback or RFC 1918 address. A
model server on someone's own network normally has a name, so that refused
the ordinary case of the thing this harness was added for - being pointed at
an open-weight model you serve yourself.

A name now qualifies when the operator has allowlisted it, through
dejima-allow --project or --global, read through approved_entries so it is
the same store, validator and answer bin/dejima uses when it builds the
container's allowlist. That ties acceptance to a grant the operator made
deliberately rather than to how the URL is spelled, and an endpoint failing
the check is one the session could not have connected to anyway.

Worth being clear about what the check was never doing. dejima writes a
placeholder into providers.json on this path whatever the endpoint is, so no
real credential enters the container either way - R5 holds by construction
here, not by this test. What the check decides is whether the configuration
can WORK: an endpoint needing a key would get the placeholder and fail, and
saying so at launch beats an auth error three layers down.

A literal address still qualifies on its own, and the octet comparison stays
numeric, so 10.example.com is still a name rather than the network it looks
like.
2026-08-22 02:27:58 -07:00
branderson 191274ddd1 Merge pull request 'Let a session update a plugin, by seeding the tree instead of mounting it' (#172) from plugin-state into development
Reviewed-on: #172
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 00:47:59 -07:00
branderson 913ed79027 Let a session update a plugin, by seeding the tree instead of mounting it
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (remote) (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 5m8s
tests / confined (providers) (pull_request_target) Successful in 2m5s
tests / confined (static) (pull_request_target) Successful in 3m36s
tests / privileged (pull_request_target) Successful in 3m57s
tests / confined (config) (pull_request_target) Successful in 6m32s
tests / confined (audit) (pull_request_target) Successful in 8m52s
~/.claude/plugins was mounted read-only, with the other config directories
that hold something executable. The symptom was a plugin that would not load:
when the backend published an update, Claude Code could not install it, and
the only way out was to run Claude Code on the host and restart the container.

The tree is two things at once, and mounting all of it read-only got both
wrong. cache/<marketplace>/<plugin>/<version>/ is each installed version,
materialised - the bulk by far, one MCP server's node_modules being most of
it, and never rewritten, because an update writes a NEW version directory
beside the old. Everything else is bookkeeping Claude Code rewrites as it
goes: which plugins are installed and at what version, the resolved
catalogue, the marketplace checkout. Read-only, it could neither materialise
the new version nor record it, and the plugin was left at a version the
catalogue no longer matched.

The read-only mount was not protecting anything either. What it protects for
hooks/ and skills/ is a session writing something the HOST will execute next
time. A copy has no such path: the session writes its own copy, which goes
away with the container. The host's tree is what the operator's own runs
produce, and it is only ever read.

So the two halves are carried differently. Everything the harness rewrites is
seeded as a writable copy owned by the session - a few megabytes. Each
installed version stays a read-only mount of the host's, which keeps four
hundred megabytes out of a per-launch copy while leaving the session able to
add a version beside them.

That last part needs the levels above a version to exist and be owned by the
session, and they cannot be image_dirs because the marketplace and plugin
names are not known until a launch reads them. Docker creates a bind mount's
missing parents as root, so without seeding them the session could read every
installed version and write no new one - which is the failure this is about.

The mechanism is a third tier beside config dirs and staged config, and the
distinction between the three is what a session may do to the result: a
mounted directory is the host's copy, staged config is a root-owned read-only
copy for anything that RUNS, and seeded state is a copy the session owns and
may rewrite. emit_seed_from carries host content; emit_seed_dir creates an
empty writable directory for the case where the contents arrive as mounts.
The seed tree mirrors the destination the way the staging tree already does,
so entrypoint.sh names no harness's paths.

The runtime test is the one that matters here, because ownership is the whole
point and only Docker produces it. It boots a container with a seeded tree and
one mounted version, and asserts what the arrangement exists for: the registry
is writable, a new version directory can be created beside the mounted one,
and the mounted one is neither writable nor changed.
2026-08-22 00:36:30 -07:00
branderson a523c0a10a Merge pull request 'Add Cline as the second supported harness' (#164) from cline-harness into development
Reviewed-on: #164
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 00:15:49 -07:00
branderson efc5d634ef Merge pull request 'Stop rebuilding the same caches in every subshell' (#169) from launch-perf into development
Reviewed-on: #169
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 00:09:25 -07:00
branderson c511f2b8a8 Stop rebuilding the same caches in every subshell
tests / confined (conversation) (pull_request_target) Successful in 57s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (shellcheck) (pull_request_target) Successful in 49s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (static) (pull_request_target) Successful in 2m51s
tests / confined (remote) (pull_request_target) Successful in 3m23s
tests / privileged (pull_request_target) Successful in 3m28s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (audit) (pull_request_target) Successful in 8m36s
tests / confined (providers) (pull_request_target) Successful in 2m0s
tests / confined (project) (pull_request_target) Successful in 4m49s
tests / confined (config) (pull_request_target) Successful in 6m15s
A launch took eight seconds and ran twenty-six thousand shell commands, almost
none of them doing work twice on purpose. The config schema, the manifest
schemas, the provider root and each provider's directory are all cached
already - and every one of those caches was being filled inside a `$(...)`,
where it dies with the subshell that filled it. Nothing populated them in the
shell that forks, so each fork rebuilt them: _manifest_table was emitted 72
times where the cache intends three, _config_table 23 times where it intends
one, and provider_root's resolution ran 652 times.

So the fix is where they are filled, not how. The two schema tables and the
provider root are compile-time constants and are resolved at source time, in
the shell every subshell inherits from. Every shipped provider's directory is
resolved the same way - a handful of readlinks once, rather than two per
manifest read. bin/dejima parses the config file in its own shell before
anything reads a key.

Deliberately not the manifests themselves: parsing one validates every value in
it, and the providers that were not selected are never read, so warming all of
them measured slower than warming none. Warming just the selected three
measured as noise, so that is not here either.

One fork that was not a cache at all: emit_mount_at resolved the same mount
target inside a loop over the container paths, so a mount cost a readlink per
path rather than one.

Eight seconds to six, over seven runs each with the page cache warm. That is an
operator's launch as much as a test's - it is the same code path, and it is
what the suites that call bin/dejima were paying per invocation.

The config warm needed one correction that the existing tests caught. Its
stderr is redirected, and the parse status is cached, so caching a FAILED warm
let the real caller return that failure with the reason already discarded - an
invalid config refused with no explanation. A failed warm now drops the cache
entry so the caller parses again and reports. There is a test for it on the
launch path, since that is the path an operator takes and the warm runs before
--print-config too.

Beyond that, test/run-confined.sh runs the confined suites concurrently, which
is what actually moves the suite's wall clock: about seven minutes rather than
about twenty-five. The suites share no state, and CI already does this as a
matrix - this is the local equivalent, and it did not exist. It reports what
each suite RAN as well as what failed, because a suite that cannot start exits
nonzero having run nothing, and counting only failures reads that as green.
2026-08-21 23:57:40 -07:00
branderson 86d2a91cfb Merge pull request 'Ask the harness where its transcripts are, instead of assuming Claude Code's' (#165) from harness-transcripts into cline-harness
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (remote) (pull_request_target) Successful in 3m26s
tests / confined (static) (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 4m26s
tests / confined (config) (pull_request_target) Successful in 6m50s
tests / confined (audit) (pull_request_target) Successful in 7m32s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / privileged (pull_request_target) Successful in 3m29s
Reviewed-on: #165
Reviewed-by: branderson <brad@branderson.io>
2026-08-21 21:48:34 -07:00
58 changed files with 4059 additions and 217 deletions
+20
View File
@@ -41,6 +41,19 @@ set before opening a PR, not between edits.
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
When the whole set genuinely is wanted, run it concurrently rather than one
file after another:
```sh
bash test/run-confined.sh
```
The suites share no state, so they run together safely: about seven minutes
instead of about twenty-five. It reports what each suite ran as well as what
failed, because a suite that cannot start - a syntax error, or duplicate test
names, which bats refuses outright - exits nonzero having run nothing, and
that reads as "no failures" to anything counting only failures.
Two things that are not in that table because they defeat it: `bin/dejima`
itself and `bin/dejima-config-lib.sh` are read by nearly every other suite, so a
change to either is one of the cases where the whole set genuinely applies. And
@@ -201,6 +214,13 @@ Branches here are opened against `development`, which merges to `main`
periodically. `main` stays releasable while work lands quickly; nothing else
about the workflow changes.
Moving `development` to `main` is not an ordinary merge, and is not something
to do as a follow-on to finishing a branch. It has its own pass - suites,
security review, documentation review, and the manual checks nothing here can
run - in [docs/release.md](docs/release.md), and `test/release-pass.sh` runs
what can be run and prints the rest. If you are asked to prepare a release,
start there rather than opening the pull request first.
`gitea-pr` defaults its base to the repository's default branch, so say so:
```sh
+13
View File
@@ -0,0 +1,13 @@
# This project keeps its history as usable evidence of how the work was done.
#
# One rule per line, from the set dejima implements. Declaring it here is not
# enough on its own: .dejima/ is in the workspace and a session can write it,
# so the operator approves it with `dejima-project approve` and dejima reads
# only what was approved.
#
# commit-per-step: every plan step that writes a file gets its own commit,
# rather than one commit per task. A step is only legible afterwards if the
# file's state before and after it can both be read back from history, and one
# commit carrying several steps that touch the same file destroys that
# irrecoverably.
commit-per-step
+5
View File
@@ -29,3 +29,8 @@ npm:cline
apt:tmux
apt:bats
# The other half of what CI runs. The CI job installs it itself, so without
# this line a sandbox is the one place the gate cannot be run at all - and a
# base image that happens to carry it is not a guarantee, as one that stopped
# carrying it demonstrated.
apt:shellcheck
+20
View File
@@ -422,6 +422,26 @@ 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 the whole thing
Suspending a session backgrounds all of dejima - the container, the credential
proxy, the conversation shipper - and `fg` brings back a working session, with
the terminal put back the way the harness had it.
That takes doing, because a container run holds the terminal in raw mode, where
Ctrl-Z is not a signal at all: it is a byte, delivered to whatever is running
inside. A harness that implements Ctrl-Z itself then stops itself, and nothing
in a container can continue it - there is no job-control shell in there. So
dejima takes that one character back for the host while a session runs
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
still reach the session, which is how a harness expects to be interrupted.
Suspending is not the same as 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`](docs/remote-access.md).
## Sign in on the host first
**Install your harness on the host and authenticate it there before running
+170 -1
View File
@@ -9,6 +9,51 @@
# dejima ~/repos/myproject
set -euo pipefail
# --help, before anything else runs.
#
# Deliberately ahead of the library sourcing and the config parse below: the
# moment someone reaches for --help is often the moment the config is missing
# or wrong, and a help page that needs a working config to print is no help.
# Nothing here but `cat`.
#
# stdout and exit 0, unlike usage(), which stays on stderr with a nonzero exit
# so a mistyped invocation still fails a script. That split is the whole of
# what #64 asked for: bare invocation keeps its terse line, --help explains.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_HELP'
dejima - run an agent harness in a container that cannot reach your machine
Usage: dejima [options] [workspace] [-- args...]
workspace project directory to mount read-write (default: current directory)
-- args... everything after is passed to the harness command untouched
Options (each overrides the config file for one launch only):
--forge <name> which forge integration to use for this launch
--workflow <name> what the agent is told to do with its work
--cmd <command> run this instead of the harness's own command
--ro, --extra-readonly <path> also mount this host path read-only
--rw, --extra-read-write <path> also mount this host path read-write
--allow-github-push open GitHub's push path (port 22) for this launch
--no-firewall disable the egress firewall entirely (debugging only)
--print-config print the resolved configuration and exit
--print-briefing print the session briefing the agent will get, and exit
-h, --help this message
--ro and --rw may be given more than once. Both refuse a path that overlaps
dejima's own approval, audit or epitaph stores.
Configuration lives in ~/.config/dejima/config and [core] harness must be set
before a launch will start. --print-config shows what is actually in effect,
including which flag overrode which key.
See docs/configuration.md for every key, docs/security.md for what the
container can and cannot reach.
DEJIMA_HELP
exit 0 ;;
esac
# readlink -f resolves through the ~/.local/bin/dejima symlink
# install.sh sets up - without it, BASH_SOURCE[0] is the symlink's own path
# when invoked by name via PATH, so dirname finds ~/.local/bin instead of
@@ -31,6 +76,29 @@ source "$SCRIPT_DIR/dejima-harness-lib.sh"
# session's own stdout stays exactly what the container wrote.
color_init 2
# Parse the config file here, in THIS shell, before anything reads a key.
#
# The parse is already cached (_CFG_PARSED_PATH), but almost every reader
# reaches it through `$(config_get ...)`, and a cache filled inside a command
# substitution is discarded with the subshell that filled it. Nothing on the
# launch path populated it in the parent, so each of those subshells re-read
# and re-parsed the whole file - dozens of times per launch. Doing it once
# here means every subshell inherits it already parsed.
#
# Failure is deliberately not fatal here: this is a warm-up, not the
# validation, and the callers that already refuse a bad config still do.
#
# But a failed warm must not CONSUME the diagnosis. _config_parse prints the
# first problem it finds as it parses, and the status is cached - so leaving a
# failure cached let the real caller return that status with the message
# already swallowed by the redirect above, and an invalid config was refused
# with no reason given. Dropping the cache entry makes that caller parse again
# and report, which costs one extra parse of a config that is about to be
# refused anyway.
if ! config_validate >/dev/null 2>&1; then
_CFG_PARSED_PATH=""
fi
# Flags come first, then the optional workspace path, then "--" and
# everything after that reaches the container command untouched - including a
# token that looks like one of our own flags. The "--" must come AFTER the
@@ -133,6 +201,7 @@ while [ $# -gt 0 ]; do
--no-firewall) _set_override core.firewall off; shift ;;
--allow-github-push) ALLOW_GITHUB_PUSH_FLAG=1; shift ;;
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
--harness) _need_value --harness "${2:-}"; _set_override core.harness "$2"; shift 2 ;;
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
--extra-readonly|--ro)
@@ -315,6 +384,7 @@ if [ -z "$DEJIMA_HARNESS_NAME" ]; then
fi
provider_validate_selection "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1
# The selected harness's plugin: what dejima does for this harness that no
# manifest value could state. Loaded once the selection is validated, and
# before anything that calls a hook. A manifest without a plugin is refused
@@ -358,6 +428,36 @@ fi
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
# Resolved here, with the workspace, rather than beside the other approval
# reads further down: --print-briefing composes and exits well before those,
# so a briefing printed for inspection would have silently lacked whatever
# they decide. That is not hypothetical - the same ordering already caught
# DEJIMA_RW_MOUNTS, whose sentence went missing from --print-briefing while
# real sessions were told correctly.
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
# Whether this project has been approved to keep its history as evidence, and
# so gets the commit-granularity paragraph in its briefing.
#
# Read from the APPROVAL STORE, never from the declaration in the workspace.
# The workspace is session-writable, so a session that could turn this on
# would be writing the next session's system prompt - the channel #104 is
# about, and the reason nothing on the launch path reads a project's
# declarations directly. The project declares, the operator approves, and this
# reads what was approved. A static test holds that this file never names that
# directory at all, which is why it is not spelled out here. Global entries
# count too, for an operator who wants this everywhere.
DEJIMA_CORPUS=off
_corpus_approved="$(approved_entries "$(config_root)" corpus | tr '\n' ' ')"
if [ -n "$PROJECT_STORE" ]; then
_corpus_approved="$_corpus_approved $(approved_entries "$PROJECT_STORE" corpus | tr '\n' ' ')"
fi
case " $_corpus_approved " in
*" commit-per-step "*) DEJIMA_CORPUS=on ;;
esac
unset _corpus_approved
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
@@ -549,6 +649,28 @@ else
DEJIMA_MERGE_BLOCK=""
fi
# Which of this forge's commands write a body the session authored, as one
# record per line for the shadow to read: "<kind> <command prefix>".
#
# Not conditional on the workflow, unlike the merge block above. A merge block
# is a policy about what a workflow permits; this is a statement about who
# wrote the text, which is true of every session whatever it is allowed to do.
# The comma-separated manifest form is turned into lines here rather than in
# the shadow, so the parsing sits next to the schema that validates it.
DEJIMA_DISCLOSE=""
_disclose_spec="$(manifest_get forge "$DEJIMA_FORGE_NAME" disclose)"
if [ -n "$_disclose_spec" ]; then
_old_ifs="$IFS"
IFS=,
for _entry in $_disclose_spec; do
_entry="${_entry#"${_entry%%[![:space:]]*}"}"
_entry="${_entry%"${_entry##*[![:space:]]}"}"
[ -n "$_entry" ] || continue
DEJIMA_DISCLOSE="${DEJIMA_DISCLOSE}${DEJIMA_DISCLOSE:+$'\n'}${_entry%%:*} ${_entry#*:}"
done
IFS="$_old_ifs"
fi
# WORKSPACE_DIR is resolved above, ahead of --print-briefing, since
# briefing_compose needs DEJIMA_RW_MOUNTS (which needs WORKSPACE_DIR for its
# own overlap guard) before that point.
@@ -566,12 +688,12 @@ fi
# session widen its own firewall for the next run. The declaration is only
# ever reported (see hooks/sandbox-context.sh) and approved by hand.
ALLOWED_DOMAINS="$(approved_entries "$(config_root)" allowed-domains | tr '\n' ' ')"
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
if [ -n "$PROJECT_STORE" ]; then
project_domains="$(approved_entries "$PROJECT_STORE" allowed-domains | tr '\n' ' ')"
ALLOWED_DOMAINS="${ALLOWED_DOMAINS}${project_domains:+ $project_domains}"
fi
# The approval store, and the exit-audit's own report/baseline stores, are
# only out of the container's reach while none of them are inside the
# mounted workspace - same class of warning as the self-mounted repo TCB
@@ -754,6 +876,8 @@ SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
SANDBOX_CONVERSATION_SHIP_PID=""
SANDBOX_TTY_SUSPEND_PID=""
SANDBOX_TTY_MODES=""
# Names each host-side task in cleanup() as it starts. Between the container
# exiting and the operator's shell prompt returning, dejima audits every
@@ -800,6 +924,18 @@ cleanup() {
# rather than dying with the batch. Reaped for the same reason the proxy
# is: an unreaped child reparents to PID 1 and stays a zombie wherever
# PID 1 does not reap.
# The terminal first, and its settings before its watcher: the container
# run restores what it saved when it attached, but a run that was killed
# rather than exited restores nothing, and what it would leave behind is a
# terminal where Ctrl-C is undefined. Cheap, and only does anything when
# there was a terminal to save.
if [ -n "${SANDBOX_TTY_MODES:-}" ]; then
stty "$SANDBOX_TTY_MODES" < /dev/tty 2>/dev/null || true
fi
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
wait "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
fi
if [ -n "${SANDBOX_CONVERSATION_SHIP_PID:-}" ]; then
kill "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
wait "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
@@ -1058,6 +1194,7 @@ extra_mounts+=(-e "DEJIMA_FORGE_HOSTS=$DEJIMA_FORGE_HOSTS")
extra_mounts+=(-e "DEJIMA_PUSH_HOSTS=$DEJIMA_PUSH_HOSTS")
extra_mounts+=(-e "DEJIMA_BLOCKED_BRANCHES=$DEJIMA_BLOCKED_BRANCHES")
extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
# The harness's own configuration, mounted at the mode it declared, and -
# for the directories it marked scannable - the symlink targets that have to
@@ -1229,6 +1366,25 @@ for _rec in ${DEJIMA_STAGED_CONFIG[@]+"${DEJIMA_STAGED_CONFIG[@]}"}; do
extra_mounts+=(-v "$_ssrc:/etc/dejima-harness/$_srel:ro")
done
# The harness's own writable state, seeded from the host. Mounted read-only
# into a staging tree the same way staged config is - entrypoint.sh installs a
# copy owned by the session user, so the harness can rewrite it and nothing it
# writes reaches the operator's.
#
# The destinations travel as one space-separated list rather than being
# rediscovered by walking the staging tree, because some of them have no
# source: a directory whose contents are mounted individually needs to exist
# and be owned by the session, and there is nothing to copy into it.
harness_seed_load
SANDBOX_HARNESS_SEED=""
for _rec in ${DEJIMA_SEED[@]+"${DEJIMA_SEED[@]}"}; do
IFS=$'\t' read -r _seedrel _seedsrc <<< "$_rec"
SANDBOX_HARNESS_SEED="${SANDBOX_HARNESS_SEED:+$SANDBOX_HARNESS_SEED }$_seedrel"
[ -n "$_seedsrc" ] || continue
extra_mounts+=(-v "$_seedsrc:/etc/dejima-seed/$_seedrel:ro")
done
export SANDBOX_HARNESS_SEED
# The real Anthropic OAuth credential never enters the container: a host-side
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
# ~/.claude is not mounted whole, so the credential has no path into the
@@ -1701,6 +1857,19 @@ compose_status=0
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
SANDBOX_CONVERSATION_SHIP_PID=$!
# Ctrl-Z, which without this is not a signal at all: the container run puts
# the terminal in raw mode, so 0x1a is delivered into the container, where a
# harness that implements suspend stops itself with no job-control shell in
# there to continue it. See bin/dejima-tty-suspend and #179. Started here so
# that the terminal is only altered while a session is on it, and backgrounded
# into this same process group so the kernel stops and continues it with
# everything else.
SANDBOX_TTY_MODES="$(stty -g < /dev/tty 2>/dev/null)" || SANDBOX_TTY_MODES=""
if [ -n "$SANDBOX_TTY_MODES" ]; then
"$SCRIPT_DIR/dejima-tty-suspend" &
SANDBOX_TTY_SUSPEND_PID=$!
fi
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
+35
View File
@@ -17,6 +17,41 @@
# the thing running right now". Pass --container as well to do both.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
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>
Scope, which decides who the grant is for and how long it lasts:
(none) the running container, right now, until it exits. Finds it
if exactly one is running; refuses if none or several.
--container <id> the same, for a named container. Use this when a session
told you its own id rather than letting anything guess.
--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.
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
printed if it is not, so a typo or a blocked port does not look like success.
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
DEJIMA_ALLOW_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+31
View File
@@ -11,6 +11,37 @@
# --list one tab-separated row per live session, for scripting
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_ATTACH_HELP'
dejima-attach - list live dejima sessions and attach to one
Usage: dejima-attach [run-id | --list]
(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
--list one tab-separated row per live session, for scripting
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
terminal can reach are the same thing described once.
Attaching gives a tmux session on the HOST, not a shell inside the container.
That is deliberate - it is how an operator watches or steers a session - but it
means the tmux prefix key opens a window outside the sandbox, so exposing it is
exposing host access.
A session appears here only while its container is running.
See docs/remote-access.md.
DEJIMA_ATTACH_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+37
View File
@@ -8,6 +8,43 @@
# `claude` in that repo again. See docs/security.md.
set -uo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_AUDIT_HELP'
dejima-audit - report what a session changed outside its workspace
Usage: dejima-audit <snapshot|compare|show|ack> [workspace] [state-file]
snapshot record the current state of everything watched
compare compare against the last snapshot and file a report
show [--full] index of reports waiting to be read; --full prints one
ack walk a report finding by finding and record a decision
workspace defaults to the enclosing git repository, else $PWD.
snapshot and compare are run by bin/dejima around a session, not usually by
hand. show and ack are for reading what they found.
What is watched is the part a container boundary cannot protect: dejima's own
trusted code, the harness configuration a later host session would execute,
and anything else on the host a session could have reached. A finding is
CRITICAL when it names something that runs unsandboxed on the host.
ack records a decision per finding, so an approved change stops being reported
until the thing it approved changes again. Acknowledging is per value, not per
path: the same file with different content is a new finding.
Reports and baselines live under ~/.local/share/dejima, outside every mount, so
a session cannot read what was concluded about it or edit what is pending.
See docs/security.md.
DEJIMA_AUDIT_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
+9
View File
@@ -68,6 +68,7 @@ remote-access.base_url ?url
remote-access.ttyd_port int:1:65535 7681
remote-access.ttyd_font_size ?int:8:72
remote-access.ttyd_tmux_prefix ?chord
remote-access.ttyd_focus_on_tap ?bool
remote-access.debounce int:0:86400 60
EOF
}
@@ -100,6 +101,14 @@ _config_load_schema() {
_CFG_SCHEMA_LOADED=1
}
# Loaded here, at source time, rather than on first use. Lazily is not enough:
# nearly every caller reaches config_get through `$(...)`, and a cache filled
# inside a command substitution dies with the subshell that filled it. The
# first call in each of those subshells therefore re-read the table, so the
# cache above saved nothing for exactly the callers that dominate a launch.
# Filling it in the parent means every subshell inherits it already populated.
_config_load_schema
_config_spec() {
_config_load_schema
if [ -n "${_CFG_TYPE[$1]+x}" ]; then
+52 -6
View File
@@ -16,6 +16,39 @@
# Usage: dejima-conversation-ship <workspace>
set -uo pipefail # not -e: a torn line or a Loki hiccup must not kill the tailer
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_CONVERSATION_SHIP_HELP'
dejima-conversation-ship - ship a session's conversation to Loki
Usage: dejima-conversation-ship <workspace>
Started and stopped by bin/dejima around a session. Running it by hand is only
useful for debugging.
Ships prompts, replies and tool invocations - never tool output, which is most
of the volume and was not asked for. Runs on the host rather than in the
container, because a log of a session that the session shares a filesystem and
a signal namespace with is one it can silence.
What that does not buy is trust in the contents. The transcript is written by
the session, so this ships what was recorded without attesting to it.
Off unless [logging] loki_url is set AND [logging] conversation is on. Both are
required because session content is a different sensitivity class from the DNS
and firewall logs, which ship on loki_url alone.
Where the transcripts are, and how one becomes a log line, are the selected
harness's business; the batching and the shipping are dejima's.
See docs/configuration.md.
DEJIMA_CONVERSATION_SHIP_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
@@ -64,16 +97,29 @@ POLL_SECONDS=3
push() {
# $1 is a newline-terminated block of "<ns timestamp>\t<line>" records.
local raw="$1" values payload
local raw="$1" payload
[ -n "$raw" ] || return 0
values="$(printf '%s' "$raw" | jq -R -s '
# One jq, and nothing through argv. A single argument is capped at 128 KiB
# (MAX_ARG_STRLEN), and both the intermediate --argjson value and the
# finished body used to be passed that way - so a batch of long lines
# failed with "argument list too long" and the batch was dropped.
#
# Twenty lines at up to sixteen kilobytes each is well past that cap, and
# the transform's own clip is what sets those numbers, so this was not a
# remote possibility: it is what a harness whose tool results are large
# produces as a matter of course. Found running Cline.
#
# The payload is built in one pass and handed to curl on stdin with -d @-.
# A shell variable is bounded by memory rather than by MAX_ARG_STRLEN, so
# holding it is fine; passing it is not.
payload="$(printf '%s' "$raw" | jq -R -s '
split("\n") | map(select(length > 0))
| map(split("\t") | [.[0], (.[1:] | join("\t"))])
| {streams: [{stream: {job: "dejima-conversation", source: "dejima"}, values: .}]}
')" || return 0
payload="$(jq -n --argjson values "$values" \
'{streams: [{stream: {job: "dejima-conversation", source: "dejima"}, values: $values}]}')" || return 0
curl -s -o /dev/null --max-time 5 -H "Content-Type: application/json" \
-XPOST "$LOKI_URL/loki/api/v1/push" -d "$payload" || true
printf '%s' "$payload" | curl -s -o /dev/null --max-time 5 \
-H "Content-Type: application/json" \
-XPOST "$LOKI_URL/loki/api/v1/push" -d @- || true
}
# Which transcript files belong to this workspace. The harness's plugin
+36
View File
@@ -17,6 +17,42 @@
# for a project are the union of its global and project approvals.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_DEP_HELP'
dejima-dep - approve a package a project needs, so its image carries it
Usage: dejima-dep <add|remove|list> [--global | --project [path]] [<spec>]
add <spec> approve it; the image is rebuilt with it at the next launch
remove <spec> withdraw the approval
list show what is approved in the scope given
Scope:
--project [path] one project, every session in it. Path defaults to $PWD.
--global every project on this host.
(none) same as --project for the current directory.
A spec is <apt|npm|pip|cargo>:<name>, optionally @<version>, or a release
archive as tarball:<name>@<sha256>:<url>. The digest is required and checked at
build time, because a tarball is code that will run in every future session.
Approving is a separate act from declaring. A project declares what it needs in
.dejima/dependencies, which is inside the workspace and therefore writable by
the session; nothing is installed until an operator approves it here. Use
`dejima-project status` to see what a project is asking for.
Nothing is installed into a running container: a rebuild happens at the next
launch, so an approval made mid-session takes effect on the one after it.
See docs/configuration.md.
DEJIMA_DEP_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+35
View File
@@ -24,6 +24,41 @@
# Usage: dejima-epitaph save <workspace>
set -uo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_EPITAPH_HELP'
dejima-epitaph - keep what a session worked out before its context was compacted
Usage: dejima-epitaph save <workspace>
save <workspace> extract any compaction summaries from that workspace's
transcripts and keep them
Run by bin/dejima when a session exits, not usually by hand.
A harness that compacts a long session writes a summary of what it is about to
forget. For some harnesses that summary is the only durable account of a long
session's reasoning, and it goes away with the container. This keeps it.
What a compaction looks like in a transcript is the harness's business, so the
selected harness's plugin does the extracting; everything else here is dejima's:
where the store is, that nothing mounts it, and that a directory appears only
once something has been written into it.
The store is host-side and never mounted, so one session cannot read what an
earlier one concluded or edit what this one is about to record.
Nothing is kept when the selected harness does not lose anything to compaction,
which is not a failure - it means the transcript already holds it all.
See docs/security.md.
DEJIMA_EPITAPH_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
+78 -2
View File
@@ -40,7 +40,7 @@
# The hooks core will call. A plugin defines the ones its harness needs and
# omits the rest; anything else it defines is a name core does not call, and
# the static test refuses it rather than letting it look wired up.
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files"
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files harness_seed"
# What an operator loses when a harness cannot host a given guard. The set
# itself is DEJIMA_KNOWN_GUARDS in dejima-provider-lib.sh, next to the
@@ -212,8 +212,12 @@ emit_mount_at() {
_guarded_stores
_refuse_guarded_mount "harness state mount" "$src" \
${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"}
# Resolved once, above the loop. It does not depend on $guarded, and
# inside the loop it was a fork per container path per mount.
local target_real
target_real="$(readlink -m "$target")"
for guarded in $DEJIMA_CONTAINER_PATHS; do
if _paths_overlap "$(readlink -m "$target")" "$guarded"; then
if _paths_overlap "$target_real" "$guarded"; then
echo "Refusing harness mount at $target: it overlaps $guarded," >&2
echo "which the sandbox needs in place inside the container." >&2
exit 1
@@ -383,6 +387,78 @@ harness_staged_config_load() {
harness_hook harness_staged_config
}
# --- seeded state ---------------------------------------------------------
#
# State the harness OWNS and rewrites, carried from the host as a writable
# container-local copy. The third tier, and the distinction from the two above
# is what the session may do to it:
#
# config dirs - mounted. The host's copy is what the session sees, and a
# read-only one is read-only because the session writing it
# would be writing the host's.
# staged config - copied, root-owned, read-only. The session cannot write it
# at all, which is the point for anything that RUNS.
# seeded state - copied, owned by the session, WRITABLE. The session may
# rewrite it freely, and nothing it writes reaches the host,
# because the host's copy was only ever read.
#
# The third exists because a harness keeps bookkeeping it must be able to
# update mid-session - a plugin registry, a catalogue, an install marker - and
# mounting that read-only does not protect anything. The host's copy is not
# what a session could subvert; the host's copy is what the operator's own
# runs produce. What mounting it read-only actually did was break the harness,
# because it could not record what it had just done.
#
# Same mirroring as staged config, so entrypoint.sh needs no mapping:
# /etc/dejima-seed/<rel> is installed at $HOME/<rel>.
DEJIMA_SEED=()
# A path under $HOME the session must be able to write, with no host content
# behind it. For a directory whose CONTENTS are mounted individually: Docker
# creates a bind mount's missing parents as root, so without this the session
# owns nothing along the path and cannot add an entry beside the mounted ones.
emit_seed_dir() {
_emit_seed_record "$1" ""
}
# The same, seeded with a copy of a host path. The copy is the point: mounting
# it would make the session's writes the operator's, and staging it read-only
# would stop the harness writing at all.
emit_seed_from() {
local src="$1" rel="$2"
[ -n "$src" ] || return 0
# Absent is not an error. A harness names the state it would carry if the
# operator had any, and a fresh install has none of it yet.
[ -e "$src" ] || return 0
_emit_seed_record "$rel" "$src"
}
_emit_seed_record() {
local rel="$1" src="${2:-}"
[ -n "$rel" ] || return 0
# Same refusal as staged config, for the same reason: this becomes a
# location under $HOME inside the container, and anything that could climb
# out of it is a plugin bug rather than something to normalise quietly.
# Spaces are refused too, because the list reaches the container as one
# space-separated environment variable - the homepath manifest type
# already forbids them, and a plugin computing one has the same duty.
case "$rel" in
/*|*..*|*[[:space:]]*|*$'\t'*|*$'\n'*)
echo "Harness plugin emitted an invalid seed path: $rel" >&2
exit 1 ;;
esac
case "$src" in
*:*) echo "Harness plugin emitted a seed source containing a colon: $src" >&2
exit 1 ;;
esac
DEJIMA_SEED+=("$rel"$'\t'"$src")
}
harness_seed_load() {
DEJIMA_SEED=()
harness_hook harness_seed
}
# --- epitaphs ------------------------------------------------------------
#
# What a session leaves when its context is compacted away. Where the store
+29
View File
@@ -27,6 +27,35 @@
# markers can abort it and silence every other session's notifications.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_NOTIFY_WATCH_HELP'
dejima-notify-watch - turn a session's notifications into phone pushes
Usage: dejima-notify-watch [--once]
(no argument) watch until killed, which is what the systemd unit runs
--once one scan, then exit
Started by a systemd USER unit on the host. Never as root, never inside a
container.
A session's hooks drop marker files when it wants attention or has finished;
this turns those into ntfy pushes. It runs host-side because it is the trust
boundary: a possibly-compromised session writes the markers, and the phone they
reach is outside the sandbox. Nothing the session writes is used as a command,
a URL or a topic - the marker only says which of a fixed set of events happened.
Does nothing unless [remote-access] enabled is on and a notify URL is set.
See docs/remote-access.md.
DEJIMA_NOTIFY_WATCH_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+63 -2
View File
@@ -8,6 +8,43 @@
# Usage: dejima-project <status|approve> [<path>]
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_PROJECT_HELP'
dejima-project - review and approve what a project declares it needs
Usage: dejima-project <status|approve> [path]
status [path] what the project declares, and whether each entry is
approved, pending, rejected as malformed, or beyond the cap
approve [path] approve everything pending, after showing it and asking
path defaults to the current directory.
A project declares its requirements in files inside its own checkout:
.dejima/dependencies packages its image should carry
.dejima/allowed-domains hosts its sessions may reach
.dejima/corpus rules about how its history is recorded
Those are declarations, not grants. They live in the workspace, which a session
can write, so nothing they contain takes effect until it is approved here and
recorded in a store outside the container's reach. That is the whole point of
the split: a session can ask, and only an operator can agree.
An entry that fails validation is reported as rejected and never offered for
approval, so a malformed declaration cannot be waved through by answering yes.
approve is interactive and shows everything before it asks once.
See docs/configuration.md.
DEJIMA_PROJECT_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -125,11 +162,14 @@ case "$action" in
report dependencies valid_dep_spec
echo "allowed-domains:"
report allowed-domains valid_domain
echo "corpus:"
report corpus valid_corpus_rule
;;
approve)
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; } | sed -n 's/^rejected //p')
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; } | sed -n 's/^rejected //p')
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
if [ -n "$trunc_deps" ]; then
@@ -143,13 +183,14 @@ case "$action" in
sed 's/^/ /' <<< "$rejected" >&2
echo >&2
fi
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ]; then
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ]; then
echo "Nothing pending approval for $project."
exit 0
fi
echo "Declared but not approved for $project:"
[ -n "$pending_deps" ] && printf ' dependency: %s\n' $pending_deps
[ -n "$pending_doms" ] && printf ' domain: %s\n' $pending_doms
[ -n "$pending_corpus" ] && printf ' corpus: %s\n' $pending_corpus
echo
printf 'Approve all of the above? [y/N] '
read -r reply || reply=n
@@ -187,7 +228,27 @@ case "$action" in
failed="$failed $dom"
fi
done
# Written through approve_entry directly, unlike the two above. Those
# go through their own CLIs because approving them can also patch a
# RUNNING container's firewall or image; a corpus rule changes what the
# next session is told and nothing about a live one, so there is
# nothing to patch and no CLI to route through.
corpus_failed=""
corpus_approved=0
for rule in $pending_corpus; do
if approve_entry "$(ensure_project_store "$project")" corpus "$rule"; then
corpus_approved=1
else
corpus_failed="$corpus_failed $rule"
fi
done
echo
if [ -n "$corpus_failed" ]; then
echo "NOT approved for $project (could not record):$corpus_failed" >&2
fi
if [ "$corpus_approved" = 1 ]; then
echo "Approved corpus rules for $project. They take effect at the next launch."
fi
if [ -n "$dep_failed" ]; then
echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
echo "Fix the cause and re-run; nothing was recorded for those." >&2
+26
View File
@@ -235,10 +235,27 @@ _approved_validator() {
case "$1" in
allowed-domains) printf 'valid_domain' ;;
dependencies) printf 'valid_dep_spec' ;;
corpus) printf 'valid_corpus_rule' ;;
*) return 1 ;;
esac
}
# The rules a project can declare in .dejima/corpus, each of which changes
# what the session briefing tells the agent about how to record its work.
#
# A fixed set rather than a free identifier, and refusing the unknown is the
# point: an entry nothing implements would approve cleanly, appear in the
# store, and do nothing - which reads as the rule being in force when it is
# not. There is one today.
DEJIMA_CORPUS_RULES="commit-per-step"
valid_corpus_rule() {
case " $DEJIMA_CORPUS_RULES " in
*" $1 "*) return 0 ;;
esac
return 1
}
# Approvals are host-side and trusted, so an entry that fails validation
# means a bug or tampering, not user error - refuse rather than proceed.
approved_entries() {
@@ -608,6 +625,15 @@ remote_ttyd_font_size() {
# interpolated into the page's client options, and a named-chord set is one
# fewer place an escaping question can be got wrong. A stock tmux does not
# use either of those two anyway.
# Whether tapping the terminal page raises the soft keyboard. Unset is the
# useful default and is deliberately NOT a bool default of off: the page
# decides per client from the pointer type, which a host-wide setting cannot
# see. Only a value the operator actually wrote is passed on.
remote_ttyd_focus_on_tap() {
config_is_set remote-access ttyd_focus_on_tap || return 0
config_get remote-access ttyd_focus_on_tap
}
remote_ttyd_tmux_prefix() {
config_get remote-access ttyd_tmux_prefix
}
+159 -2
View File
@@ -24,7 +24,10 @@ MAX_MANIFEST_LINES=200
# enough (see _provider_keys_field) that an external process per call is
# real, avoidable cost; every caller already invokes this file by a path
# containing a "/", so the two are equivalent here.
_PROVIDER_ROOT_CACHE=""
# Resolved once, here, for the same subshell reason as the schema loads below:
# filling this on first use meant filling it again in every `$(...)` that
# reached it, which was a fork apiece and hundreds of them per launch.
_PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
provider_root() {
if [ -n "${DEJIMA_PROVIDER_ROOT:-}" ]; then
printf '%s' "$DEJIMA_PROVIDER_ROOT"
@@ -140,13 +143,14 @@ capabilities ?list:identifier
pr_command ?command
issue_command ?command
merge_block ?command
disclose ?disclosures
keys ?list:keyspec
requires ?list:identifier
host_keys ?list:identifier
url_keys ?list:identifier
fixed_hosts ?list:hostname
hosts_allowlisted_by_core bool off
install ?list:relpath
install ?list:installspec
env_export ?list:export
EOF
;;
@@ -226,6 +230,27 @@ _manifest_load_schema() {
_MFT_LOADED="$_MFT_LOADED$axis "
}
# Every axis, at source time, for the reason _config_load_schema is called at
# source time: manifest_get is reached through `$(...)` almost everywhere, and
# a cache populated in a subshell is discarded with it. There are three axes
# and the tables are compile-time constants, so loading all of them costs one
# pass and removes the re-read from every subshell that follows.
_manifest_load_schema forge
_manifest_load_schema workflow
_manifest_load_schema harness
# Every shipped provider's directory, resolved here for the same reason.
# provider_dir does two readlinks and a containment check per call, caches the
# result, and is then called from inside `$(manifest_get ...)` - so the cache
# was rebuilt in each subshell and the readlinks paid again. There are a
# handful of provider directories and the resolution cannot change during a
# run, so doing all of them once costs a fixed few forks and removes the
# repeat from every manifest read that follows.
#
# Failures are ignored: an unreadable or missing directory is not this loop's
# to report, and provider_dir refuses it again with its own message when
# something actually asks for it.
_manifest_spec() {
local axis="$1" want="$2"
_manifest_load_schema "$axis"
@@ -251,6 +276,57 @@ _provider_valid_value() {
# traversal - a manifest may only name a file it ships.
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] && [[ "$value" != *".."* ]]
;;
disclosures)
# Which of this forge's commands write a body a person authored,
# and what kind of artifact each one writes. Comma-separated
# because every entry is multi-word, unlike every other list here.
#
# disclose = pr:<cli> create, issue:<other cli> create
#
# The kind selects the marker; the marker text itself is core's
# (docker/forge-guard.sh), so that what dejima says about agent
# authorship cannot come out differently per forge.
# An empty entry is a typo, not an entry to skip - the same
# reading the list types take of a trailing separator. Checked on
# the raw string, because word splitting discards it silently.
case "$value" in ,*|*,) return 1 ;; esac
[[ "$value" =~ ,[[:space:]]*, ]] && return 1
local entry kind rest
local IFS=,
for entry in $value; do
# Leading and trailing space around each comma-separated entry.
entry="${entry#"${entry%%[![:space:]]*}"}"
entry="${entry%"${entry##*[![:space:]]}"}"
[ -n "$entry" ] || return 1
case "$entry" in
pr:*|issue:*|comment:*) ;;
*) return 1 ;;
esac
kind="${entry%%:*}"
rest="${entry#*:}"
[ -n "$kind" ] || return 1
_provider_valid_value command "$rest" || return 1
done
;;
installspec)
# A CLI this forge puts on PATH: either a file it ships, or
# "system:<name>" for one the image already provides.
#
# The second form exists because a forge CLI has to be reachable
# behind the shadow whether or not this repo wrote it - that is
# where docker/forge-guard.sh execs the real command through, and
# a forge naming a command that never gets there has a merge block
# that cannot be enforced. Declaring it here rather than special
# -casing the name in the image build is also what keeps the
# manifest a complete account of what this forge puts in /usr/local
# /bin, which is the reading the audit depends on.
case "$value" in
system:*)
[[ "${value#system:}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
;;
*) _provider_valid_value relpath "$value" ;;
esac
;;
homepath)
# A directory the harness needs inside the sandbox user's home,
# created in that harness's image layer. Relative to $HOME and
@@ -374,6 +450,27 @@ manifest_validate() {
return "${_MF_STATUS[$id]}"
}
# Every shipped provider's DIRECTORY, resolved here. Same subshell reason as
# the schema loads above: provider_dir does two readlinks and a containment
# check per call, caches the result, and is reached from inside
# `$(manifest_get ...)` - so each of those subshells rebuilt the cache and
# paid the forks again. There are a handful of providers and the resolution
# cannot change during a run.
#
# Deliberately NOT the manifests. Parsing those means validating every value
# in them, and most providers are not the selected one - warming all of them
# measured slower than leaving them alone. bin/dejima warms the three it
# actually selected instead, once it knows which those are.
#
# Failures are ignored: a missing or unreadable directory is not this loop's
# to report, and provider_dir refuses it again when something asks.
for _axis in forge workflow harness; do
for _name in $(provider_list "$_axis" 2>/dev/null); do
provider_dir "$_axis" "$_name" >/dev/null 2>&1 || true
done
done
unset _axis _name
_manifest_parse() {
local axis="$1" name="$2" dir="$3" id="$4" file line key value type line_count
# Staged, and committed to _MF_VAL only once the whole file has passed, so
@@ -926,6 +1023,62 @@ BRIEFING_EOF
# has done something the operator was supposed to decide, and added to a queue
# maintainers are already drowning in. Saying so here is cheaper than
# discovering it afterwards, and the flag is exactly when it becomes possible.
# What a project gets for declaring .dejima/corpus: a rule about commit
# granularity, and the reason for it.
#
# The reason is not style. A plan step is only usable as evidence if the file's
# state immediately before it and immediately after it can both be recovered
# from history - before from the commit's parent, after from the commit. One
# commit carrying several steps that touch the same file destroys that: the
# third step's "after" already contains the fourth's and the fifth's, and
# splitting the diff to attribute a hunk per step would mean constructing a
# file state that never existed in the repository. So the information has to
# be put into the history when the work is done, or it is not recoverable
# afterwards by any amount of cleverness.
#
# Measured rather than assumed, on two repositories with the same author and
# the same planning workflow: the one committing per step yielded usable
# material from its steps, and the one committing once per task yielded none,
# with every step after the first to touch a given file discarded.
#
# The bullet about deletion is measured too, and names branch deletion rather
# than squashing on purpose. Verified here: a squash-merge with the branch
# retained leaves both step commits reachable in a fresh clone, because a clone
# fetches remote branches; the same squash-merge with the branch deleted first
# leaves none. Squashing is what an operator notices; deleting the branch is
# what several forges do by default, and it is the one that destroys the
# record.
#
# Deliberately says nothing about what the corpus is for or who collects it.
# That is the operator's business, and a briefing that argued for it would be
# dejima making a case rather than stating a rule the project already adopted.
_briefing_core_corpus() {
cat <<'BRIEFING_EOF'
This project keeps its history as usable evidence of how the work was done
(it declares .dejima/corpus). That puts one requirement on how you commit:
- Give every plan step that changes a file its own commit, and say so in the
step. Not one commit at the end covering five steps.
- A step that only reads files or runs tests needs no commit. This is about
steps that write, not ceremony.
- Do not squash a task's step commits together before committing them. What
happens at merge time is survivable: a clone fetches remote branches, so a
squash-merge still leaves the step commits reachable through the branch ref.
What does not survive is the branch being DELETED afterwards, which removes
the last thing pointing at them. Several forges offer that as a
merge-time default. If this one does, say so rather than assuming the record
is safe, because everything above can be done perfectly and still yield
nothing.
The reason is that a step is only legible later if the file's state before it
and after it can both be read back from history: before from the commit's
parent, after from the commit. When one commit carries several steps that
touch the same file, no step in it can be isolated any more, and reconstructing
one would mean inventing a file state the repository never held. Nothing can
recover that afterwards, which is why it has to be done while the work is.
BRIEFING_EOF
}
_briefing_core_github_push() {
cat <<'BRIEFING_EOF'
- GitHub push is enabled for this session, for the repository you were
@@ -1205,6 +1358,10 @@ briefing_compose() {
text+="$(_briefing_core_github_push)"
text+=$'\n\n'
fi
if [ "${DEJIMA_CORPUS:-off}" = "on" ]; then
text+="$(_briefing_core_corpus)"
text+=$'\n\n'
fi
# The harness fragment closes the briefing: it supplies both the resume
# command closing_a has just promised and the sentence about where the
# session history it resumes from actually lives. Both are the harness's
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# Makes Ctrl-Z suspend the whole launch instead of disappearing into the
# container. Started in the background by bin/dejima for the duration of the
# container run; not something to run by hand.
#
# `docker compose run` puts the terminal in raw mode to proxy the container's
# TTY, and raw mode has ISIG off - so Ctrl-Z is not a signal at all, it is the
# byte 0x1a, delivered to whatever is running in the container. A harness that
# implements Ctrl-Z itself (nvim and Claude Code both do) then restores the
# terminal and stops, and nothing inside a container can continue it: there is
# no job-control shell in there. The session is wedged, the host never noticed,
# and there is no job to `fg`. That is #179.
#
# So the one character that means "suspend" is taken back for the host, and
# only that one: intr and quit stay undefined, so Ctrl-C and Ctrl-\ still reach
# the session as bytes, which is how a harness expects to be interrupted.
#
# Everything host-side is in one process group - this script, the container
# run, the credential proxy, the conversation shipper - so the kernel stops and
# continues all of it together. Nothing here forwards signals.
#
# On resume the terminal has to be put back. The shell restores its OWN modes
# when it takes the terminal at the prompt, and puts those same modes back on
# `fg` rather than the ones the job was using - measured on both bash and zsh -
# so without this the session comes back line-buffered and echoing. The SIGCONT
# trap re-applies the modes the container run was using, then pokes the group
# with SIGWINCH: the container client answers a resize by resizing the
# container's TTY, which is what makes a full-screen harness repaint over
# whatever the shell printed while the session was away.
set -u
usage() {
cat <<'EOF'
Usage: dejima-tty-suspend
Reclaims Ctrl-Z for the host while a dejima session runs, so that suspending
backgrounds the whole launch and `fg` brings back a working session. Without
it Ctrl-Z is delivered into the container as a keystroke and the session is
left suspended with no way to resume it.
Started in the background by bin/dejima and stopped when the session ends.
Running it by hand does nothing useful: it acts on the terminal it inherits
and exits on its own if no container run takes that terminal over.
Options:
-h, --help Show this help.
EOF
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
"") ;;
*) usage >&2; exit 1 ;;
esac
# A terminal to act on, or nothing to do. bin/dejima starts this in the
# background, where the shell gives stdin /dev/null, so every stty below reads
# the terminal by name rather than from a descriptor.
[ -e /dev/tty ] || exit 0
stty -a < /dev/tty >/dev/null 2>&1 || exit 0
# Wait for the container run to take the terminal, which is what "-isig" in the
# settings means. Applying the reclaim before that would just be overwritten by
# it. Bounded rather than open-ended: a launch that fails before attaching
# should leave this script gone, not waiting.
attached=0
for _ in $(seq 1 100); do
if stty -a < /dev/tty 2>/dev/null | grep -q -- '-isig'; then
attached=1
break
fi
sleep 0.05
done
[ "$attached" = 1 ] || exit 0
# isig turns the signal characters back on; the three that are not wanted are
# undefined by name, so only susp survives. dsusp is not on Linux terminals but
# is named anyway for the cost of nothing, and ignored where it does not exist.
stty isig susp '^Z' intr undef quit undef < /dev/tty 2>/dev/null ||
stty isig susp '^Z' intr undef quit undef dsusp undef < /dev/tty 2>/dev/null ||
exit 0
# What the session's terminal looks like now, to be put back on resume.
session_modes="$(stty -g < /dev/tty 2>/dev/null)" || exit 0
on_cont() {
stty "$session_modes" < /dev/tty 2>/dev/null || true
# The whole process group, since this script does not know the container
# client's pid and SIGWINCH is ignored by default everywhere else in it.
kill -WINCH 0 2>/dev/null || true
}
trap on_cont CONT
# Idle until the session ends and bin/dejima stops this. `wait` rather than a
# bare sleep: a trap runs the moment it interrupts a wait, where it would
# otherwise be held until the sleep finished.
while :; do
sleep 300 &
wait $! 2>/dev/null || true
done
+38
View File
@@ -28,6 +28,35 @@
# must match the run-id shape before it is used as a path component.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_TTYD_HELP'
dejima-ttyd - serve the web terminal that fronts dejima-attach
Usage: dejima-ttyd
Takes no arguments. Started by systemd/dejima-ttyd.service; running it by hand
is only useful for debugging.
Reads ttyd_port from [remote-access] in the config so the port has exactly one
source of truth - the systemd unit deliberately does not repeat it.
Binds 127.0.0.1 only, and that is a requirement rather than a default worth
overriding. Attaching gives a tmux session on the host, so a listener on any
address a container can route to is an escape hatch out of the sandbox;
loopback is not one. Put nginx in front of it, bound to one LAN address - see
docs/nginx-dejima.conf.example.
Serves nothing until [remote-access] enabled is on.
See docs/remote-access.md.
DEJIMA_TTYD_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -62,6 +91,12 @@ font_size="$(remote_ttyd_font_size)"
# C-b; see docs/remote-access.md.
tmux_prefix="$(remote_ttyd_tmux_prefix)"
# Whether a tap on the terminal raises the soft keyboard. Passed on only when
# the operator set it: unset means the page decides from the pointer type,
# which is right for almost everyone and is something a host-wide setting
# cannot see. See docs/remote-access.md.
focus_on_tap="$(remote_ttyd_focus_on_tap)"
# Checked rather than left to exec, whose failure is bash's "ttyd: not found"
# on a line number - true, and useless to an operator who needs to know that
# the missing piece is a package.
@@ -134,9 +169,12 @@ font_opt=()
prefix_opt=()
[ -z "$tmux_prefix" ] || prefix_opt=(-t "tmuxPrefix=$tmux_prefix")
focus_opt=()
[ -z "$focus_on_tap" ] || focus_opt=(-t "focusOnTap=$focus_on_tap")
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
--index "$index_html" \
${font_opt[@]+"${font_opt[@]}"} \
${prefix_opt[@]+"${prefix_opt[@]}"} \
${focus_opt[@]+"${focus_opt[@]}"} \
"$SCRIPT_DIR/dejima-attach"
+92 -1
View File
@@ -80,6 +80,11 @@ harness_state_mounts() {
[ -d "$history/$sid" ] || continue
emit_mount "$history/$sid"
done < <(workspace_session_ids "$projects" "$ws")
# Each installed plugin version, read-only. Here rather than in a hook of
# its own because this is the hook core calls to collect mounts, and the
# plugin tree is state this harness keeps on the host like the two above.
_plugin_version_mounts
}
# Puts dejima's guard hooks where Claude Code will find them: symlinked into
@@ -256,12 +261,98 @@ harness_config_dirs() {
for d in backups tasks plans sessions; do
emit_config_dir "$HOME/.claude/$d"
done
emit_config_dir "$HOME/.claude/plugins" ro
# plugins/ is deliberately absent, and used to be here read-only. See
# harness_seed below: the tree is partly the operator's install and partly
# Claude Code's own bookkeeping, and mounting all of it read-only stopped
# the bookkeeping without protecting anything.
for d in hooks skills commands agents output-styles; do
emit_config_dir "$HOME/.claude/$d" ro scan
done
}
# The plugin tree: which parts of it the session may write, and why the answer
# is not "none".
#
# ~/.claude/plugins was mounted read-only, with the rest of the config
# directories that hold something executable. That was the wrong shape, and
# the symptom was a plugin that would not load. The tree is two things at
# once:
#
# marketplaces/<name>/ the catalogue checkout, and the plugin sources
# cache/<name>/<plugin>/<v>/ each installed version, materialised
# installed_plugins.json what is installed, and at which version
# known_marketplaces.json where those came from
# plugin-catalog-cache.json the catalogue, resolved
#
# Claude Code updates a plugin by materialising the new version alongside the
# old, at cache/<marketplace>/<plugin>/<newversion>/, and then rewriting the
# registry. Read-only, it can do neither: the update fails and the plugin is
# left at a version the catalogue no longer matches, which is what "the plugin
# does not load" turns out to mean. The only way out was to run Claude Code on
# the host and restart the container.
#
# Mounting it read-only protected nothing, either. The concern for hooks/ and
# skills/ is that a session writing them installs something the HOST will
# execute next time. A copy has no such path: the session writes its own copy,
# which goes away with the container.
#
# So: everything the harness rewrites is seeded as a writable copy, and only
# the version directories - the bulk, and already-materialised code - stay
# read-only mounts of the host's. That keeps the 400-odd megabytes of an
# installed MCP server out of a per-launch copy while leaving the session able
# to add a version beside them.
harness_seed() {
local plugins="$HOME/.claude/plugins" f mkt plug ver
# Small, and all of it rewritten in place by Claude Code.
for f in installed_plugins.json known_marketplaces.json \
plugin-catalog-cache.json .last_inuse_sweep; do
emit_seed_from "$plugins/$f" ".claude/plugins/$f"
done
# The catalogue checkout and per-plugin data. Copied rather than mounted
# so a refresh in-session works; a few megabytes, against a mount that
# would make the refresh fail.
emit_seed_from "$plugins/marketplaces" ".claude/plugins/marketplaces"
emit_seed_from "$plugins/data" ".claude/plugins/data"
# The levels above an installed version, created empty and owned by the
# session. Docker makes a bind mount's missing parents root-owned, so
# without these the session could read every mounted version and write no
# new one beside them - which is the failure this whole hook is about.
emit_seed_dir ".claude/plugins/cache"
[ -d "$plugins/cache" ] || return 0
for mkt in "$plugins/cache"/*/; do
[ -d "$mkt" ] || continue
emit_seed_dir ".claude/plugins/cache/$(basename "$mkt")"
for plug in "$mkt"*/; do
[ -d "$plug" ] || continue
emit_seed_dir ".claude/plugins/cache/$(basename "$mkt")/$(basename "$plug")"
done
done
}
# Each installed plugin version, mounted read-only at its own path.
#
# The bulk of the tree by far - one MCP server's node_modules is most of it -
# and none of it is rewritten: an update writes a NEW version directory rather
# than changing an existing one, which is what makes mounting these safe and
# copying them wasteful. harness_seed above owns the writable levels between
# them.
_plugin_version_mounts() {
local plugins="$HOME/.claude/plugins" mkt plug ver
[ -d "$plugins/cache" ] || return 0
for mkt in "$plugins/cache"/*/; do
[ -d "$mkt" ] || continue
for plug in "$mkt"*/; do
[ -d "$plug" ] || continue
for ver in "$plug"*/; do
[ -d "$ver" ] || continue
emit_mount "${ver%/}" ro
done
done
done
}
# Config carried in as copies rather than mounts - see the staged-config
# notes in bin/dejima-harness-lib.sh for why these three are not mounted at
# their real paths.
+89 -29
View File
@@ -53,13 +53,30 @@ harness_briefing_state_dir() {
# to withhold, which is why an open-weight model served locally is supported
# today and api.openai.com is not.
#
# A literal address or `localhost`, never a resolvable name: a name is a
# promise about what DNS will say later, and this has to be decidable now.
# The octets are compared as numbers rather than matched as text - `10.*`
# would accept the hostname 10.example.com as readily as the network, and
# a dotted quad written into this file at all is a shape the suite refuses
# repo-wide because that is how an operator's real LAN address leaks into a
# public repo.
# Two ways an endpoint qualifies, and neither is about the URL's syntax.
#
# A literal loopback or RFC 1918 address is host-local by construction. The
# octets are compared as numbers rather than matched as text - `10.*` would
# accept the hostname 10.example.com as readily as the network - and a dotted
# quad is never written into this file, because that is how an operator's real
# LAN address leaks into a public repo.
#
# A NAME qualifies when the operator has already allowlisted it, through
# `dejima-allow --project` or `--global`. That is the same set that decides
# what the container can reach at all, so an endpoint failing this check is
# one the session could not have connected to anyway. Requiring a literal
# address instead was too strict by half: a model server on someone's own
# network normally has a name, and refusing it made the local-endpoint path -
# the reason this harness is provider-agnostic at all - unusable for the
# ordinary case.
#
# What neither branch is doing is protecting the model credential. dejima
# writes a placeholder into providers.json on this path whatever the endpoint
# is, so no real credential enters the container either way. What this decides
# is whether the configuration can WORK: an endpoint that needs a key would
# get the placeholder and fail, and saying so at launch beats an auth error
# three layers down. Pointing it somewhere is the operator's call, bounded by
# an allowlist entry they made deliberately.
_cline_host_local() {
local url="$1" host a b
host="${url#*://}"
@@ -74,23 +91,42 @@ _cline_host_local() {
case "$host" in
localhost|::1) return 0 ;;
esac
# Four numeric octets, or it is a name.
# Four numeric octets means an address; anything else is a name, and is
# decided by the allowlist below rather than refused here.
local -a oct=()
local numeric=1 o
IFS=. read -ra oct <<< "$host"
[ "${#oct[@]}" -eq 4 ] || return 1
local o
for o in "${oct[@]}"; do
case "$o" in ''|*[!0-9]*) return 1 ;; esac
[ "$o" -le 255 ] || return 1
case "$o" in ''|*[!0-9]*) numeric=0; break ;; esac
[ "$o" -le 255 ] || { numeric=0; break; }
done
if [ "${#oct[@]}" -eq 4 ] && [ "$numeric" -eq 1 ]; then
a="${oct[0]}" b="${oct[1]}"
# Loopback, and the three private ranges. The last of these is where
# Docker's own bridge gateway sits, which is how a container reaches a
# model server running on the host.
[ "$a" -eq 127 ] && return 0
[ "$a" -eq 10 ] && return 0
{ [ "$a" -eq 192 ] && [ "$b" -eq 168 ]; } && return 0
{ [ "$a" -eq 172 ] && [ "$b" -ge 16 ] && [ "$b" -le 31 ]; } && return 0
return 1
fi
_cline_host_allowlisted "$host"
}
# Whether the operator has allowlisted this name, project or global. Read
# through approved_entries so it is the same store, the same validator and the
# same answer bin/dejima uses when it builds the container's allowlist - a
# second reader of that file would be a second thing to keep in step.
_cline_host_allowlisted() {
local host="$1" store entry
for store in "$(config_root)" "${PROJECT_STORE:-}"; do
[ -n "$store" ] || continue
while IFS= read -r entry; do
[ -n "$entry" ] || continue
[ "$entry" = "$host" ] && return 0
done < <(approved_entries "$store" allowed-domains 2>/dev/null)
done
a="${oct[0]}" b="${oct[1]}"
# Loopback, and the three private ranges. The last of these is where
# Docker's own bridge gateway sits, which is how a container reaches a
# model server running on the host.
[ "$a" -eq 127 ] && return 0
[ "$a" -eq 10 ] && return 0
{ [ "$a" -eq 192 ] && [ "$b" -eq 168 ]; } && return 0
{ [ "$a" -eq 172 ] && [ "$b" -ge 16 ] && [ "$b" -le 31 ]; } && return 0
return 1
}
@@ -131,6 +167,8 @@ harness_prepare_config() {
fi
base_url="$(harness_config_get base_url)"
local ctx
ctx="$(harness_config_get context_window)"
# The Anthropic path is the credential proxy's path, and it only works in
# the shape that was actually tested: the proxy on, terminating TLS for
@@ -176,9 +214,15 @@ harness_prepare_config() {
echo "something the sandbox never sees." >&2
echo "" >&2
echo "Supported today: leave base_url unset for Anthropic through" >&2
echo "dejima's own credential proxy, or point it at a model served on" >&2
echo "the Docker host or the sandbox network, which needs no key -" >&2
echo "as a literal address or localhost, not a name." >&2
echo "dejima's own credential proxy, or point it at a model you serve" >&2
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&2
echo "address, or a host you have allowlisted:" >&2
echo "" >&2
echo " bin/dejima-allow --project <path> <host>" >&2
echo "" >&2
echo "The allowlist is what decides whether the container can reach it" >&2
echo "at all, so an endpoint that fails this check is one the session" >&2
echo "could not have connected to anyway." >&2
return 1
fi
@@ -188,25 +232,41 @@ harness_prepare_config() {
# one would land if anything ever wrote one here by mistake.
chmod 700 "$state" 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
# model nobody configured, and the first sign of trouble is a connection
# failure to an endpoint the firewall was never asked to allow. Found by
# running it against a real local model; every earlier test wrote this file
# by hand and happened to include the field.
#
# The launch's own time, because that is what it is: the moment dejima
# wrote this configuration. The file is rewritten every launch anyway.
local now
now="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
local tmp
tmp="$(mktemp "$settings/.providers.XXXXXX")" || return 1
if [ -n "$base_url" ]; then
jq -n --arg m "$model" --arg u "$base_url" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "openai-compatible", modes: {},
providers: {"openai-compatible": {
settings: {provider: "openai-compatible", model: $m,
baseUrl: $u, apiKey: "dejima-no-credential-needed"},
tokenSource: "manual"}}}' > "$tmp"
settings: ({provider: "openai-compatible", model: $m,
baseUrl: $u, apiKey: "dejima-no-credential-needed"}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
else
# The placeholder is what Cline sends; the host-side proxy replaces it
# with the real credential on the way out. Same arrangement as Claude
# Code's faked .credentials.json, confirmed end to end against the API.
jq -n --arg m "$model" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "anthropic", modes: {},
providers: {anthropic: {
settings: {provider: "anthropic", model: $m,
apiKey: "sandbox-credential-proxy-placeholder"},
tokenSource: "manual"}}}' > "$tmp"
settings: ({provider: "anthropic", model: $m,
apiKey: "sandbox-credential-proxy-placeholder"}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
fi || { rm -f "$tmp"; return 1; }
chmod 600 "$tmp"
mv "$tmp" "$settings/providers.json"
+1
View File
@@ -93,6 +93,7 @@ services:
SANDBOX_HARNESS_PROXIED_DOMAIN: ${SANDBOX_HARNESS_PROXIED_DOMAIN:-}
SANDBOX_HARNESS_BRIEFING_ARGS: ${SANDBOX_HARNESS_BRIEFING_ARGS:-}
SANDBOX_HARNESS_BRIEFING_PATH: ${SANDBOX_HARNESS_BRIEFING_PATH:-}
SANDBOX_HARNESS_SEED: ${SANDBOX_HARNESS_SEED:-}
# Opt-in escape hatch from the default hard GitHub-push block - see
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
+80 -20
View File
@@ -117,26 +117,44 @@ if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
fi
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
# The first word of the block names the CLI to shadow. The real one is
# already behind the symlink this replaces (see install-forge-bins.sh), so
# the guard has a fixed path to exec.
_blocked_cli="${DEJIMA_MERGE_BLOCK%% *}"
case "$_blocked_cli" in
*/*|.*|'')
echo "entrypoint: refusing merge-block command name: $_blocked_cli" >&2
exit 1 ;;
esac
if [ -x "/usr/local/lib/dejima/forge/$_blocked_cli" ]; then
ln -sf /usr/local/lib/dejima/forge-guard.sh "/usr/local/bin/$_blocked_cli"
else
# A workflow blocking a command this forge does not ship is a
# misconfiguration that would otherwise be silent - the guard would
# never be reached and the block would look enforced.
echo "entrypoint: [workflow] merge_block names '$_blocked_cli', which forge" >&2
echo "'${DEJIMA_FORGE_NAME:-?}' does not install. Nothing is being blocked." >&2
exit 1
fi
# Every CLI the shadow has something to do about: the one whose merge a
# workflow blocks, and every one that writes a body the session authored. More
# than one, because a forge can have a CLI per artifact rather than one with
# subcommands, and because a forge with nothing blocked can still have
# something to disclose.
#
# The first word of each record names the CLI. The real one is already behind
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
# fixed path to exec.
_shadow_clis() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
_shadow_cli_list="$(_shadow_clis | sort -u)"
if [ -n "$_shadow_cli_list" ]; then
while read -r _cli; do
[ -n "$_cli" ] || continue
case "$_cli" in
*/*|.*|'')
echo "entrypoint: refusing forge command name: $_cli" >&2
exit 1 ;;
esac
if [ -x "/usr/local/lib/dejima/forge/$_cli" ]; then
ln -sf /usr/local/lib/dejima/forge-guard.sh "/usr/local/bin/$_cli"
else
# Naming a command this forge does not ship is a misconfiguration
# that would otherwise be silent - the guard would never be
# reached, and the block or the disclosure would look enforced.
echo "entrypoint: this forge's manifest names '$_cli', which forge" >&2
echo "'${DEJIMA_FORGE_NAME:-?}' does not install. Nothing is being enforced." >&2
exit 1
fi
done <<< "$_shadow_cli_list"
fi
# Put the harness wrapper on PATH under the harness's own command name,
@@ -217,6 +235,48 @@ if [ -d /etc/dejima-harness ]; then
done < <(find /etc/dejima-harness -type f -print0)
fi
# The harness's own writable state. Unlike the staged config above, these are
# owned by the sandbox user and stay writable: they are what the harness
# rewrites as it runs - a plugin registry, a catalogue, an install marker -
# and a harness that cannot record what it just did is a harness that redoes
# it, or refuses to load what it already has.
#
# Nothing written here reaches the host. The host's copy was mounted read-only
# into the staging tree and read once; what the session has is a copy of it.
# That is the whole reason this is a copy rather than a mount.
#
# A destination with nothing staged for it is created empty and left that way.
# That is not a no-op: it is how a directory whose CONTENTS are bind-mounted
# individually comes to be owned by the sandbox user. Docker creates a mount's
# missing parents as root, so without this the session could read the mounted
# entries and add nothing beside them - which is exactly what it needs to do
# when the harness installs a version it did not have.
#
# chown is not recursive past a staged copy, and does not need to be: the
# copied trees hold no mounts, and the directories that do hold mounts have
# nothing staged for them, so the walk never descends into one.
for _rel in ${SANDBOX_HARNESS_SEED:-}; do
case "$_rel" in
/*|*..*)
echo "entrypoint: refusing harness seed path '$_rel': it must be" >&2
echo "relative to the sandbox user's home and must not contain '..'." >&2
exit 1 ;;
esac
_dest="$SANDBOX_HOME/$_rel"
if [ -e "/etc/dejima-seed/$_rel" ]; then
mkdir -p "$(dirname "$_dest")"
# -T so a directory source lands AS the destination rather than
# inside it on a second run, and --no-preserve=ownership because the
# host's uid is not necessarily this container's.
cp -a -T --no-preserve=ownership "/etc/dejima-seed/$_rel" "$_dest" 2>/dev/null \
|| cp -a -T "/etc/dejima-seed/$_rel" "$_dest"
chown -R "$SANDBOX_USER:$SANDBOX_USER" "$_dest"
else
mkdir -p "$_dest"
chown "$SANDBOX_USER:$SANDBOX_USER" "$_dest"
fi
done
# The briefing, rendered before the session starts, for a harness that is not
# handed it by a session-start hook. Two routes and one rendering: the manifest
# names a flag in `briefing_args` for a harness that takes the text on its
+208 -36
View File
@@ -1,12 +1,19 @@
#!/bin/bash
# Shadows a forge CLI on PATH so the one subcommand a workflow blocks is
# refused whatever ran it.
# Shadows a forge CLI on PATH, so that two rules hold whatever ran it: the one
# subcommand a workflow blocks is refused, and a body the session authored says
# so.
#
# The rule is the one hooks/merge-guard.sh applies - a merge is the operator's
# decision, so the sandbox opens a PR and stops - moved off the harness's hook
# protocol and onto the command. It works for a harness that cannot intercept
# a tool call, and it compares argv rather than regex-matching a command
# string, so quoting, spacing and pipelines stop mattering.
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
# operator's decision, so the sandbox opens a PR and stops - moved off the
# harness's hook protocol and onto the command. It works for a harness that
# cannot intercept a tool call, and it compares argv rather than
# regex-matching a command string, so quoting, spacing and pipelines stop
# mattering.
#
# The second is here for the same reason it is not in the forge CLIs: dejima
# writes two of them and not the third, so a disclosure living in the tools is
# one that depends on which forge an operator picked. This is the one place
# every forge passes through.
#
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
# whether a merge is possible is the scope of the token the forge issued. This
@@ -15,6 +22,10 @@
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
# merge". Its first word is the command this shadows;
# the rest is the subcommand sequence being refused.
# DEJIMA_DISCLOSE one record per line, "<kind> <command prefix>", e.g.
# "pr gitea-pr create". Both are resolved host-side by
# bin/dejima from the forge's manifest, never hardcoded
# to a forge here.
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
set -u
@@ -26,37 +37,198 @@ if [ ! -x "$real" ]; then
exit 127
fi
# Outside a dejima container, or with nothing blocked, this is a pass-through.
if [ -z "${DEJIMA:-}" ] || [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
# Outside a dejima container, or with nothing to do, this is a pass-through.
if [ -z "${DEJIMA:-}" ] ||
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ]; }; then
exec "$real" "$@"
fi
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
# The first word names the command; only the rest is a subcommand sequence to
# compare against argv. A block naming a different command than the one being
# run is not this invocation's business.
[ "${#blocked[@]}" -ge 1 ] || exec "$real" "$@"
[ "${blocked[0]}" = "$self" ] || exec "$real" "$@"
want=("${blocked[@]:1}")
if [ "${#want[@]}" -eq 0 ]; then
# The whole command is blocked, not one of its subcommands.
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
exit 1
fi
# argv has to begin with the blocked sequence. Flags before the subcommand are
# skipped so `gitea-pr --verbose merge 7` is still a merge.
args=("$@")
j=0
for w in "${want[@]}"; do
while [ "$j" -lt "${#args[@]}" ] && [ "${args[$j]#-}" != "${args[$j]}" ]; do
j=$((j + 1))
done
[ "$j" -lt "${#args[@]}" ] || exec "$real" "$@"
[ "${args[$j]}" = "$w" ] || exec "$real" "$@"
j=$((j + 1))
# The words of a command prefix, minus the CLI name, appear as a run of
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
# --verbose merge 7` is still a merge.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
plain+=("$arg")
done
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 1
# argv_has <word>... - true when those words appear consecutively in `plain`.
argv_has() {
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# --- the merge block --------------------------------------------------------
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
# The first word names the command; only the rest is a subcommand sequence
# to compare against argv. A block naming a different command than the one
# being run is not this invocation's business.
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
exit 1
fi
if argv_has "${blocked[@]:1}"; then
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 1
fi
fi
fi
# --- the authorship disclosure ---------------------------------------------
[ -n "${DEJIMA_DISCLOSE:-}" ] || exec "$real" "$@"
# The wording is core's, not any forge's, so that dejima says one thing about
# agent authorship however the session reached the forge. The kind is what a
# manifest picks per command.
marker_for() {
case "$1" in
pr) printf '**Summary generated by LLM agent**' ;;
issue) printf '**Filed by LLM agent**' ;;
comment) printf '**Comment by LLM agent**' ;;
esac
}
kind=""
while read -r record_kind record_rest; do
[ -n "$record_kind" ] || continue
read -ra words <<< "$record_rest"
[ "${#words[@]}" -ge 1 ] || continue
[ "${words[0]}" = "$self" ] || continue
if argv_has "${words[@]:1}"; then
kind="$record_kind"
break
fi
done <<< "$DEJIMA_DISCLOSE"
[ -n "$kind" ] || exec "$real" "$@"
marker="$(marker_for "$kind")"
[ -n "$marker" ] || exec "$real" "$@"
# The body is rewritten into a temp file and the argument replaced with its
# path. That covers every spelling including `--body-file -`, where reading
# stdin here is the only way to prepend to it at all and the CLI behind this
# cannot read it twice. Turning --body-file into --body instead would put the
# whole body in argv, where a long one meets the 128KB per-argument limit.
#
# A command with no body argument is passed through untouched: there is
# nothing to disclose on. `gh pr create --fill` is that case - its body comes
# from commit messages, which are prose the session wrote somewhere else.
# The CLI behind this validates its own --body-file and then never gets the
# chance to: what reaches it is a temp file that always exists. So the check
# happens here instead, and refuses rather than sending something the operator
# did not write.
#
# Without it a mistyped path produced a PR whose entire body was the marker -
# created, not refused - and a --body-file naming something unreadable-forever
# (/dev/zero) filled a temp file until the disk did. Both are shapes the CLIs
# already refused before this shim stood in front of them.
body=""
read_body_or_die() {
if [ "$1" = "-" ]; then
body="$(cat)"
return 0
fi
if [ ! -f "$1" ]; then
echo "dejima: --body-file names something that is not a readable file: $1" >&2
exit 1
fi
body="$(cat -- "$1")" || exit 1
}
# The path to a temp file holding the marker and the body. Set outside the
# command substitution as well, so the caller can delete it afterwards.
tmp=""
disclosed() {
tmp="$(mktemp)" || exit 1
printf '%s\n\n%s' "$marker" "$1" > "$tmp" || exit 1
}
# Only the first body argument is rewritten. A second would mean the CLI is
# being passed two, which is its own error to report.
args=()
rewrote=0
while [ "$#" -gt 0 ]; do
if [ "$rewrote" = 0 ]; then
case "$1" in
--body-file)
# Left alone when the value is missing: that is the CLI's
# error to give, not this script's to pre-empt.
if [ "$#" -ge 2 ]; then
read_body_or_die "$2"
disclosed "$body"
args+=(--body-file "$tmp")
rewrote=1
shift 2
continue
fi
;;
--body-file=*)
read_body_or_die "${1#--body-file=}"
disclosed "$body"
args+=("--body-file=$tmp")
rewrote=1
shift
continue
;;
--body)
if [ "$#" -ge 2 ]; then
args+=(--body "$marker
$2")
rewrote=1
shift 2
continue
fi
;;
--body=*)
args+=("--body=$marker
${1#--body=}")
rewrote=1
shift
continue
;;
esac
fi
args+=("$1")
shift
done
# Run rather than exec, only on this path: exec would replace this process and
# leave the temp file behind, and a body is not always small.
if [ -n "$tmp" ]; then
"$real" "${args[@]}"
status=$?
rm -f "$tmp"
exit "$status"
fi
exec "$real" "${args[@]}"
+26
View File
@@ -57,7 +57,33 @@ for conf in "$root"/*/forge.conf; do
declared=""
for rel in $value; do
# "system:<name>" is a CLI the image already provides - `gh`, which
# comes from a package rather than from this repo. It still has to sit
# behind the shadow, because that is the fixed path forge-guard.sh
# execs the real command through, and a forge whose CLI never gets
# there has a merge block the entrypoint cannot enforce. A symlink
# rather than a copy: it is not ours, and it can be large.
case "$rel" in
system:*)
name="${rel#system:}"
case "$name" in
''|*/*|*..*)
echo "forge $forge declares a bad system command: $rel" >&2
exit 1 ;;
esac
# Resolved now rather than trusted: a name that is not in the
# image yet would otherwise become a dangling symlink, and the
# first sign of it would be the guard exiting 127 mid-session.
path="$(command -v "$name" 2>/dev/null || true)"
if [ -z "$path" ]; then
echo "forge $forge declares $rel, which this image does not provide" >&2
exit 1
fi
ln -sf "$path" "$real/$name"
ln -sf "$real/$name" "$dest/$name"
declared="$declared $name"
declared_any=1
continue ;;
/*|*..*|"")
echo "forge $forge declares an out-of-tree install path: $rel" >&2
exit 1 ;;
+116 -8
View File
@@ -16,9 +16,13 @@ set to get a working sandbox: uncomment what you need. `dejima --print-config`
prints the resolved schema with real values, which is the fastest way to see
what a given launch will actually use.
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.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection |
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
@@ -29,6 +33,10 @@ what a given launch will actually use.
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release |
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted |
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one |
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
@@ -87,6 +95,62 @@ A project can declare what it needs in two committed files:
# tarball:<name>@<sha256>:<url> release archive
```
### Declaring that the history is evidence
A third file, approved the same way as the two above:
```
.dejima/corpus # one rule per line; today, commit-per-step
```
An approved `commit-per-step` adds a paragraph to the session briefing
requiring **one commit per plan step that writes a file**, rather than one
commit per task, and explaining why. Absent or unapproved, nothing is added
and nothing changes, which is the default for every project that has not asked
for it.
It goes through approval despite granting no capability, and that is
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the *next*
session's system prompt. `bin/dejima` reads the approval store and never a
project's declarations - a static test holds that it does not so much as name
that directory.
An unknown rule is reported but never offered for approval. One that nothing
implements would approve cleanly, sit in the store and do nothing, which reads
as being in force when it is not.
**Check the forge's merge policy before relying on this.** The briefing tells
the agent how to commit, but the setting that can undo all of it is yours: a
squash-merge is survivable, because a clone fetches remote branches and the
step commits stay reachable through the branch ref, but deleting the branch
afterwards removes the last thing pointing at them. Several forges offer
"delete branch on merge" and some default it on. Measured both ways on a
throwaway repository: squash-merge with the branch retained left every step
commit reachable in a fresh clone; the same merge with the branch deleted first
left none.
So a project can adopt the rule, write every plan correctly, and still yield
nothing, with no error anywhere - a squash-merged `main` looks perfectly
healthy. dejima's own history is intact because it merges with merge commits.
The reason is recoverability rather than tidiness. A step is only legible
afterwards if the file's state immediately before it and immediately after it
can both be read back from history: before from the commit's parent, after
from the commit. One commit carrying several steps that touch the same file
destroys that, because no step in it can be isolated any more, and
reconstructing one would mean building a file state the repository never held.
Nothing recovers that later, so it has to be recorded while the work happens.
It lives in the project rather than in `~/.config/dejima/config` because it is
a fact about the repository. Anyone who clones it and runs dejima gets the same
commit discipline, which is what makes the history uniformly usable: a rule
half the contributors follow produces a history nothing can be taken from.
What dejima does not do is say what the history is *for*. Collecting it, and
deciding whether to, is the operator's business; the briefing states the rule
the project already adopted and stops there.
### Tarball dependencies
For a toolchain no package ecosystem ships in a version you can use - a
@@ -232,6 +296,21 @@ first-run login, though the credential itself never enters the container
(see [security notes](security.md) for the host-side proxy that makes
that work).
Plugins carry over in two pieces, because they are two different things. What
Claude Code rewrites as it runs - the installed-plugin registry, the resolved
catalogue, the marketplace checkout - is copied in as a **writable** copy, so
the session can install or refresh a plugin without reaching your files. Each
already-installed version under `plugins/cache/` is mounted **read-only**
instead: it is the bulk of the tree, and an update writes a new version
directory beside the old rather than changing it.
That split is why a plugin update now works inside a session. The whole tree
used to be mounted read-only, which meant Claude Code could neither materialise
the new version nor record it, and the plugin was left at a version the
catalogue no longer matched - which presents as the plugin simply not loading.
The fix was not to make your files writable: nothing the session writes reaches
them either way, because what it writes is a copy.
`~/.claude` itself is *not* shared as a whole: its contents are enumerated
in `docker-compose.yml`, config files are copied in rather than mounted,
and anything not listed is container-local - writable during the session,
@@ -286,14 +365,43 @@ not yours either. Two shapes are supported:
proxy exactly as Claude Code is. What is written into the container is the
placeholder `sandbox-credential-proxy-placeholder`; the real credential
stays on the host.
- **`base_url` set to a loopback or RFC 1918 address** - a model you are
serving yourself, on the Docker host or the sandbox network. Nothing needs
a credential, so there is nothing to withhold.
- **`base_url` set to a model you serve yourself** - either a loopback or
RFC 1918 address, or a host you have allowlisted with `dejima-allow`.
Nothing needs a credential, so there is nothing to withhold.
A hosted provider other than Anthropic is refused at launch. Its API key
would have to go inside the container, and dejima has no proxy that could
hold one back on its behalf - which is the one property the sandbox does not
trade away. See [development.md](development.md#worked-example-cline).
A name qualifies through the allowlist rather than through how it is spelled,
because that is the same store deciding whether the container can reach the
endpoint at all: one that fails the check is one the session could not have
connected to anyway. A model server on your own network normally has a name,
so requiring a literal address would have made this path unusable for the
ordinary case.
Anything else is refused at launch - a hosted provider's API key would have to
go inside the container, and dejima has no proxy that could hold one back on
its behalf. Note what the check is *not* doing: dejima writes a placeholder
into the container on this path whatever the endpoint is, so no real
credential enters it either way. What the check decides is whether the
configuration can work at all, which is worth saying at launch rather than as
an auth error three layers down. See
[development.md](development.md#worked-example-cline).
If the endpoint serves a model Cline has no catalogue entry for - which is every
model you serve yourself - Cline has no context budget for it either, and will
grow the conversation until the *server* rejects it rather than compacting:
```
rpc error: ... request (16465 tokens) exceeds the available context size (16384 tokens)
```
Set `[harness.cline] context_window` to stop that. With it, Cline compacts
before it reaches the limit instead of overflowing.
Set it a little **under** whatever the server allows. Cline's accounting is
approximate, and a compaction does not always land under target when a single
tool result is large, so a value equal to the server's limit still overflows
occasionally. There is no default: the right number belongs to the model being
served, and a wrong one is worse than none - too high changes nothing, too low
compacts constantly.
`[harness.cline] model` has no default and must be set. Any model id shipped
here would go stale with the next Cline release, and choosing the model an
+34 -11
View File
@@ -32,6 +32,7 @@ run them.
| `bin/dejima-config-lib.sh` | The config file's schema, parser and value validators, plus the dependency-spec grammar. The base layer: it sources nothing, so everything else can source it. Not on PATH |
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH: see [docs/providers.md](providers.md) |
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
| `docs/release.md` | The pass a `development` -> `main` release goes through, and why each part of it is not a test |
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
@@ -341,9 +342,14 @@ against Anthropic through dejima's own credential proxy and the key never
enters the container. Pointed at someone else's hosted API there is no proxy
that could hold a key back, so the key would have to go in - which is R5
failing, not a network restriction. The plugin therefore accepts `base_url`
only for a literal loopback or RFC 1918 address, where an open-weight model
served locally needs no credential at all and R5 has nothing to protect. A
provider-generic proxy would widen this; nobody has written one.
only for a model the operator serves themselves - a loopback or RFC 1918
address, or a host they have allowlisted - where an open-weight model needs no
credential at all and R5 has nothing to protect. The allowlist is what
qualifies a name, because it is the same store deciding what the container can
reach: an endpoint failing the check is one the session could not have
connected to anyway, and a model server on someone's own network normally has
a name rather than a literal address. A provider-generic proxy would widen
this to hosted providers; nobody has written one (#171).
*Conversation logging works; epitaphs do not, and only one of those is
Cline's fault.* Cline's transcripts are
@@ -357,14 +363,30 @@ shape (`text`, `tool_use` with id/name/input, `tool_result`), so the transform
differs only in the envelope, and the same policy holds: prompts, assistant
text and tool invocations, never tool results.
Epitaphs stay absent because **no Cline compaction has been observed**. It has
a `--compaction agentic|basic|off` flag, so summaries presumably exist in some
form, but what one looks like in a `.messages.json` has not been seen - and a
guess would produce a store that either silently keeps nothing or keeps the
wrong record under dejima's promise that the artefact is what the session
wrote. The hook is simply not defined, which `dejima-epitaph` treats as
"nothing was kept" rather than as an error. Whoever sees a real compaction
first should write it down.
Epitaphs stay absent, and now for a settled reason rather than an unexamined
one. Cline's compaction has been observed, against a locally served model with
the context window set small enough to force it: four completed
`auto_compaction` cycles, each dropping the working context from five messages
to three.
**The persisted record was untouched by every one of them.** All five file
reads remained in `.messages.json` in full, tool results included, at close to
twenty kilobytes apiece - the same content the compaction had just removed from
what was being sent to the model. There is no summary record, no marker, and no
gap.
So compaction here is a working-context operation and nothing more. Epitaphs
exist because Claude Code's compaction *destroys* context that survives only as
the summary written into its transcript; the container goes away and the
summary is the only remaining account. Cline destroys nothing on disk, so there
is nothing for an epitaph to preserve, and `harness_extract_epitaphs` stays
undefined because that is the right answer rather than a missing piece.
Worth knowing if this is revisited: the summary Cline builds during compaction
is real but lives only in the event stream, as `auto_compaction` notices
carrying token counts. A run with too small a budget reports
`compaction_budget_emergency` and falls back from `agentic_summary` to plain
trimming. Neither path writes anything to the session record.
### Where a harness's support lives: three tiers
@@ -467,6 +489,7 @@ harness-specific path resolving to nothing is the honest answer.
| Briefing delivery | **by which of three routes**, not whether. Claude Code takes the briefing as a system prompt, through the flag its manifest names in `briefing_args`, because a `SessionStart` payload past roughly nine kilobytes is persisted and replaced with a preview - so the hook route silently under-delivered a briefing carrying a project's `.dejima.md`. Cline takes no such flag and registers no hook, so it declares a `briefing_path` and reads a file `entrypoint.sh` writes root-owned and read-only. A harness doing neither still gets it through the hook. What the briefing *says* is none of theirs: composing it host-side is core's, and the container-side part - this container's id, the declared-versus-installed gap, the project's `.dejima.md` - is one script every route calls | `hooks/dejima-briefing-render.sh`, `hooks/sandbox-context.sh`, `docker/entrypoint.sh` |
| Notifications | `Notification`/`Stop` hooks, registered per session into the settings layer, are what drop the markers remote access watches | `hooks/dejima-notify.sh`, `bin/dejima` |
| Config parity | which directories carry over and at what mode. Declared by the plugin, since `docker-compose.yml` is static YAML and could only name one harness's | `bin/harness/claude-code.sh` |
| Seeded state | which of the harness's own state it must be able to REWRITE, carried in as a writable copy owned by the session. Declared by the plugin through `emit_seed_from` and `emit_seed_dir`; the seed tree mirrors the destination, like staged config, so `entrypoint.sh` names no harness's paths. The distinction from the other two is what a session may do to it, and mounting the wrong thing read-only breaks a harness rather than protecting anything - see the plugin tree | `bin/harness/<name>.sh`, `docker/entrypoint.sh` |
| Staged config | which files are copied rather than mounted. Declared by the plugin; the staging tree mirrors the destination, so `entrypoint.sh` names no harness's paths | `bin/harness/claude-code.sh` |
| App state | `~/.claude.json` is copied rather than mounted, because it mixes live state with `mcpServers` config | `bin/dejima` |
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
+94 -4
View File
@@ -122,14 +122,15 @@ operator can set, resolved the same way as any other config key
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The literal command prefix `hooks/merge-guard.sh` refuses inside the sandbox (e.g. `gitea-pr merge`) - only when the workflow's `block_merge = on`. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
| `host_keys` | `?list:identifier` | (empty) | Config keys (from `keys`) that hold a literal hostname - unioned by `forge_hosts` into the firewall allowlist and the push guard's allowed hosts. |
| `url_keys` | `?list:identifier` | (empty) | Config keys that hold a URL - `forge_hosts` strips scheme and path and unions the bare host in the same way as `host_keys`. |
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (`github` uses this - `github.com`, `ssh.github.com`, `api.github.com` are the same three hosts for every operator, so there's nothing to configure and nothing to get wrong). |
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the extra-domains path, which resolves a name into the port-unrestricted `allowed-domains` ipset and exempts it from DNS-rebind protection - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the firewall's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
| `install` | `?list:relpath` | (empty) | Files under this forge's own directory that ship into `/usr/local/bin` of the image - the only manifest key naming something that actually gets copied and executed. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build both when a declared file is missing and when a forge ships a file under its `bin/` that it does not declare. A forge with `install =` empty therefore puts nothing executable in the image. |
| `install` | `?list:installspec` | (empty) | The CLIs this forge puts on PATH in the image - the only manifest key naming something that actually gets copied and executed. Either a path under the forge's own directory, or `system:<name>` for a command the image already provides from a package. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build when a declared file is missing, when a declared `system:` command is not in the image, and when a forge ships a file under its `bin/` that it does not declare. Every command a forge names anywhere - `pr_command`, `issue_command`, `merge_block`, `disclose` - has to appear here, because a CLI that is not behind the shadow is one the guards cannot reach: the entrypoint refuses to start rather than leave a block or a disclosure looking enforced. |
| `env_export` | `?list:export` | (empty) | `NAME=key` (the config key's resolved value) or `NAME=@key` (the *contents* of the file the config key names) pairs - the environment variables `bin/dejima` passes into the container for this forge. The `@` form is how a token reaches the container without its path being mistaken for the secret. |
Worked example, `forges/gitea/forge.conf`:
@@ -268,9 +269,13 @@ opposite of "providers are data, never sourced." In reading order:
for the agent too) - fixed prose, not overridable by any provider.
2. The selected workflow's fragment, with its worktree/capability/autonomy
markers resolved.
3. The core closing text (how to hand a task back to the operator when the
3. The commit-granularity rule, when the project ships a `.dejima/corpus`
file - see [configuration.md](configuration.md#declaring-that-the-history-is-evidence).
Conditional core prose, gated the same way the GitHub-push paragraph is,
and absent for every project that has not declared it.
4. The core closing text (how to hand a task back to the operator when the
sandbox genuinely can't do it).
4. The selected harness's fragment: how to resume this session on the host,
5. The selected harness's fragment: how to resume this session on the host,
and where the history it would resume from lives. Both are the harness's
own facts rather than core's - Claude Code resumes from a bind-mounted
`~/.claude`, Cline from a per-project state directory the command has to
@@ -408,6 +413,76 @@ every launch:
| harness | `claude-code` | hosts every guard; **not a default** - `[core] harness` must be set |
| harness | `cline` | provider-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
### Harness integration maturity
Both harnesses are supported, and supported means the same thing for each: a
manifest, a plugin, tests, and the capability bar in
[development.md](development.md) either met or declared unmet. It does not mean
they have had the same amount of use, and the gap is wide enough to say so
rather than let the word "supported" imply otherwise.
**Claude Code is the harness dejima was built around.** Nearly every mechanism
here - the credential proxy, the briefing, the per-project state mounts, the
exit audit - was designed against it and generalised afterwards. It hosts all
four guards. Its rough edges were found by running it for real work, over and
over, which is a kind of coverage no test suite substitutes for.
**Cline is new, and has barely been used.** Its integration was built
deliberately and is tested, but a test only covers what someone thought to
check. The first run against a live model immediately turned up a bug no test
had: Cline silently discards a provider entry with no `updatedAt` and falls back
to its own hosted endpoint, which presents as a connection failure to a host
nobody configured. The first real session turned up a second, in a component
Cline does not own at all - the conversation shipper passed its payload as a
command-line argument, which Cline's larger tool results overflow. It hosts no
guards, so every launch names what is not in force.
Neither of those was a bad integration. That is the normal rate of discovery
for something this new, and it is the reason to expect more.
So: if you are choosing between them with no other reason to prefer one, choose
`claude-code`. Choose `cline` for what it offers that the other does not - it
can be pointed at any endpoint, including a model you serve yourself - and
expect to be the person who finds the next crack.
### Forge integration maturity
The same caveat the harnesses get, for the same reason: two forges listed in
one table invites the reading that each got the same consideration, and they
did not.
**gitea is the forge dejima is developed on.** Every PR in this repo was
opened through it, every issue filed through it, and its three CLIs are
dejima's own - which is why they do things `gh` does not, like showing only
the review threads still outstanding. Its rough edges were found by using it.
**GitHub has never been run end to end.** It was built from the manifest
outward and tested at the config layer, and what that missed is instructive:
`forge = github` could not start a container at all under `worktree-pr`, the
only workflow that opens PRs, because the merge shadow had no `gh` to shadow.
Ten tests exercised the forge and none of them reached the entrypoint. Fixing
that immediately turned up a second thing, that `gh --repo owner/name pr merge
N` walked straight past the merge block. Neither was a hard bug to find once
anyone looked.
Two known differences remain, both filed rather than fixed:
- **Review feedback is thinner.** `gitea-pr comments` shows unresolved review
threads and hides the rest; GitHub does not expose thread resolution through
the REST API `gh` uses for this, so working through PR feedback there
re-reads threads already dealt with, with no signal that it is doing so.
- **`gh --repo` reaches past the sandboxed repository.** The gitea CLIs refuse
an origin that is not on the configured host, so they can only act on the
repo the sandbox was launched against. `gh` takes `--repo` on nearly every
subcommand, and `forge = github` supplies a deliberately write-capable
token. A per-repository fine-grained PAT is the shape of `push_token_file`
that makes this moot.
So: if you have a choice, `gitea` is the better-worn path. Choose `github`
because that is where your repositories are, which is a good reason - and
expect to be the person who finds the next crack.
**The shipped defaults are deliberately the non-pushing pair**
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
and no forge credential, so any default naming a real forge would be an
@@ -419,6 +494,21 @@ that is true of every fresh install: this sandbox pushes nowhere yet.
Pushing is opted into by naming both a pushing workflow and a forge that
resolves.
**A PR, issue or comment a session writes says an agent wrote it.** The body
is prepended with a marker - "Summary generated by LLM agent" on a PR,
"Filed by LLM agent" on an issue, "Comment by LLM agent" on a comment -
before it reaches the forge. This happens in `docker/forge-guard.sh`, which
shadows the forge's CLI on PATH, rather than in the CLIs themselves: dejima
wrote two of the three and a marker living in the tools would mean the
answer to "does dejima disclose this" depended on which forge an operator
picked. It applies on edits as well as creates, because the marker describes
where the text came from rather than the event that wrote it.
Two things it does not cover. A command that sends no body has none added -
`gh pr create --fill` takes its body from commit messages, and inventing one
would be worse than leaving it. And running a forge CLI on the host, outside
a sandbox, marks nothing: there the person running it wrote the body.
**Selecting `forge = github` is a real posture change, not just a config
choice.** It opens `ssh.github.com:22` for push - the read-only GitHub access
every session gets regardless (clone, fetch, `gh` API calls over port 443)
+88
View File
@@ -0,0 +1,88 @@
# Releasing: development to main
`main` is what an operator installs from, and what the CI hardener reads
`.dejima/allowed-domains` from. Work lands on `development` continuously; a
release is the deliberate act of moving it across, and this is what that act
consists of.
`test/release-pass.sh` runs the parts that can be run and prints the parts that
cannot, chosen from what the release actually changes. Start there.
## Why a pass at all
Every check below exists because something got through without it. The pattern
is always the same shape: the change was correct, the tests were green, and the
thing that broke was outside what any test could see - a container that could
not start, a body that arrived empty, a suite that passed in CI and failed on
the machine the work was done on.
A release is the last point where anyone looks at the whole diff rather than
one change at a time, which makes it the only place some of these are visible
at all.
## 1. The suites
`test/run-confined.sh` and `test/shellcheck.sh` run anywhere.
`test/runtime.bats` needs Docker, which a sandbox does not have. It is the only
thing that exercises the mounts, the firewall and the entrypoint end to end, so
a release is exactly when not running it stops being acceptable. Either run it
on the host, or **check that the privileged CI job ran** - it is pinned to one
self-hosted runner and queues silently when that runner is down, so a pull
request can sit with the runtime half neither green nor red.
"CI covers it" is not "it passed."
## 2. Security review
Not a generic checklist - this project's product is an argument that a boundary
holds, so the review is about that boundary. Over the whole `main...development`
diff:
- **Does a mount widen?** A path that was read-only, a scope that was one
project and is now the home directory.
- **Does a guard move after the thing it guards?** Order is the whole content
of a check.
- **Does a credential cross the boundary that did not before?** Including
indirectly: a variable that reaches the container, a file whose mask moved.
- **Does `.dejima/allowed-domains` gain a name?** That file is read from `main`
by dejima-ci-hardener, which treats what it finds there as reviewed. Adding a
domain widens every future CI job on that runner, from the moment it merges.
- **Did a refusal become a warning?** Or a hard failure become a fallback. Both
are how a boundary stops holding without anyone deciding it should.
- **Did a test stop asserting what its name claims?** A test that still passes
while the behaviour under it is gone is worse than no test.
The reviewer should be told to look for those specifically. A review briefed
only as "look for security problems" reliably returns generic advice about
input validation, because that is what a diff looks like without the context of
what this repository is defending.
## 3. Documentation review
- Every changed behaviour has its documentation changed in the same release.
- No document describes something that no longer exists.
- Nothing is claimed as working that has never been run. Where that is the
case, say so where a reader will meet it - the forge and harness maturity
sections in [providers.md](providers.md) are what this looks like.
## 4. Manual testing
`test/release-pass.sh` prints the list for the paths this release touches. The
standing entries, and why each is not a test:
| Touched | What to do, and why no test does it |
| --- | --- |
| `bin/dejima`, the entrypoint, compose | Launch a real session: mounts, proxy and exit audit only meet each other here |
| `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` | 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 |
| `harnesses/` | Launch the harness against a real model |
## 5. The release pull request
Say what was run and what was not, in the body. 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.
+19 -2
View File
@@ -63,6 +63,7 @@ approvals):
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://`
@@ -494,8 +495,8 @@ contribution is delivering the drag.
### Toolbar
```
ESC CTRL TAB ← ↓ ↑ →
PREFIX ◀W W▶ ZOOM DRAG PASTE KBD
ESC CTRL TAB ← ↓ ↑ → ⏎
PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
```
- `CTRL` is sticky: one tap arms it for the next key, a second tap locks it on,
@@ -507,15 +508,31 @@ PREFIX ◀W W▶ ZOOM DRAG PASTE KBD
- `◀W` and `W▶` are prefix + `p` / prefix + `n`. They are the answer to the
window names in the status bar being unhittable on a phone: two large targets
that are always in the same place.
- `SESS` goes back to the session picker, for switching to another sandbox
without closing the tab. It reloads the page without the run-id, which is
what makes `dejima-attach` show the list again. The session you leave is
unaffected: each client gets its own tmux session grouped onto the real one,
and only that view is reaped when you detach.
- `ZOOM` is prefix + `z`, toggling tmux's pane zoom - the fastest way to make
one pane usable on a small screen.
- `PASTE` reads the phone's clipboard and sends it, wrapped in bracketed-paste
markers when the application has asked for them. It needs a secure context;
on an `http://` front door the browser refuses the read and the page says so
rather than doing nothing.
- `⏎` sends Enter, which is the key a session asks for most and the one a soft
keyboard puts furthest from a thumb.
- `KBD` raises the soft keyboard, which is otherwise awkward to get back once
it has been dismissed.
**Tapping the terminal does not raise the keyboard.** On a phone, tapping is
also how you scroll and select, so every one of those taps used to summon the
keyboard and cost a second tap to put it away. `KBD` is the deliberate way to
ask for it. A desktop is unchanged - there is no soft keyboard to raise, and
clicking to focus is what a terminal does.
Set `ttyd_focus_on_tap = on` to get the old behaviour for every client, or add
`?focus=1` to the URL for one. `?focus=0` forces it off on a desktop.
Copying *out* works through OSC 52: anything that emits it - tmux with
`set-clipboard on`, nvim, your own yank binding - lands in the phone's
clipboard. Nothing needs to be installed for that, and nothing here assumes
+7 -14
View File
@@ -9,6 +9,12 @@ set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
# The marker saying an agent wrote the body is deliberately not here. It is
# applied by docker/forge-guard.sh, which shadows this command inside the
# sandbox, so that it reaches every forge and not only the ones whose CLI
# dejima happens to have written. Outside a sandbox this tool is being run by
# a person, and a person's body is not agent-generated.
function usage() {
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
echo " gitea-issue view NUMBER"
@@ -214,10 +220,6 @@ case "$subcommand" in
echo "gitea-issue create requires --title" >&2
exit 1
fi
body="**Filed by LLM agent**
${body}"
api_or_die "create issue" -X POST "$issues_api" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
@@ -241,12 +243,7 @@ ${body}"
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
fi
if [ -n "$body" ]; then
# The same marker create writes. It describes where the body came
# from rather than the event that wrote it, so an edited body
# keeps the disclosure instead of quietly shedding it.
payload=$(echo "$payload" | jq --arg b "**Filed by LLM agent**
$body" '. + {body: $b}')
payload=$(echo "$payload" | jq --arg b "$body" '. + {body: $b}')
fi
api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
@@ -262,10 +259,6 @@ $body" '. + {body: $b}')
echo "gitea-issue comment requires --body or --body-file" >&2
exit 1
fi
body="**Comment by LLM agent**
${body}"
api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg body "$body" '{body: $body}')"
+7 -9
View File
@@ -3,6 +3,12 @@ set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
# The marker saying an agent wrote the body is deliberately not here. It is
# applied by docker/forge-guard.sh, which shadows this command inside the
# sandbox, so that it reaches every forge and not only the ones whose CLI
# dejima happens to have written. Outside a sandbox this tool is being run by
# a person, and a person's body is not agent-generated.
function usage() {
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
echo " gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
@@ -220,9 +226,6 @@ case "$subcommand" in
parse_body_args "$@"
title="$parsed_title"
base="$parsed_base"
body="**Summary generated by LLM agent**
${body}"
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$current_branch" = "HEAD" ]; then
@@ -273,12 +276,7 @@ ${body}"
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
fi
if [ -n "$body" ]; then
# The same marker create writes. It describes where the body came
# from rather than the event that wrote it, so an edited body
# keeps the disclosure instead of quietly shedding it.
payload=$(echo "$payload" | jq --arg b "**Summary generated by LLM agent**
$body" '. + {body: $b}')
payload=$(echo "$payload" | jq --arg b "$body" '. + {body: $b}')
fi
api_or_die "edit PR #${pr_number}" -X PATCH \
+4
View File
@@ -2,6 +2,10 @@ capabilities = pr issues
pr_command = gitea-pr
issue_command = gitea-issue
merge_block = gitea-pr merge
# Every command that sends a body a person did not write. The wording of the
# marker is core's (docker/forge-guard.sh); this only says which commands carry
# one and what each of them is writing.
disclose = pr:gitea-pr create, pr:gitea-pr edit, issue:gitea-issue create, issue:gitea-issue edit, comment:gitea-issue comment
keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/token
requires = ssh_host api token_file
host_keys = ssh_host
+6 -1
View File
@@ -24,9 +24,14 @@ capabilities = pr issues
pr_command = gh pr
issue_command = gh issue
merge_block = gh pr merge
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
keys = push_token_file:?path
requires = push_token_file
fixed_hosts = github.com ssh.github.com api.github.com
hosts_allowlisted_by_core = on
install =
# gh comes from a package, not from this repo, but it still has to be
# declared: the entrypoint links the merge shadow over a command only when it
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise rather
# than leave a merge block that looks enforced and is not.
install = system:gh
env_export = GH_TOKEN=@push_token_file
+12 -1
View File
@@ -66,7 +66,18 @@ no_confirm_args = --auto-approve true
# base_url absent means the Anthropic path through dejima's credential proxy.
# Set, it means a self-hosted endpoint, and the plugin refuses one that is not
# host-local - see bin/harness/cline.sh for why that is an R5 check.
keys = state_dir:path=~/.local/share/dejima/cline model:?identifier base_url:?url
# context_window is the one Cline cannot work out for itself. It budgets
# against its own catalogue, and for a model it does not recognise - which is
# every self-served one - it has no budget at all, so it grows the conversation
# until the SERVER rejects it ("exceeds the available context size") instead of
# compacting. Set, Cline compacts before it gets there.
#
# No default, because the right value belongs to the model being served and a
# wrong one is worse than none: too high and nothing changes, too low and it
# compacts constantly. Set it a little under whatever the server allows -
# Cline's accounting is approximate and a compaction does not always land
# under target when a single tool result is large.
keys = state_dir:path=~/.local/share/dejima/cline model:?identifier base_url:?url context_window:?int:512:100000000
# Deliberately empty: resuming needs the state directory's path as well as the
# session id, briefing placeholders do not nest, and `template` forbids the
+10 -2
View File
@@ -198,8 +198,16 @@ fi
# the harness having declared it (see `guards` in its manifest).
echo
source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
_harness="$(config_get core harness 2>/dev/null)" || _harness=""
if [ -z "$_harness" ]; then
# A config that does not parse leaves config_get empty for EVERY key, so
# without this the operator is told no harness is set while looking at the
# harness line they just wrote. config_validate names the line actually at
# fault, on stderr, which is the answer to a question this cannot answer.
_harness=""
if config_exists && ! config_validate; then
echo "$CONFIG_FILE could not be read, so no guard hooks were registered."
echo "Fix what is reported above and re-run ./install.sh."
elif ! _harness="$(config_get core harness 2>/dev/null)" || [ -z "$_harness" ]; then
_harness=""
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
echo "registered. Set one and re-run ./install.sh."
else
+139
View File
@@ -467,6 +467,69 @@ write_config() {
[ "$(printf '%s\n' "$output" | wc -l)" -eq "$expected" ]
}
@test "warming the config cache does not swallow the reason it is invalid" {
# bin/dejima parses the config once in its own shell before anything reads
# a key, so the parse survives into the subshells that do the reading. That
# warm redirects stderr away, and the parse status is cached - so caching a
# FAILED warm would let the real caller return the failure with the message
# already discarded, refusing a config without ever saying why.
#
# Asserted on a real launch rather than on --print-config, because the warm
# runs before either and this is the path an operator actually takes.
write_config '[core]' 'firewall = maybe'
local workspace; workspace="$(mktemp -d)"
run "$REPO_ROOT/bin/dejima" "$workspace"
rm -rf "$workspace"
[ "$status" -ne 0 ] || { echo "an invalid config launched"; return 1; }
[[ "$output" == *"invalid value"* ]] \
|| { echo "refused without saying why: $output"; return 1; }
[[ "$output" == *firewall* ]] \
|| { echo "the refusal does not name the key: $output"; return 1; }
}
@test "--harness selects a harness for one launch without changing the default" {
# A host keeps one harness as its default and reaches another when it wants
# to. Everything else was already in place - harnesses configure side by
# side under their own [harness.<name>] sections, and the image layer is
# keyed on the harness name so switching switches images. Only the flag was
# missing, which is why this is one line beside --forge and --workflow.
write_config '[core]' 'harness = claude-code' 'forge = none' 'workflow = local-only' \
'[harness.cline]' 'model = m'
run "$REPO_ROOT/bin/dejima" --print-config
[[ "$output" == *"core.harness=claude-code"* ]] \
|| { echo "default not in effect: $output"; return 1; }
run "$REPO_ROOT/bin/dejima" --harness cline --print-config
[[ "$output" == *"core.harness=cline"* ]] \
|| { echo "the flag did not select it: $output"; return 1; }
# And the file is unchanged - a flag is for one launch.
grep -q '^harness = claude-code$' "$CONFIG_DIR/config" \
|| { echo "the flag rewrote the config file"; return 1; }
}
@test "--harness refuses a harness that does not exist" {
# Validated against the same provider set the config key is, so a typo
# fails at the flag rather than at plugin load with a worse message.
write_config '[core]' 'harness = claude-code' 'forge = none' 'workflow = local-only'
run "$REPO_ROOT/bin/dejima" --harness not-a-harness --print-config
[ "$status" -ne 0 ] || { echo "an unknown harness was accepted"; return 1; }
[[ "$output" == *"invalid value for --harness"* ]] || { echo "$output"; return 1; }
}
@test "--harness changes what the session is actually told, not just the printed config" {
# The flag has to reach the composed briefing, or it would select a harness
# for the container while the session was briefed about a different one.
# Cline's fragment names its own resume command, which Claude Code's does
# not, so the two are distinguishable.
write_config '[core]' 'harness = claude-code' 'forge = none' 'workflow = local-only' \
'[harness.cline]' 'model = m'
run "$REPO_ROOT/bin/dejima" --print-briefing
[[ "$output" != *"CLINE_DATA_DIR"* ]] || { echo "default briefed as cline"; return 1; }
run "$REPO_ROOT/bin/dejima" --harness cline --print-briefing
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"CLINE_DATA_DIR"* ]] \
|| { echo "the briefing is still the default harness's: $output"; return 1; }
}
@test "--print-config refuses an invalid config rather than printing defaults" {
write_config '[audit]' 'keep = 0'
run "$REPO_ROOT/bin/dejima" --print-config
@@ -1445,6 +1508,62 @@ EOF
rm -rf "$rw"
}
@test "declaring corpus does not turn it on; approving it does" {
# The security half, and the reason this is not simply "does the file
# exist". .dejima/ is in the workspace, which is session-writable - so a
# session that could turn this on by writing a file would be writing the
# next session's system prompt. The project declares, the operator
# approves, and bin/dejima reads only what was approved.
local ws; ws="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only'
mkdir -p "$ws/.dejima"
printf 'commit-per-step\n' > "$ws/.dejima/corpus"
run bash -c 'cd "$1" && "$2" --print-briefing' _ "$ws" "$REPO_ROOT/bin/dejima"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"its own commit"* ]] \
|| { echo "a declaration alone put the rule in force"; return 1; }
yes | "$REPO_ROOT/bin/dejima-project" approve "$ws" >/dev/null 2>&1
run bash -c 'cd "$1" && "$2" --print-briefing' _ "$ws" "$REPO_ROOT/bin/dejima"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"its own commit"* ]] \
|| { echo "approving it did not put the rule in force: $output"; return 1; }
rm -rf "$ws"
}
@test "an unknown corpus rule is refused rather than approved and ignored" {
# A rule nothing implements would approve cleanly, sit in the store, and
# do nothing - which reads as being in force when it is not.
local ws; ws="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only'
mkdir -p "$ws/.dejima"
printf 'commit-per-hour\n' > "$ws/.dejima/corpus"
run "$REPO_ROOT/bin/dejima-project" status "$ws"
[[ "$output" == *"commit-per-hour"* ]] || { echo "not reported at all: $output"; return 1; }
[[ "$output" != *"pending commit-per-hour"* ]] \
|| { echo "an unimplemented rule is offered for approval: $output"; return 1; }
rm -rf "$ws"
}
@test "the corpus rule is resolved before --print-briefing exits" {
# --print-briefing composes and exits early, so anything it needs has to
# be resolved above it. DEJIMA_RW_MOUNTS was caught by exactly this once,
# its sentence missing from the printed briefing while real sessions were
# told correctly - a diagnostic that disagrees with the session is worse
# than no diagnostic.
local ws; ws="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only'
mkdir -p "$ws/.dejima"
printf 'commit-per-step\n' > "$ws/.dejima/corpus"
yes | "$REPO_ROOT/bin/dejima-project" approve "$ws" >/dev/null 2>&1
run bash -c 'cd "$1" && "$2" --print-briefing' _ "$ws" "$REPO_ROOT/bin/dejima"
[[ "$output" == *"its own commit"* ]] \
|| { echo "--print-briefing disagrees with what a session would get"; return 1; }
rm -rf "$ws"
}
@test "a read_write entry overlapping a protected path is refused, not warned" {
_rw_setup
workspace="$(mktemp -d)"
@@ -1920,6 +2039,26 @@ _epitaph_transcript() {
|| { echo "wrote a harness config anyway"; return 1; }
}
@test "install.sh names the broken line rather than blaming the harness" {
# A config that does not parse leaves config_get empty for every key, so
# the harness read comes back empty too - and reporting that as "no
# harness is set" sends the operator to a line that is already correct.
# The key here is real: it is one a newer dejima accepts, which is how an
# operator arrives with a config their checkout does not know yet.
printf '[core]\nharness = claude-code\nforge = none\nworkflow = local-only\n' \
> "$CONFIG_DIR/config"
printf '[harness.claude-code]\nno_such_key = 1\n' >> "$CONFIG_DIR/config"
run_install
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"no_such_key"* ]] \
|| { echo "did not name the offending key: $output"; return 1; }
[[ "$output" != *"No [core] harness is set"* ]] \
|| { echo "blamed the harness line, which is correct: $output"; return 1; }
[ ! -f "$CONFIG_DIR/.claude/settings.json" ] \
|| { echo "registered guards from a config it could not read"; return 1; }
}
@test "[mounts] readonly is refused when it names the filesystem root" {
# Root contains every guarded store, so mounting it hands the session all
# of them at once - the approval store, the audit reports, the epitaphs.
+27
View File
@@ -124,6 +124,33 @@ await_shipped() {
# The two loss paths a SIGTERM without a trap produces. bin/dejima kills this
# immediately after the container exits, and the tail of a transcript is the
# part worth having - it is usually the agent's final answer.
@test "a batch too large for an argv argument still ships" {
# A single argument is capped at 128 KiB (MAX_ARG_STRLEN). The payload used
# to be passed to jq and then to curl that way, so a batch of long lines
# failed with "argument list too long" and was dropped silently - the
# shipper keeps running, and only the conversation goes missing.
#
# Not a remote possibility: the transform clips a message at sixteen
# kilobytes and the batch holds twenty, so any harness whose tool results
# are large produces this as a matter of course. Found running Cline.
start_loki
write_config
mkdir -p "$(slug_dir)"
local big
big="$(head -c 60000 /dev/zero | tr '\0' 'x')"
local i
for i in 1 2 3 4 5; do
say "chunk-$i-$big" >> "$(slug_dir)/s1.jsonl"
done
start_ship
# The marker is at the START of one of those lines, so finding it means the
# whole oversized batch was accepted rather than one small line sneaking
# through beside a dropped one.
await_shipped "chunk-5-" || { echo "an oversized batch never arrived"; return 1; }
run jq -r '.streams[0].stream.job' <<< "$(shipped | head -1)"
[ "$output" = "dejima-conversation" ] || { echo "malformed payload: $output"; return 1; }
}
@test "a batched line survives the kill that ends the session" {
start_loki
write_config
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Drives bin/dejima-tty-suspend the way a user drives Ctrl-Z, and reports.
Ctrl-Z cannot be tested by sending a signal: the whole question is whether the
terminal turns that keystroke INTO a signal, or passes it through as a byte.
That needs a real terminal, and a real job-control shell to suspend a job and
bring it back - the kernel discards SIGTSTP sent to an orphaned process group,
which is what a launcher started any other way ends up in.
So: a pty, an interactive shell on it, and a stand-in for the container run
that does the one thing that matters here - put the terminal in raw mode and
proxy keystrokes onward. Docker is not available where these tests run, and is
not what is being tested; the terminal handling is.
Usage: tty-suspend-harness.py <helper-script> <shell>
Prints one "PASS <name>" or "FAIL <name> <detail>" per line, and exits nonzero
if any failed.
"""
import fcntl
import os
import pty
import select
import struct
import sys
import termios
import time
HELPER = os.path.abspath(sys.argv[1])
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
WORK = os.environ.get("TTY_SUSPEND_WORK") or "/tmp"
RECEIVED = os.path.join(WORK, "received")
LAUNCHER = os.path.join(WORK, "launcher.sh")
CONTAINER = os.path.join(WORK, "container.py")
# A stand-in for `docker compose run`: raw mode, then proxy stdin onward.
# "Onward" is a file, which is what lets a test see whether a keystroke reached
# the container or was turned into a signal before it could.
open(CONTAINER, "w").write('''#!/usr/bin/env python3
import os, signal, sys, termios, tty
fd = 0
saved = termios.tcgetattr(fd)
signal.signal(signal.SIGWINCH,
lambda *a: (sys.stdout.write("CONTAINER-REDRAW\\r\\n"), sys.stdout.flush()))
tty.setraw(fd)
sys.stdout.write("CONTAINER-ATTACHED\\r\\n"); sys.stdout.flush()
open(%r, "wb").close()
try:
while True:
try:
b = os.read(fd, 1)
except OSError:
break
if not b:
break
with open(%r, "ab") as fh:
fh.write(b)
if b == b"\\x1a":
# What a harness that implements Ctrl-Z does, and why the byte
# reaching the container is fatal: it stops itself, and there is no
# job-control shell in a container to continue it.
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
sys.stdout.write("CONTAINER-SUSPENDED-ITSELF\\r\\n"); sys.stdout.flush()
os.kill(os.getpid(), signal.SIGSTOP)
tty.setraw(fd)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
''' % (RECEIVED, RECEIVED))
# dejima's shape: background children in one process group, the helper, then
# the container run in the foreground.
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
# existed. It is the control: a harness that cannot see the bug cannot be
# trusted to see the fix either.
start_helper = "" if os.environ.get("TTY_SUSPEND_NO_HELPER") else \
'"%s" &\nhelper=$!' % HELPER
open(LAUNCHER, "w").write('''#!/bin/bash
sleep 300 &
sleep 300 &
%s
python3 %s
kill "${helper:-0}" 2>/dev/null || true
echo LAUNCHER-DONE
''' % (start_helper, CONTAINER))
# 77 means "no terminal to test with", which bats turns into a skip. A machine
# that cannot allocate a pty cannot answer the question either way, and a
# failure there would say something untrue about the code.
try:
master, slave = pty.openpty()
except OSError as exc:
sys.stderr.write("no pty available: %s\n" % exc)
sys.exit(77)
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 100, 0, 0))
pid = os.fork()
if pid == 0:
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
os.dup2(slave, 0); os.dup2(slave, 1); os.dup2(slave, 2)
if slave > 2:
os.close(slave)
os.close(master)
env = dict(os.environ, PS1="PROMPT> ")
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
os.execvpe(SHELL, args, env)
os.close(slave)
seen = []
failures = []
def pump(seconds):
end = time.time() + seconds
while time.time() < end:
r, _, _ = select.select([master], [], [], 0.05)
if r:
try:
chunk = os.read(master, 4096)
except OSError:
return
if not chunk:
return
seen.append(chunk.decode("utf-8", "replace"))
def expect(text, timeout=8.0):
"""Wait for text to appear in output since the last call to forget()."""
end = time.time() + timeout
while time.time() < end:
if text in "".join(seen):
return True
pump(0.1)
return text in "".join(seen)
def forget():
del seen[:]
def send(s):
os.write(master, s.encode())
pump(0.3)
def lflag():
return termios.tcgetattr(master)[3]
def received():
try:
with open(RECEIVED, "rb") as fh:
return fh.read()
except FileNotFoundError:
return b""
def check(name, ok, detail=""):
print(("PASS " + name) if ok else ("FAIL %s %s" % (name, detail)))
if not ok:
failures.append(name)
try:
pump(1.0)
forget()
send("bash %s\n" % LAUNCHER)
check("the container run attached", expect("CONTAINER-ATTACHED"))
for cycle in (1, 2):
before = received()
forget()
send("\x1a")
stopped = expect("Stopped", 5.0) or expect("suspended", 0.5)
check("cycle%d-suspends-the-job" % cycle, stopped)
check("cycle%d-nothing-reached-the-container" % cycle,
received() == before, repr(received()[-8:]))
forget()
send("echo SHELL-ALIVE-%d\n" % cycle)
check("cycle%d-shell-usable-while-suspended" % cycle,
expect("SHELL-ALIVE-%d" % cycle, 5.0))
forget()
send("fg\n")
expect("CONTAINER-REDRAW", 5.0)
flags = lflag()
check("cycle%d-raw-mode-restored" % cycle,
not (flags & termios.ICANON) and not (flags & termios.ECHO),
"ICANON=%s ECHO=%s" % (bool(flags & termios.ICANON), bool(flags & termios.ECHO)))
check("cycle%d-redraw-poked" % cycle, "CONTAINER-REDRAW" in "".join(seen))
check("cycle%d-session-alive" % cycle, "LAUNCHER-DONE" not in "".join(seen))
before = received()
send("typed\n")
pump(0.8)
check("input-reaches-the-resumed-session", received() != before)
before = received()
send("\x03")
pump(0.8)
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
repr(received()[-4:]))
finally:
try:
os.killpg(os.getpgid(pid), 9)
except Exception:
pass
sys.exit(1 if failures else 0)
+64 -5
View File
@@ -482,9 +482,14 @@ assert_remedy_forges_exist() {
}
@test "the hook substitutes only the two runtime placeholders" {
# env -u, because these run inside a dejima sandbox as often as not, and
# there the briefing-delivery variables are set - the hook then emits only
# the session id and never reads the file under test. That made this one
# fail and the next one pass for the wrong reason, its assertion being
# satisfied by the session id the stub carries anyway. See .dejima.md.
b="$(mktemp)"
printf 'session=@@SESSION_ID@@ container=@@CONTAINER_ID@@\n' > "$b"
run env DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
'printf "{\"session_id\":\"abc123\",\"cwd\":\"$PWD\"}" | bash "$1"' _ \
"$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -507,7 +512,7 @@ assert_remedy_forges_exist() {
printf 'resume-cmd: dejima --resume @@SESSION_ID@@\ncontainer=@@CONTAINER_ID@@\n' > "$b"
# Session id crafted to test each special case in the replacement context.
# Use printf %s so the value is never interpreted by the shell.
run env DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
'printf "{\"session_id\":%s,\"cwd\":\"%s\"}" "$(printf "%s" "$1" | jq -Rs .)" "$PWD" | bash "$2"' _ \
'pr-42&rm -rf /tmp\test/path' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -522,7 +527,7 @@ assert_remedy_forges_exist() {
}
@test "a missing briefing file degrades rather than failing the session" {
run env DEJIMA=1 DEJIMA_BRIEFING_FILE=/nonexistent/briefing bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE=/nonexistent/briefing bash -c \
'printf "{\"session_id\":\"x\",\"cwd\":\"$PWD\"}" | bash "$1"' _ \
"$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -686,6 +691,57 @@ assert_remedy_forges_exist() {
[[ "$output" == *"say so in your reply"* ]]
}
@test "a project that declares corpus gets the commit-granularity rule" {
DEJIMA_CORPUS=on run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# Matched against the text with its line breaks flattened. The briefing is
# wrapped prose, so any assertion on more than a couple of words is
# otherwise hostage to where a line happens to end - which is how the
# first version of this test failed on text that was present.
local flat
flat="$(printf '%s' "$output" | tr '\n' ' ' | tr -s ' ')"
[[ "$flat" == *"its own commit"* ]] || { echo "no per-step commit rule"; return 1; }
# The reason travels with the rule. A convention with no reason attached is
# one an agent talks itself out of the first time it is inconvenient.
[[ "$flat" == *"state before it and after it can both be read back"* ]] \
|| { echo "the rule is stated without its reason"; return 1; }
[[ "$flat" == *"inventing a file state the repository never held"* ]] \
|| { echo "does not say why splitting the diff is not an option"; return 1; }
# And the two edges that stop it becoming ceremony.
[[ "$flat" == *"needs no commit"* ]] \
|| { echo "does not exempt steps that only read"; return 1; }
[[ "$flat" == *"squash"* ]] \
|| { echo "says nothing about squashing the step commits away"; return 1; }
# And names the act that actually destroys the record. A squash-merge with
# the branch retained leaves the step commits reachable, because a clone
# fetches remote branches; deleting the branch removes the last pointer to
# them. Naming only squashing would let a project follow every rule and
# still yield nothing, under a forge default it never chose.
[[ "$flat" == *"DELETED"* ]] \
|| { echo "does not name branch deletion as the destructive act"; return 1; }
}
@test "a project declaring no corpus gets no commit-granularity rule" {
# Off by default, and the default is the absence of a file rather than a
# setting - so this is what every project that has not asked for it sees.
run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"own commit"* ]] \
|| { echo "the corpus rule leaked into an undeclared project"; return 1; }
}
@test "the corpus rule says nothing about who collects it or why" {
# dejima ships the rule, not the argument for it. A briefing that made the
# case would be dejima lobbying for a use the project already opted into,
# in every session, for every user of that project.
DEJIMA_CORPUS=on run lib briefing_compose gitea worktree-pr claude-code
local forbidden
for forbidden in benchmark corpus-building training "machine learning" dataset; do
[[ "${output,,}" != *"${forbidden,,}"* ]] \
|| { echo "the briefing argues for the use: $forbidden"; return 1; }
done
}
# Two points from docs/responsible-use.md that an agent can act on, as
# opposed to the majority which are the operator's to follow. Kept short on
# purpose: this text is in every session's context, for every user.
@@ -743,8 +799,11 @@ assert_remedy_forges_exist() {
# would promote a deliberately read-only PAT to a pushing credential.
[ "$(lib manifest_get forge github requires)" = "push_token_file" ]
[[ "$(lib manifest_get forge github env_export)" == *"GH_TOKEN=@push_token_file"* ]]
# Ships no CLI: gh is already in the image.
[ -z "$(lib manifest_get forge github install)" ]
# gh comes from a package rather than from this repo, but it is still
# declared: the entrypoint links the merge shadow only over a command that
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise.
# This forge shipping nothing of its own is what "system:" says.
[ "$(lib manifest_get forge github install)" = "system:gh" ]
}
@test "the briefing does not call GitHub read-only when the forge can push to it" {
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# The pre-release gate: everything that can be checked by running it, plus the
# list of what cannot, derived from what the release actually changes.
#
# Run before opening a development -> main pull request. It is deliberately not
# a CI job: half of what it produces is a list of things a person has to go and
# do, and a green tick nobody had to earn is worse than no tick.
#
# Usage: test/release-pass.sh [base] [head] (defaults: main, HEAD)
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base="${1:-main}"
head="${2:-HEAD}"
cd "$root"
case "${1:-}" in
-h|--help)
echo "Usage: test/release-pass.sh [base] [head]"
echo
echo "Runs the gates that can be run here, then prints the manual checks"
echo "the diff between base and head calls for. See docs/release.md."
exit 0 ;;
esac
status=0
note() { printf '\n== %s\n' "$1"; }
fail() { printf ' NOT OK %s\n' "$1"; status=1; }
pass() { printf ' ok %s\n' "$1"; }
note "Confined suites"
if bash test/run-confined.sh; then
pass "every confined suite"
else
fail "confined suites"
fi
note "shellcheck"
# Captured rather than re-run, and the status kept in a variable rather than
# read off $? at a distance: 127 is "not installed", which is a different thing
# from "found faults" and must not be reported as either passing or failing the
# code.
shellcheck_out="$(bash test/shellcheck.sh 2>&1)"
shellcheck_rc=$?
if [ "$shellcheck_rc" = 0 ]; then
pass "shellcheck"
elif [ "$shellcheck_rc" = 127 ]; then
printf '%s\n' "$shellcheck_out"
fail "shellcheck is not installed here - the gate did NOT run"
else
printf '%s\n' "$shellcheck_out"
fail "shellcheck found faults"
fi
note "Privileged suite"
if docker info >/dev/null 2>&1; then
if bash -c 'bats test/runtime.bats'; then
pass "test/runtime.bats"
else
fail "test/runtime.bats"
fi
else
fail "no Docker here - test/runtime.bats did NOT run"
echo " It is the only thing that exercises mounts, the firewall and"
echo " the entrypoint end to end. Run it on the host, or check that"
echo " the privileged CI job RAN rather than queued: it is pinned to"
echo " one self-hosted runner and queues silently when that is down."
fi
# --- what a person has to do, chosen by what the release touches -------------
changed="$(git diff --name-only "$base...$head" 2>/dev/null)" || changed=""
if [ -z "$changed" ]; then
note "No diff between $base and $head - nothing to release."
exit "$status"
fi
touches() { grep -qE "$1" <<< "$changed"; }
note "Manual checks this release calls for"
printed=0
manual() { printf ' [ ] %s\n' "$1"; printed=1; }
touches '^(bin/dejima$|docker/entrypoint|docker/Dockerfile|docker/docker-compose)' &&
manual "Launch a real session. Nothing else exercises the mounts, the
credential proxy and the exit audit together."
touches '^(forges/|bin/dejima-provider-lib|docker/forge-guard)' &&
manual "Open a pull request through EVERY configured forge, and check the
body that arrived. forge = github has never been run end to end."
touches '^(docker/git-guard|hooks/git-push-guard|hooks/merge-guard|docker/forge-guard)' &&
manual "Try the thing the guard refuses, by the route an agent would take."
touches '^(bin/dejima-tty-suspend|bin/dejima$)' &&
manual "Ctrl-Z a real session and fg it back: the terminal handling is
tested against a stand-in, not against a real container run."
touches '^web/' &&
manual "Load the page on a real phone. Nothing here can see a soft keyboard."
touches '^(harnesses/|bin/harness/|docker/harness-wrapper)' &&
manual "Launch each affected harness against a real model."
touches '^(bin/dejima-attach|bin/dejima-ttyd|systemd/)' &&
manual "Attach over the web front door and detach again."
touches '^\.dejima/allowed-domains$' &&
manual "A domain here is read from main by the CI hardener and widens every
future CI job on that runner. Say in the PR what it is for."
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
note "Reviews"
echo " [ ] Security review of the whole $base...$head diff, against this"
echo " project's own invariants - see docs/release.md."
echo " [ ] Documentation review: every changed behaviour has its doc changed,"
echo " and no doc describes something that no longer exists."
note "Then"
echo " Say in the release PR what was run and what was not. A privileged job"
echo " that queued is not a job that passed."
exit "$status"
+45 -4
View File
@@ -1694,11 +1694,16 @@ EOF
}
@test "an argument that is not --once is refused rather than watched forever" {
# Anything unrecognized - a typo, --help - used to fall into the watch
# loop, which for an interactive invocation looks like a hang.
# Anything unrecognized - a typo - used to fall into the watch loop, which
# for an interactive invocation looks like a hang.
#
# --help was on this list until #64, which asked every tool to explain
# itself. It is now a recognized argument that prints and exits 0, so it
# belongs to the --help test rather than here. The property this one is
# about is unchanged: an argument the tool does not know is refused.
conf_with_ntfy
stub_curl
for bad in --help --onces -x once; do
for bad in --onces -x once; do
run timeout 10 "$REPO_ROOT/bin/dejima-notify-watch" "$bad"
[ "$status" -eq 2 ]
[[ "$output" == *"usage:"* ]]
@@ -1937,6 +1942,7 @@ expect_ttyd_argv() {
printf '%s\n' --index "$(dirname "$(readlink -f "$REPO_ROOT/bin")")/web/index.html"
[ -z "${2:-}" ] || printf '%s\n' -t "fontSize=$2"
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
}
# Sentinel on both sides: both are command substitutions, which strip trailing
@@ -2226,6 +2232,40 @@ PY
assert_ttyd_argv 12345 20 C-Space
}
@test "ttyd_focus_on_tap reaches ttyd, and unset sends nothing so the page decides" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# Unset is not "off". The page picks from the pointer type, which is a
# property of the client and not of the host, so a host-wide default here
# would be wrong for whichever half of the clients it did not match.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
# Both settings, spelled the way the config's bool type produces them -
# "off" has to survive the trip, and an is-it-empty test would drop it.
local value
for value in on off; do
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_focus_on_tap = $value"
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 '' '' "$value"
done
# Alongside the other -t knobs, since they share one splice and the wrong
# order would still leave each of them present.
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20' \
'ttyd_tmux_prefix = C-a' 'ttyd_focus_on_tap = on'
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 20 C-a on
}
@test "dejima-ttyd refuses a ttyd_tmux_prefix it cannot parse rather than launching with one" {
# The page refuses an unparseable prefix too, and says so - but it says so
# on the phone, hours later, to whoever happens to press PREFIX. Refusing
@@ -2846,7 +2886,8 @@ remote_doc() {
# config schema (_config_table in bin/dejima-config-lib.sh). Pinned here
# rather than derived so this still catches the doc losing one of them.
local keys key
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix'
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix
ttyd_focus_on_tap'
for key in $keys; do has -F "\`$key\`"; done
# The port default, taken from the library so the two cannot drift.
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# Every confined suite at once, for the case where the whole set is genuinely
# wanted - before opening a pull request, or after touching bin/dejima, which
# nearly every suite reads.
#
# The suites are separate files that share no state, which is what makes this
# safe: each writes into its own BATS_*_TMPDIR, and the two that bind a socket
# ask the kernel for a free port rather than naming one. Run sequentially they
# take about twenty-five minutes on ten cores; run together, about seven, which
# is the difference between checking the whole set and not bothering.
#
# CI does not use this. It runs the same suites as a matrix, one job each, so
# that a failure names the suite it came from without anyone reading a log -
# see .gitea/workflows/tests.yml. This is the local equivalent.
#
# The list is discovered, so a suite added under test/ is picked up the day it
# lands. Only the privileged ones are named, because "needs a live Docker" is
# not visible in the filename.
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root" || exit 1
# runtime.bats builds the image and boots containers; ci-runner.bats asserts
# against a real runner. Neither can run in a dejima sandbox, which is where
# this repo is usually worked on.
PRIVILEGED=" runtime ci-runner "
suites=()
for f in test/*.bats; do
[ -f "$f" ] || continue
name="$(basename "$f" .bats)"
case "$PRIVILEGED" in *" $name "*) continue ;; esac
suites+=("$name")
done
[ "${#suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
# One process per suite. There are fewer suites than cores on anything this
# repo is developed on, and they are shell rather than CPU-bound, so a job
# limit would cost wall clock for no benefit.
out="$(mktemp -d)"
trap 'rm -rf "$out"' EXIT
for s in "${suites[@]}"; do
( bats "test/$s.bats" > "$out/$s" 2>&1; printf '%s' "$?" > "$out/$s.status" ) &
done
wait
# Counted from the TAP output rather than taken from the exit status alone: a
# suite that fails to run at all - a syntax error, or duplicate test names,
# which bats refuses outright - exits nonzero having run nothing, and reporting
# that as "0 failures" is how a green board comes to mean nothing.
failed=0
printf '%-16s %6s %8s\n' suite ran failed
for s in "${suites[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
printf '%-16s %6s %8s' "$s" "$ran" "$bad"
if [ "$ran" -eq 0 ]; then
printf ' <- ran nothing\n'
failed=1
elif [ "$bad" -gt 0 ] || [ "$status" -ne 0 ]; then
printf '\n'
failed=1
else
printf '\n'
fi
done
if [ "$failed" -ne 0 ]; then
echo
for s in "${suites[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
echo "--- $s"
if [ "$ran" -eq 0 ]; then
head -20 "$out/$s"
else
grep -E '^not ok' -A 6 "$out/$s"
fi
echo
done
fi
exit "$failed"
+64
View File
@@ -603,6 +603,70 @@ dexec_node() {
[ "$status" -ne 0 ]
}
@test "a seeded plugin tree is writable, and a mounted version is not" {
# The arrangement plugin updates depend on, asserted in a real container
# because ownership is the whole point and only Docker produces it: it
# creates a bind mount's missing parents as root, so the levels above an
# installed version have to be seeded or the session owns nothing along
# the path.
#
# The failure this guards against is a plugin that will not load. Claude
# Code installs an update by materialising the new version beside the old,
# at cache/<marketplace>/<plugin>/<newversion>/, and then rewriting its
# registry. With the tree mounted read-only it could do neither, and the
# only way out was to run Claude Code on the host and restart.
local test_container seed ver
test_container="$(test_container_name seed)"
seed="$(mktemp -d)"
# A host tree shaped like a real one: bookkeeping, a marketplace checkout,
# and one installed version.
mkdir -p "$seed/state/.claude/plugins/marketplaces/mkt" \
"$seed/state/.claude/plugins/cache/mkt/alpha"
printf '{"installed":true}\n' > "$seed/state/.claude/plugins/installed_plugins.json"
printf 'catalog\n' > "$seed/state/.claude/plugins/marketplaces/mkt/entry"
mkdir -p "$seed/version"
printf 'shipped\n' > "$seed/version/plugin.json"
ver=/home/node/.claude/plugins/cache/mkt/alpha/1.0.0
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e SANDBOX_HARNESS_SEED=".claude/plugins/installed_plugins.json .claude/plugins/marketplaces .claude/plugins/cache .claude/plugins/cache/mkt .claude/plugins/cache/mkt/alpha" \
-v "$seed/state/.claude/plugins/installed_plugins.json:/etc/dejima-seed/.claude/plugins/installed_plugins.json:ro" \
-v "$seed/state/.claude/plugins/marketplaces:/etc/dejima-seed/.claude/plugins/marketplaces:ro" \
-v "$seed/version:$ver:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$SESSION_IMAGE" >/dev/null
wait_ready "$test_container"
# The seeded copies arrived, and belong to the session.
run docker exec "$test_container" cat /home/node/.claude/plugins/installed_plugins.json
[[ "$output" == *'"installed":true'* ]] || { echo "registry not seeded: $output"; return 1; }
run docker exec "$test_container" cat /home/node/.claude/plugins/marketplaces/mkt/entry
[ "$output" = "catalog" ] || { echo "marketplace not seeded: $output"; return 1; }
# Writable, which the mount it replaced was not - this is the registry
# rewrite that used to fail.
run docker exec -u node "$test_container" \
bash -c 'printf "{\"installed\":false}\n" > /home/node/.claude/plugins/installed_plugins.json'
[ "$status" -eq 0 ] || { echo "the registry is not writable: $output"; return 1; }
# A new version can be created beside the mounted one. This is the update.
run docker exec -u node "$test_container" mkdir -p "$ver/../1.1.0"
[ "$status" -eq 0 ] || { echo "cannot install a new version: $output"; return 1; }
# And the installed version itself is still read-only, which is why it can
# be shared from the host at all.
run docker exec -u node "$test_container" bash -c "printf x > $ver/tamper"
[ "$status" -ne 0 ] || { echo "an installed version is writable from the session"; return 1; }
run docker exec "$test_container" cat "$ver/plugin.json"
[ "$output" = "shipped" ] || { echo "the mounted version changed: $output"; return 1; }
docker rm -f "$test_container" >/dev/null 2>&1 || true
rm -rf "$seed"
}
@test "ALLOW_GITHUB_PUSH=1 opens port 22 to GitHub" {
local test_container="$(test_container_name ghpush)"
docker run -d --name "$test_container" \
+16
View File
@@ -40,6 +40,22 @@ mapfile -t scripts < <(list_scripts)
[ "${#scripts[@]}" -gt 0 ] || { echo "found no shell scripts to check" >&2; exit 1; }
cd "$root"
# Said plainly rather than left to `exec` to fail with "not found", which reads
# as a broken script rather than a missing tool - and, on a nonzero exit nobody
# reads closely, as a check that ran.
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck is not installed, so nothing was checked." >&2
echo >&2
echo "It is declared in .dejima/dependencies. In a sandbox that predates" >&2
echo "that declaration, approve it and relaunch so the image is rebuilt:" >&2
echo >&2
echo " dejima-project approve" >&2
echo >&2
echo "Report the suite as not run rather than as passing." >&2
exit 127
fi
# -S warning: info and style are largely taste, and a check whose output gets
# skimmed is not a check. Severities and the disabled classes are in
# .shellcheckrc, beside the reasoning for each.
+676 -32
View File
@@ -484,6 +484,52 @@ _harness_declares_dir() {
# The config directories the selected harness declares, as "path<TAB>mode<TAB>scan"
# lines. Runs the hook rather than grepping the plugin, so these assert what a
# launch would actually mount.
# The seed destinations the harness declares, one per line. Against a fixture
# HOME rather than the operator's, so the set does not depend on which plugins
# happen to be installed on the machine running the tests - except where a test
# says otherwise.
_declared_seeds() {
local home="${1:-$PLUGIN_FIXTURE_HOME}"
bash -c '
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
HOME="'"$home"'"
harness_plugin_load claude-code
harness_seed_load
for r in ${DEJIMA_SEED[@]+"${DEJIMA_SEED[@]}"}; do
printf "%s\n" "${r%%$'"'"'\t'"'"'*}"
done
'
}
# The read-only mounts the harness emits for installed plugin versions.
_declared_plugin_mounts() {
local home="${1:-$PLUGIN_FIXTURE_HOME}"
bash -c '
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
HOME="'"$home"'"
harness_plugin_load claude-code
extra_mounts=()
_guarded_stores() { GUARDED_STORES=(); }
_plugin_version_mounts
printf "%s\n" ${extra_mounts[@]+"${extra_mounts[@]}"} | grep -v "^-v$"
'
}
# A plugin tree shaped like a real one: a marketplace, two plugins, and two
# installed versions of one of them - which is what an update looks like
# mid-flight, and the case the whole arrangement exists for.
_plugin_fixture() {
PLUGIN_FIXTURE_HOME="$(mktemp -d "$BATS_TEST_TMPDIR/plughome.XXXXXX")"
local p="$PLUGIN_FIXTURE_HOME/.claude/plugins"
mkdir -p "$p/marketplaces/mkt" "$p/data/thing" \
"$p/cache/mkt/alpha/1.0.0" "$p/cache/mkt/alpha/1.1.0" "$p/cache/mkt/beta/2.0.0"
printf '{}\n' > "$p/installed_plugins.json"
printf '{}\n' > "$p/known_marketplaces.json"
printf '{}\n' > "$p/plugin-catalog-cache.json"
}
_declared_config_dirs() {
bash -c '
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
@@ -625,17 +671,72 @@ _declared_config_dirs() {
[ "$status" -ne 0 ] || { echo "entrypoint still names a harness path"; return 1; }
}
@test "~/.claude/hooks and plugins are mounted read-only" {
# Hooks run automatically with no approval gate, and plugins carry code a
# later unsandboxed host session loads - see docker-compose.yml's comment
# on these mount lines. Without them, a compromised session can unlink a
# hook symlink and drop a plain malicious file in its place, which the
# dynamic-mount logic in bin/dejima never sees (it only protects symlink
# targets that resolve outside ~/.claude).
@test "~/.claude/hooks is mounted read-only" {
# Hooks run automatically with no approval gate - see docker-compose.yml's
# comment on these mount lines. Without this, a compromised session can
# unlink a hook symlink and drop a plain malicious file in its place,
# which the dynamic-mount logic in bin/dejima never sees (it only protects
# symlink targets that resolve outside ~/.claude).
local dirs
dirs="$(_declared_config_dirs)"
grep -q "^/h/.claude/hooks"$'\t'"ro" <<< "$dirs" || { echo "$dirs"; return 1; }
grep -q "^/h/.claude/plugins"$'\t'"ro" <<< "$dirs" || { echo "$dirs"; return 1; }
}
@test "the plugin tree is not mounted read-only wholesale" {
# It was, with the rest of the directories holding something executable,
# and that broke plugin updates without protecting anything. Claude Code
# materialises a new version beside the old and rewrites its registry;
# read-only it can do neither, and the plugin is left at a version the
# catalogue no longer matches. A copy has no path to the host, which is
# what the read-only mount was for.
local dirs
dirs="$(_declared_config_dirs)"
grep -q "^/h/.claude/plugins"$'\t' <<< "$dirs" \
&& { echo "the plugin tree is still a config-dir mount: $dirs"; return 1; }
return 0
}
@test "what the harness rewrites in the plugin tree is seeded writable" {
_plugin_fixture
# The registry, the catalogue and the marketplace checkout: all rewritten
# by Claude Code as it installs or refreshes, none of them useful to the
# session read-only.
local seeds
seeds="$(_declared_seeds)"
local rel
for rel in .claude/plugins/installed_plugins.json \
.claude/plugins/known_marketplaces.json \
.claude/plugins/plugin-catalog-cache.json \
.claude/plugins/marketplaces; do
grep -qx "$rel" <<< "$seeds" || { echo "$rel is not seeded: $seeds"; return 1; }
done
}
@test "the levels above an installed plugin version are seeded as writable directories" {
_plugin_fixture
# Docker creates a bind mount's missing parents as root, so mounting each
# version without this leaves the session able to read every installed one
# and write no new one beside them - which is the whole failure.
local seeds
seeds="$(_declared_seeds)"
grep -qx '.claude/plugins/cache' <<< "$seeds" \
|| { echo "the cache root is not seeded: $seeds"; return 1; }
grep -qE '^\.claude/plugins/cache/[^/]+/[^/]+$' <<< "$seeds" \
|| { echo "no per-plugin cache directory is seeded: $seeds"; return 1; }
}
@test "an installed plugin version is mounted read-only, not copied" {
_plugin_fixture
# One MCP server's node_modules is most of the tree. An update writes a
# NEW version directory rather than changing an existing one, which is
# what makes mounting these safe and copying them wasteful.
local mounts
mounts="$(_declared_plugin_mounts)"
[ -n "$mounts" ] || skip "no installed plugin versions on this host to assert against"
grep -qE ':ro$' <<< "$mounts" \
|| { echo "a version directory is mounted writable: $mounts"; return 1; }
grep -qE '/\.claude/plugins/cache/[^/]+/[^/]+/[^/:]+:' <<< "$mounts" \
|| { echo "not mounted at version granularity: $mounts"; return 1; }
}
@test "~/.claude.json is a throwaway per-session copy, not the real file mounted directly" {
@@ -820,6 +921,7 @@ _cline_cfg() {
_cline_prepare() {
DEJIMA_CONFIG_DIR="$1" bash -c '
source "$2/bin/dejima-provider-lib.sh"
source "$2/bin/dejima-project-lib.sh"
source "$2/bin/dejima-harness-lib.sh"
source "$2/bin/dejima-slug-lib.sh"
harness_plugin_load cline
@@ -890,6 +992,32 @@ _cline_prepare() {
done
}
@test "cline accepts an endpoint the operator has allowlisted by name" {
# A model server on someone's own network normally has a name, not a
# literal address. Requiring the literal made the local-endpoint path -
# the reason this harness is provider-agnostic at all - unusable for the
# ordinary case.
#
# The name qualifies because the operator allowlisted it, which is the
# same store that decides what the container can reach: an endpoint that
# fails this is one the session could not have connected to anyway.
local cfg
cfg="$(_cline_cfg 'model = m' 'base_url = https://llm.example.test/v1')"
printf 'llm.example.test\n' > "$cfg/allowed-domains"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
[ "$status" -eq 0 ] || { echo "refused an allowlisted endpoint: $output"; return 1; }
}
@test "cline refuses a name the operator has not allowlisted" {
# The same endpoint without the grant. Nothing about the URL changed;
# what changed is whether the operator asked for it.
local cfg
cfg="$(_cline_cfg 'model = m' 'base_url = https://llm.example.test/v1')"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
[ "$status" -ne 0 ] || { echo "accepted an endpoint nothing granted"; return 1; }
[[ "$output" == *"refusing base_url"* ]] || { echo "$output"; return 1; }
}
@test "cline does not mistake a hostname for the private range it starts with" {
# A glob match on "10." accepts 10.example.com, which resolves wherever
# its owner says and would take the key with it. The octets are compared
@@ -923,6 +1051,64 @@ _cline_prepare() {
[ "$output" = "unset" ] || { echo "a base URL was written: $output"; return 1; }
}
@test "cline's provider entry carries the timestamp cline requires" {
# Without updatedAt, Cline discards the entry and falls back to its own
# hosted endpoint - reporting a provider and model nobody configured, and
# failing to connect to a host the firewall was never asked to allow.
#
# Nothing about the file looks wrong when this is missing, which is why it
# survived every test that wrote providers.json by hand: those included the
# field, and the plugin did not. It took a run against a real endpoint.
local cfg ws f
cfg="$(_cline_cfg 'model = m')"
ws="$BATS_TEST_TMPDIR/ws-ts"
run _cline_prepare "$cfg" "$ws"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
f="$(find "$cfg/state" -name providers.json | head -1)"
[ -n "$f" ] || { echo "no providers.json"; return 1; }
run jq -r '.providers.anthropic.updatedAt // "missing"' "$f"
[ "$output" != "missing" ] || { echo "no updatedAt: cline will discard this"; return 1; }
# An ISO-8601 instant, which is the shape Cline writes itself.
[[ "$output" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$ ]] \
|| { echo "not an ISO-8601 instant: $output"; return 1; }
}
@test "cline's local-endpoint provider entry carries it too" {
# Same field, the other branch. The bug was found on this one.
local cfg ws f
cfg="$(_cline_cfg 'model = m' 'base_url = http://127.0.0.1:8080/v1')"
ws="$BATS_TEST_TMPDIR/ws-ts2"
run _cline_prepare "$cfg" "$ws"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
f="$(find "$cfg/state" -name providers.json | head -1)"
run jq -r '.providers."openai-compatible".updatedAt // "missing"' "$f"
[ "$output" != "missing" ] || { echo "no updatedAt on the local branch"; return 1; }
}
@test "cline gets a context window only when one is configured" {
# Cline budgets against its own model catalogue. For a model it does not
# recognise - every self-served one - it has no budget, so it grows the
# conversation until the SERVER rejects it rather than compacting. Setting
# this is what makes it compact instead.
#
# And absent when unset, deliberately: the right value belongs to the model
# being served, and dejima guessing one is worse than leaving Cline to its
# catalogue for a model it does know.
local cfg f
cfg="$(_cline_cfg 'model = m' 'context_window = 15000')"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
f="$(find "$cfg/state" -name providers.json | head -1)"
run jq -r '.providers.anthropic.settings.contextWindow' "$f"
[ "$output" = "15000" ] || { echo "not written as a number: $output"; return 1; }
cfg="$(_cline_cfg 'model = m')"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw2"
f="$(find "$cfg/state" -name providers.json | head -1)"
run jq -r '.providers.anthropic.settings.contextWindow // "absent"' "$f"
[ "$output" = "absent" ] || { echo "invented a context window: $output"; return 1; }
}
@test "cline's state directory is one per workspace, not one for all of them" {
# Cline indexes every session it has ever run in a single sessions.db, with
# the project a session belonged to as a field inside the record rather
@@ -1000,6 +1186,93 @@ _cline_prepare() {
|| { echo "the preamble no longer says what mode the session is in"; return 1; }
}
# Every command-line tool under bin/, discovered rather than listed. A tool
# added later is covered the day it lands, which is the only version of this
# that stays true - the same reasoning test/shellcheck.sh builds its list on.
#
# Libraries are excluded by name: they are sourced, not run, and have no
# arguments to explain.
_cli_tools() {
local f name
for f in "$REPO_ROOT"/bin/*; do
[ -f "$f" ] && [ -x "$f" ] || continue
name="$(basename "$f")"
case "$name" in *-lib.sh) continue ;; esac
printf '%s\n' "$name"
done
}
@test "every CLI explains itself with --help" {
# #64: bare invocation keeps its terse usage line, --help says what the
# arguments do. On stdout and exit 0, so it can be piped or paged, where
# usage() stays on stderr and exits nonzero so a mistyped invocation still
# fails a script.
local name out
while IFS= read -r name; do
[ -n "$name" ] || continue
run "$REPO_ROOT/bin/$name" --help
[ "$status" -eq 0 ] || { echo "$name --help exited $status"; return 1; }
[ -n "$output" ] || { echo "$name --help printed nothing"; return 1; }
# It has to be about the tool it belongs to, checked on the FIRST
# line rather than anywhere in the page. A copy-pasted help would
# still mention the right name incidentally - in a "started by
# systemd/<name>.service" line, say - so "appears somewhere" is
# satisfied by a page with the wrong title on it.
[[ "${lines[0]}" == *"$name"* ]] \
|| { echo "$name --help opens with: ${lines[0]}"; return 1; }
# And -h reaches the same place - the same output, not merely another
# exit 0. A tool that ignored -h and happened to succeed would pass a
# status-only check while printing nothing like help.
local long="$output" short
short="$("$REPO_ROOT/bin/$name" -h 2>/dev/null)" \
|| { echo "$name -h failed"; return 1; }
[ "$short" = "$long" ] \
|| { echo "$name -h and --help disagree"; return 1; }
done < <(_cli_tools)
}
@test "no help page scrolls off a reasonable pane" {
# The budget is the constraint that forces a help page to choose what
# matters. Asserted rather than aspired to, so a later addition that pushes
# one past it fails here instead of quietly growing.
local name n
while IFS= read -r name; do
[ -n "$name" ] || continue
n="$("$REPO_ROOT/bin/$name" --help 2>/dev/null | wc -l)"
[ "$n" -le 30 ] || { echo "$name --help is $n lines, budget is 30"; return 1; }
# A help page that fits because it says nothing is not the goal either.
[ "$n" -ge 8 ] || { echo "$name --help is only $n lines"; return 1; }
done < <(_cli_tools)
}
@test "--help works when there is no config at all" {
# Which is exactly when someone reaches for it. Every tool dispatches
# --help before sourcing a library or reading the config, so a missing or
# malformed config cannot stop it printing.
local name
while IFS= read -r name; do
[ -n "$name" ] || continue
run env DEJIMA_CONFIG_DIR="$BATS_TEST_TMPDIR/definitely-not-here" \
"$REPO_ROOT/bin/$name" --help
[ "$status" -eq 0 ] \
|| { echo "$name --help needs a config to print (exit $status): $output"; return 1; }
done < <(_cli_tools)
}
@test "bare invocation still prints terse usage, not the help page" {
# #64 asks for both: the one-line usage stays for a mistyped invocation,
# and it stays on stderr with a nonzero exit so a script still fails.
# Checked on the tools that take a required argument.
local name
for name in dejima-allow dejima-dep dejima-project dejima-epitaph; do
run "$REPO_ROOT/bin/$name"
[ "$status" -ne 0 ] || { echo "$name with no arguments succeeded"; return 1; }
# Terse: the usage line, not the whole help page.
[ "${#lines[@]}" -le 4 ] \
|| { echo "$name prints ${#lines[@]} lines bare; usage should stay terse"; return 1; }
done
}
@test "shellcheck covers harness plugins, which carry no shebang" {
# Plugins are sourced, so they have no shebang, so the shebang-based
# discovery dropped them - silently, while they sit in the trusted
@@ -1528,10 +1801,11 @@ STUB
gitea_cli "$cli" edit 1 --body "a new body"
[ "$(cli_payload | jq 'has("title")')" = "false" ]
# The provenance marker create writes survives an edit rather than
# being shed by it.
[[ "$(cli_payload | jq -r '.body')" == *"LLM agent"* ]]
[[ "$(cli_payload | jq -r '.body')" == *"a new body"* ]]
# Verbatim, marker and all. The provenance marker used to be added
# here and is now added by the shadow, which covers every forge rather
# than the ones whose CLI this repo wrote; that an edit does not shed
# it is tested there, against the same list that puts it on a create.
[ "$(cli_payload | jq -r '.body')" = "a new body" ]
done
}
@@ -1707,6 +1981,28 @@ STUB
[ -x "$REPO_ROOT/forges/gitea/bin/gitea-issue" ]
}
# install-forge-bins.sh, with every command a manifest declares as `system:`
# stubbed onto PATH first.
#
# The image guarantees those commands - that is what declaring them means - but
# a contributor's checkout and the CI runner do not, and CI is where this was
# noticed: gh is present in a dejima container and absent on the runner, so
# these tests passed locally and failed there. A test that reads the machine it
# runs on is testing the machine.
#
# The names come from the manifests rather than a list here, so a forge that
# declares another one later needs no change to this.
_install_forge_bins() {
local stub="$BATS_TEST_TMPDIR/systemstub" name
mkdir -p "$stub"
for name in $(sed -n 's/^install *= *//p' "$REPO_ROOT"/forges/*/forge.conf \
| tr ' ' '\n' | sed -n 's/^system://p'); do
printf '#!/bin/sh\nexit 0\n' > "$stub/$name"
chmod +x "$stub/$name"
done
PATH="$stub:$PATH" bash "$REPO_ROOT/docker/install-forge-bins.sh" "$@"
}
# forges/ is inside the audit's TCB, so `install =` in a forge manifest is a
# claim an operator is entitled to read as "this forge puts nothing else
# executable in the image". The image build enforces it, but that is the slow
@@ -1716,12 +2012,18 @@ STUB
@test "what ships into the image is what a manifest declares, both ways" {
local dest
dest="$(mktemp -d)"
run bash "$REPO_ROOT/docker/install-forge-bins.sh" "$REPO_ROOT/forges" "$dest/lib" "$dest/bin"
run _install_forge_bins "$REPO_ROOT/forges" "$dest/lib" "$dest/bin"
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$dest"; return 1; }
# Nothing under any forge's bin/ was skipped, and nothing else appeared.
# A file under a bin/ with no declaration is caught by the run above
# failing, which is what leaves this free to read the filesystem for the
# shipped-from-the-repo half rather than trusting the manifests for both.
local shipped declared
shipped="$(cd "$dest/bin" && ls | sort)"
declared="$(find "$REPO_ROOT/forges" -type f -path '*/bin/*' -printf '%f\n' | sort)"
declared="$( { find "$REPO_ROOT/forges" -type f -path '*/bin/*' -printf '%f\n'
sed -n 's/^install *= *//p' "$REPO_ROOT"/forges/*/forge.conf \
| tr ' ' '\n' | sed -n 's/^system://p'
} | sort )"
rm -rf "$dest"
[ "$shipped" = "$declared" ] || {
echo "installed:"; echo "$shipped"; echo "present under a bin/:"; echo "$declared"; return 1; }
@@ -1733,8 +2035,12 @@ STUB
cp -r "$REPO_ROOT"/forges/. "$fake/"
mkdir -p "$fake/github/bin"
printf '#!/bin/sh\necho hi\n' > "$fake/github/bin/undeclared"
run bash "$REPO_ROOT/docker/install-forge-bins.sh" "$fake" "$dest/lib" "$dest/bin"
# Stubbed like the rest: this has to fail because of the undeclared file,
# not because the machine happens to be missing a declared system command.
run _install_forge_bins "$fake" "$dest/lib" "$dest/bin"
[ "$status" -ne 0 ] || { echo "an undeclared CLI installed anyway"; rm -rf "$fake" "$dest"; return 1; }
[[ "$output" == *"does not declare it"* ]] \
|| { echo "failed for another reason: $output"; rm -rf "$fake" "$dest"; return 1; }
[ ! -e "$dest/undeclared" ]
rm -rf "$fake" "$dest"
}
@@ -1744,11 +2050,94 @@ STUB
fake="$(mktemp -d)"; dest="$(mktemp -d)"
cp -r "$REPO_ROOT"/forges/. "$fake/"
rm "$fake/gitea/bin/gitea-pr"
run bash "$REPO_ROOT/docker/install-forge-bins.sh" "$fake" "$dest/lib" "$dest/bin"
run _install_forge_bins "$fake" "$dest/lib" "$dest/bin"
[ "$status" -ne 0 ] || { echo "a missing declared CLI passed"; rm -rf "$fake" "$dest"; return 1; }
[[ "$output" == *"which it does not ship"* ]] \
|| { echo "failed for another reason: $output"; rm -rf "$fake" "$dest"; return 1; }
rm -rf "$fake" "$dest"
}
@test "the release pass names every area whose check is manual" {
# This list is the only record of what the test suites cannot see, and it
# is the kind of list that rots by omission: an area gets a manual check
# because someone was burned, and then a refactor renames the path and the
# check silently stops being printed. Pinned against the paths themselves.
local script="$REPO_ROOT/test/release-pass.sh"
[ -x "$script" ]
local area
for area in bin/dejima docker/entrypoint forges/ docker/forge-guard \
hooks/merge-guard bin/dejima-tty-suspend web/ harnesses/ \
bin/dejima-ttyd '\.dejima/allowed-domains'; do
grep -q -- "$area" "$script" \
|| { echo "release-pass.sh no longer mentions $area"; return 1; }
done
# And it must say what it could not run, rather than reporting a pass.
grep -q 'did NOT run' "$script" \
|| { echo "a gate that could not run is reported as passing"; return 1; }
grep -q 'queued is not a job that passed' "$script" \
|| { echo "nothing warns that a queued CI job is not a green one"; return 1; }
}
@test "docs/release.md exists and is pointed at from where a release starts" {
[ -f "$REPO_ROOT/docs/release.md" ]
grep -q 'docs/release.md' "$REPO_ROOT/.dejima.md" \
|| { echo ".dejima.md does not point at the release pass"; return 1; }
# The two halves that make this project's review different from a generic
# one, and the two most likely to be dropped as boilerplate.
grep -q 'allowed-domains' "$REPO_ROOT/docs/release.md" \
|| { echo "the release review does not mention the CI allowlist"; return 1; }
grep -q 'queue' "$REPO_ROOT/docs/release.md" \
|| { echo "the release doc does not warn about the queued privileged job"; return 1; }
}
# --- Ctrl-Z (#179) ----------------------------------------------------------
#
# Driven through a real pty with a real interactive shell on it, because the
# question is not what a signal does - it is whether the terminal turns the
# keystroke into a signal at all. Under `docker compose run` it does not: raw
# mode has ISIG off, so Ctrl-Z is the byte 0x1a and it goes to the container.
#
# The stand-in for the container run is in test/fixtures. Docker is not
# available here and is not what is under test; the terminal handling is.
@test "Ctrl-Z suspends the whole launch instead of vanishing into the container" {
command -v python3 >/dev/null 2>&1 || skip "needs python3"
local work; work="$(mktemp -d "$BATS_TEST_TMPDIR/ttysuspend.XXXXXX")"
run env TTY_SUSPEND_WORK="$work" python3 \
"$REPO_ROOT/test/fixtures/tty-suspend-harness.py" \
"$REPO_ROOT/bin/dejima-tty-suspend" bash
[ "$status" -ne 77 ] || skip "no pty available here"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# Named rather than counted, so a harness that silently stopped checking
# something cannot pass this by reporting fewer results.
local name
for name in cycle1-suspends-the-job cycle1-nothing-reached-the-container \
cycle1-raw-mode-restored cycle1-redraw-poked \
cycle2-suspends-the-job cycle2-raw-mode-restored \
input-reaches-the-resumed-session ctrl-c-still-reaches-the-session; do
[[ "$output" == *"PASS $name"* ]] \
|| { echo "$name was not checked:"; echo "$output"; return 1; }
done
}
@test "the Ctrl-Z harness sees the bug when the fix is not there" {
# The control. A test that cannot fail is not evidence, and this one is
# made entirely of timing and terminal state - the two things most likely
# to quietly stop testing anything.
command -v python3 >/dev/null 2>&1 || skip "needs python3"
local work; work="$(mktemp -d "$BATS_TEST_TMPDIR/ttycontrol.XXXXXX")"
run env TTY_SUSPEND_WORK="$work" TTY_SUSPEND_NO_HELPER=1 python3 \
"$REPO_ROOT/test/fixtures/tty-suspend-harness.py" \
"$REPO_ROOT/bin/dejima-tty-suspend" bash
[ "$status" -ne 77 ] || skip "no pty available here"
[ "$status" -ne 0 ] || { echo "the control passed, so it proves nothing:"; echo "$output"; return 1; }
[[ "$output" == *"FAIL cycle1-suspends-the-job"* ]] \
|| { echo "$output"; return 1; }
# The byte itself, which is the whole bug: it reached the container.
[[ "$output" == *"FAIL cycle1-nothing-reached-the-container b'\\x1a'"* ]] \
|| { echo "the container did not receive the keystroke:"; echo "$output"; return 1; }
}
# --- guards that do not need the harness ------------------------------------
#
# push-guard and merge-guard used to exist only as hook scripts, which meant
@@ -1862,6 +2251,30 @@ _guard_repo() {
"$d/gitea-pr" --verbose "$blocked" 7
[[ "$output" == *"human decision in the sandbox"* ]] || { echo "$output"; return 1; }
# Nor does a flag that takes a SEPARATE value, which is the form that got
# past this and is the ordinary way to name a PR from outside its checkout:
# `gh --repo owner/name pr merge N`. The value is indistinguishable from a
# subcommand here, so a match anchored at the first non-flag argument found
# `owner/name`, gave up, and handed the merge to the real CLI.
printf '#!/bin/bash\necho "real: $*"\n' > "$d/real/gh"
chmod +x "$d/real/gh"
cp "$SANDBOX_DIR/forge-guard.sh" "$d/gh"
chmod +x "$d/gh"
local form
for form in "--repo o/r pr merge 7" "--repo=o/r pr merge 7" "pr merge 7"; do
# shellcheck disable=SC2086
run env DEJIMA=1 DEJIMA_MERGE_BLOCK="gh pr merge" DEJIMA_FORGE_REAL_DIR="$d/real" \
"$d/gh" $form
[[ "$output" == *"human decision in the sandbox"* ]] \
|| { echo "gh $form reached the real CLI: $output"; return 1; }
done
# And a command that merely mentions the blocked words out of order still
# reaches the real CLI - the run has to be consecutive.
run env DEJIMA=1 DEJIMA_MERGE_BLOCK="gh pr merge" DEJIMA_FORGE_REAL_DIR="$d/real" \
"$d/gh" pr edit 7 --add-label merge
[[ "$output" == real:* ]] || { echo "a label named merge was blocked: $output"; return 1; }
# Everything else reaches the real CLI, including a subcommand that merely
# starts with the blocked word.
local sub
@@ -1877,12 +2290,182 @@ _guard_repo() {
[[ "$output" == real:* ]] || { echo "$output"; return 1; }
}
# --- the authorship disclosure ----------------------------------------------
#
# The marker used to live in the two CLIs this repo wrote, which made "does
# dejima say an agent wrote this" depend on which forge an operator picked.
# It lives in the shadow now, so these are about the shadow.
# A shadow over a stub that reports which body it was handed, however it was
# spelled. Echoes the body back so a test can assert on what the real CLI
# would have sent, not on what the shim's argv looked like.
_disclose_shim() {
local d="$1"; shift
mkdir -p "$d/real"
cat > "$d/real/forge-cli" <<'STUB'
#!/bin/bash
body=""
while [ "$#" -gt 0 ]; do
case "$1" in
--body-file) body="$(cat -- "$2")"; shift 2; continue ;;
--body-file=*) body="$(cat -- "${1#--body-file=}")"; shift; continue ;;
--body) body="$2"; shift 2; continue ;;
--body=*) body="${1#--body=}"; shift; continue ;;
esac
shift
done
printf '%s' "$body"
STUB
chmod +x "$d/real/forge-cli"
cp "$SANDBOX_DIR/forge-guard.sh" "$d/forge-cli"
chmod +x "$d/forge-cli"
DEJIMA=1 DEJIMA_FORGE_REAL_DIR="$d/real" \
DEJIMA_DISCLOSE="$DISCLOSE" "$d/forge-cli" "$@"
}
@test "an agent-authored body says so, in every spelling the CLIs accept" {
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/disclose.XXXXXX")"
local DISCLOSE="pr forge-cli create"
local f="$d/body"
printf 'Line one.\n\nLine two.\n' > "$f"
# --body-file is what the briefing tells a session to use, and the other
# three are what it might reach for anyway.
local out
out="$(_disclose_shim "$d" create --title T --body-file "$f")"
[[ "$out" == "**Summary generated by LLM agent**"* ]] || { echo "$out"; return 1; }
[[ "$out" == *"Line one."*"Line two."* ]] || { echo "$out"; return 1; }
out="$(_disclose_shim "$d" create --title T --body-file="$f")"
[[ "$out" == "**Summary generated by LLM agent**"*"Line two."* ]] || { echo "$out"; return 1; }
out="$(_disclose_shim "$d" create --title T --body "typed inline")"
[[ "$out" == "**Summary generated by LLM agent**"*"typed inline"* ]] || { echo "$out"; return 1; }
out="$(_disclose_shim "$d" create --title T --body=typed)"
[[ "$out" == "**Summary generated by LLM agent**"*"typed"* ]] || { echo "$out"; return 1; }
# And from stdin, which is the only one where the shim reading the body is
# the only chance to prepend to it: the CLI behind it cannot read it twice.
out="$(printf 'piped in\n' | _disclose_shim "$d" create --body-file -)"
[[ "$out" == "**Summary generated by LLM agent**"*"piped in"* ]] || { echo "$out"; return 1; }
}
@test "the marker matches the kind of thing being written" {
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/disclosekind.XXXXXX")"
local DISCLOSE="pr forge-cli pr create
issue forge-cli issue create
comment forge-cli issue comment"
local out
out="$(_disclose_shim "$d" pr create --body x)"
[[ "$out" == *"Summary generated by LLM agent"* ]] || { echo "$out"; return 1; }
out="$(_disclose_shim "$d" issue create --body x)"
[[ "$out" == *"Filed by LLM agent"* ]] || { echo "$out"; return 1; }
out="$(_disclose_shim "$d" issue comment --body x)"
[[ "$out" == *"Comment by LLM agent"* ]] || { echo "$out"; return 1; }
}
@test "an edit is disclosed too, so revising a body does not shed the marker" {
# The property the CLIs used to hold themselves: the marker describes where
# the text came from, not the event that wrote it.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/discloseedit.XXXXXX")"
local DISCLOSE="pr forge-cli edit"
local out
out="$(_disclose_shim "$d" edit 7 --body "revised")"
[[ "$out" == *"Summary generated by LLM agent"*"revised"* ]] || { echo "$out"; return 1; }
}
@test "a command with nothing authored about it is passed through untouched" {
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/disclosepass.XXXXXX")"
local DISCLOSE="pr forge-cli create"
local out
# Not in the list.
out="$(_disclose_shim "$d" list --body "x")"
[ "$out" = "x" ] || { echo "list was marked: $out"; return 1; }
# In the list, but carrying no body at all - gh pr create --fill is this
# case. Marking would mean inventing a body the session did not write.
out="$(_disclose_shim "$d" create --title T)"
[ -z "$out" ] || { echo "a bodyless create grew one: $out"; return 1; }
# A flag's value does not stop the match, the same way it does not for a
# blocked merge.
out="$(_disclose_shim "$d" --repo o/r create --body "x")"
[[ "$out" == *"LLM agent"* ]] || { echo "--repo hid the create: $out"; return 1; }
}
@test "a body file the CLI would have refused is refused here, not sent empty" {
# The shadow reads --body-file and hands the CLI a temp file, which always
# exists - so the CLI's own check on that argument can never fire again,
# and whatever the shadow read is what gets published. Before this, a
# mistyped path produced a PR whose entire body was the marker, created
# rather than refused, and /dev/zero filled a temp file until the disk did.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/disclosebody.XXXXXX")"
local DISCLOSE="pr forge-cli create"
local bad out
for bad in "$d/nope" /dev/zero "$d"; do
run timeout 5 env DEJIMA=1 DEJIMA_FORGE_REAL_DIR="$d/real" \
DEJIMA_DISCLOSE="$DISCLOSE" bash -c '
mkdir -p "$1/real"
printf "#!/bin/sh\necho CLI-RAN\n" > "$1/real/forge-cli"
chmod +x "$1/real/forge-cli"
cp "$2" "$1/forge-cli"; chmod +x "$1/forge-cli"
"$1/forge-cli" create --title T --body-file "$3"' \
_ "$d" "$SANDBOX_DIR/forge-guard.sh" "$bad"
[ "$status" -ne 124 ] || { echo "hung on $bad"; return 1; }
[ "$status" -ne 0 ] || { echo "accepted $bad: $output"; return 1; }
[[ "$output" == *"not a readable file"* ]] \
|| { echo "refused $bad without saying why: $output"; return 1; }
[[ "$output" != *"CLI-RAN"* ]] \
|| { echo "ran the CLI anyway for $bad"; return 1; }
done
}
@test "the body is marked once, not once per body argument" {
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/disclosetwice.XXXXXX")"
local DISCLOSE="pr forge-cli create"
local out count
out="$(_disclose_shim "$d" create --body "x")"
count="$(grep -c 'LLM agent' <<< "$out")"
[ "$count" = "1" ] || { echo "marked $count times: $out"; return 1; }
}
@test "both shipped forges disclose on every command that writes a body" {
# Driven from the manifests rather than from a list repeated here, so a
# forge added later is held to the same rule instead of being forgotten.
# The point is coverage, not wording: every command that takes a body must
# be accounted for, and the kinds must be ones the shadow knows.
local conf forge spec entry kind seen
for conf in "$REPO_ROOT"/forges/*/forge.conf; do
forge="$(basename "$(dirname "$conf")")"
[ -n "$(lib manifest_get forge "$forge" pr_command)" ] || continue
spec="$(lib manifest_get forge "$forge" disclose)"
[ -n "$spec" ] || { echo "forge $forge writes PRs and discloses nothing"; return 1; }
seen=""
while IFS= read -r entry; do
[ -n "$entry" ] || continue
kind="${entry%%:*}"
grep -q "^ $kind)" "$SANDBOX_DIR/forge-guard.sh" \
|| { echo "forge $forge uses kind '$kind', which the shadow has no marker for"; return 1; }
seen="$seen $kind"
done <<< "$(tr ',' '\n' <<< "$spec" | sed 's/^ *//; s/ *$//')"
# create and edit for both artifacts, plus a comment: five commands,
# covering all three kinds. A forge declaring only one would pass a
# weaker check while leaving issues unmarked.
local k
for k in pr issue comment; do
[[ "$seen" == *" $k"* ]] || { echo "forge $forge never discloses a $k"; return 1; }
done
done
}
@test "a forge CLI sits behind the shadow, with the name on PATH a symlink to it" {
# The guard needs a fixed path to exec the real binary through, which is
# what the indirection buys - and it is what lets entrypoint.sh swap the
# PATH entry for the guard without moving anything at runtime.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/forgeinstall.XXXXXX")"
run bash "$SANDBOX_DIR/install-forge-bins.sh" "$REPO_ROOT/forges" "$d/lib" "$d/bin"
run _install_forge_bins "$REPO_ROOT/forges" "$d/lib" "$d/bin"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local f
for f in "$d/bin"/*; do
@@ -1891,6 +2474,37 @@ _guard_repo() {
done
}
@test "every command a forge names ends up behind the shadow" {
# The invariant #184 was a violation of. The entrypoint refuses to start
# when a workflow's merge block names a command that is not at
# /usr/local/lib/dejima/forge, so a forge naming one it never installs is
# not a degraded guard - it is a container that does not come up, in the
# only workflow that opens PRs.
#
# Checked against install-forge-bins.sh's real output rather than against
# the `install` list, because what matters is where a command ends up and
# not what a manifest says about it.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/shadow.XXXXXX")"
run _install_forge_bins "$REPO_ROOT/forges" "$d/lib" "$d/bin"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local conf forge key value cli checked=0
for conf in "$REPO_ROOT"/forges/*/forge.conf; do
forge="$(basename "$(dirname "$conf")")"
for key in pr_command issue_command merge_block; do
value="$(lib manifest_get forge "$forge" "$key")"
[ -n "$value" ] || continue
cli="${value%% *}"
[ -e "$d/lib/$cli" ] \
|| { echo "forge $forge names '$value' but nothing installs '$cli'"; return 1; }
checked=$((checked + 1))
done
done
# gitea's three and github's three. A loop that silently matched nothing
# would pass this test while checking no forge at all.
[ "$checked" -ge 6 ] || { echo "only checked $checked commands"; return 1; }
}
@test "the entrypoint links each guard only when it has something to enforce" {
local e="$SANDBOX_DIR/entrypoint.sh"
grep -q 'ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git' "$e" \
@@ -1901,12 +2515,19 @@ _guard_repo() {
# should leave PATH as the image left it.
grep -q 'if \[ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" \]' "$e" \
|| { echo "the git shadow is unconditional"; return 1; }
grep -q 'if \[ -n "${DEJIMA_MERGE_BLOCK:-}" \]' "$e" \
# The shadow is linked for whatever the forge and workflow between them
# ask for - a blocked merge, a body to disclose on, or both - and for
# nothing when they ask for neither.
grep -q 'if \[ -n "$_shadow_cli_list" \]' "$e" \
|| { echo "the forge shadow is unconditional"; return 1; }
grep -q 'DEJIMA_MERGE_BLOCK' "$e" \
|| { echo "a blocked merge no longer reaches the shadow"; return 1; }
grep -q 'DEJIMA_DISCLOSE' "$e" \
|| { echo "a disclosure no longer reaches the shadow"; return 1; }
# And a merge_block naming a command the forge does not ship is refused
# rather than looking enforced.
grep -q 'does not install. Nothing is being blocked' "$e" \
|| { echo "a merge_block for a missing CLI is silent"; return 1; }
grep -q 'does not install. Nothing is being enforced' "$e" \
|| { echo "naming a CLI the forge lacks is silent"; return 1; }
}
@test "merge-guard.sh blocks a sandbox merge and is a no-op on the host" {
@@ -2136,11 +2757,17 @@ registered_commands() {
# fails here rather than presenting as a missing skill in a session.
local dirs d
dirs="$(_declared_config_dirs)"
for d in skills commands agents output-styles hooks plugins \
for d in skills commands agents output-styles hooks \
backups tasks plans sessions; do
grep -q "^/h/.claude/$d"$'\t' <<< "$dirs" \
|| { echo "$d is no longer declared: $dirs"; return 1; }
done
# plugins/ is deliberately not among them: it is seeded as a writable copy
# rather than mounted, so entrypoint.sh creates it and there is no mount
# source for bin/dejima to pre-create.
grep -q "^/h/.claude/plugins"$'\t' <<< "$dirs" \
&& { echo "plugins is a config-dir mount again: $dirs"; return 1; }
return 0
}
@test "bin/dejima overlays the workspace .git/hooks read-only when .git is a real dir" {
@@ -2361,8 +2988,7 @@ registered_commands() {
ws="$(mktemp -d "$BATS_TEST_TMPDIR/ws.XXXXXX")"
printf 'BRIEFING-SENTINEL\n' > "$BATS_TEST_TMPDIR/briefing"
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" \
| DEJIMA=1 DEJIMA_BRIEFING_FILE="$BATS_TEST_TMPDIR/briefing" \
bash "$REPO_ROOT/hooks/sandbox-context.sh")
| sandbox_context DEJIMA_BRIEFING_FILE="$BATS_TEST_TMPDIR/briefing")
run jq -r '.hookSpecificOutput.additionalContext' <<< "$out"
[[ "$output" == *"BRIEFING-SENTINEL"* ]] \
|| { echo "the briefing did not come through the hook: $output"; return 1; }
@@ -2418,6 +3044,24 @@ registered_commands() {
[[ "$output" == *"exit 1"* ]] || { echo "a render failure does not stop the launch: $output"; return 1; }
}
# hooks/sandbox-context.sh, with the briefing-delivery variables cleared.
#
# The hook deliberately emits only the session id when the briefing already
# reached the harness another way - written to a file, or passed as an argument
# - and inside a dejima sandbox those variables are set, because that is how
# the session running these tests got its own briefing. So a test that sets
# only DEJIMA=1 is asserting a precondition it has not stated: it passes in CI,
# where the environment is clean, and fails on the machine this project is
# actually worked on. Third time (see .dejima.md), same remedy: state the
# precondition with env -u rather than assuming it.
#
# Extra KEY=VALUE assignments for one call go in as arguments.
sandbox_context() {
env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_FILE \
-u DEJIMA_BRIEFING_RENDERED DEJIMA=1 "$@" \
bash "$REPO_ROOT/hooks/sandbox-context.sh"
}
@test "sandbox-context.sh gives the agent its own container ID for dejima-allow" {
# Agents used to guess their container ID and could hand back another
# running sandbox's ID, making dejima-allow "succeed" against
@@ -2464,8 +3108,8 @@ registered_commands() {
grep -q "^/h/.claude/$d"$'\t'"ro"$'\t'"scan" <<< "$dirs" \
|| { echo "$d is not scanned for symlink targets: $dirs"; return 1; }
done
grep -q "^/h/.claude/plugins"$'\t'"ro"$'\t'"scan" <<< "$dirs" \
&& { echo "plugins should not be scanned: $dirs"; return 1; }
grep -q "^/h/.claude/plugins" <<< "$dirs" \
&& { echo "plugins is not a config-dir mount any more: $dirs"; return 1; }
grep -q 'mounted_scan' "$REPO_ROOT/bin/dejima"
}
@@ -2896,7 +3540,7 @@ STUB
mkdir -p "$ws/.dejima"
printf 'tarball:definitely-not-here@%s:https://example.com/x.tar.gz\n' "$sha" > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | sandbox_context)
rm -rf "$ws"
[[ "$out" == *"definitely-not-here"* ]]
# The digest reaches the agent intact: it is what the operator is being
@@ -2922,7 +3566,7 @@ STUB
mkdir -p "$ws/.dejima"
printf 'tarball:%s@%s:https://example.com/x.tar.gz\n' "$present" "$sha" > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | sandbox_context)
rm -rf "$ws"
[[ "$out" != *"$present"* ]]
}
@@ -2963,7 +3607,7 @@ STUB
@test "sandbox-context reports nothing when no project declarations exist" {
local ws; ws="$(mktemp -d)"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | sandbox_context)
rm -rf "$ws"
[[ "$out" != *"declares requirements"* ]]
}
@@ -2975,7 +3619,7 @@ STUB
mkdir -p "$ws/.dejima"
printf 'apt:definitely-not-installed-noeol' > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | sandbox_context)
rm -rf "$ws"
[[ "$out" == *"definitely-not-installed-noeol"* ]]
}
@@ -2996,7 +3640,7 @@ STUB
printf 'dejima Docker container\n' > "$b"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" \
| DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash "$REPO_ROOT/hooks/sandbox-context.sh")
| sandbox_context DEJIMA_BRIEFING_FILE="$b")
rm -rf "$ws"; rm -f "$b"
printf '%s' "$out" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null
[[ "$out" == *"dejima Docker container"* ]]
@@ -3026,7 +3670,7 @@ EOF
chmod +x "$stub/npm"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" \
| PATH="$stub:$PATH" DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
| sandbox_context PATH="$stub:$PATH")
local calls; calls=$(wc -l < "$stub/calls")
rm -rf "$ws" "$stub"
[ "$calls" -eq 1 ]
@@ -3041,7 +3685,7 @@ EOF
mkdir -p "$ws/.dejima"
printf 'IGNORE-PREVIOUS-INSTRUCTIONS\nreal.example.com\n' > "$ws/.dejima/allowed-domains"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | sandbox_context)
rm -rf "$ws"
[[ "$out" != *"IGNORE-PREVIOUS-INSTRUCTIONS"* ]]
[[ "$out" == *"real.example.com"* ]]
+2
View File
@@ -29,6 +29,7 @@ const CLIENT_SOURCES = [
'src/mouse.js',
'src/gestures.js',
'src/viewport.js',
'src/keyboard.js',
'src/toolbar.js',
'src/main.js'
];
@@ -43,6 +44,7 @@ const MANIFEST_SOURCES = [
'package-lock.json',
'src/config.js',
'src/gestures.js',
'src/keyboard.js',
'src/keys.js',
'src/main.js',
'src/modes.js',
+228 -9
View File
@@ -10,7 +10,7 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Generated by web/build.mjs - do not edit. Sources: 73ce49c22acbea7ada626f21ffdc2a04ff7e002f28ff45a97457c677f57d4b3e -->
<!-- Generated by web/build.mjs - do not edit. Sources: 0d07a16b97dd65deac6d7985dfa4603b6f68ba0ac31dccbb77c314bc200016ac -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
@@ -355,16 +355,28 @@ body.touch #terminal {
touch-action: manipulation;
}
/* Flex rather than a grid with a column count in it. The count was 7, which
was the number of buttons at the time - adding one wrapped the row and broke
both. Equal-basis flex children divide the row however many there are, so a
button added later needs no change here. */
#toolbar .row {
display: grid;
grid-template-columns: repeat(7, 1fr);
display: flex;
gap: 4px;
}
#toolbar .row > button {
flex: 1 1 0;
min-width: 0;
}
#toolbar button {
min-height: 40px;
padding: 0 2px;
font: 600 13px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Clamped rather than fixed: eight buttons on a narrow phone leave about
forty pixels each, and the longest label is six characters. The lower
bound is where a monospace PREFIX still fits; the upper is the size this
has always been, so a wide screen is unchanged. */
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #d7dce3;
background: #232830;
border: 1px solid #343b45;
@@ -420,11 +432,13 @@ body.touch #terminal {
<button type="button" data-action="down" aria-label="Down">&#8595;</button>
<button type="button" data-action="up" aria-label="Up">&#8593;</button>
<button type="button" data-action="right" aria-label="Right">&#8594;</button>
<button type="button" data-action="enter" aria-label="Enter">&#9166;</button>
</div>
<div class="row">
<button type="button" data-action="prefix">PREFIX</button>
<button type="button" data-action="prev-window" aria-label="Previous window">&#9664;W</button>
<button type="button" data-action="next-window" aria-label="Next window">W&#9654;</button>
<button type="button" data-action="sessions" aria-label="Back to the session picker">SESS</button>
<button type="button" data-action="zoom">ZOOM</button>
<button type="button" data-action="drag">DRAG</button>
<button type="button" data-action="paste">PASTE</button>
@@ -730,7 +744,7 @@ body.touch #terminal {
// an xterm.js option and is handed to the terminal untouched, which is how
// the existing TTYD_FONT_SIZE knob keeps working with no code here knowing
// about it. isWindows is ttyd's own, always present, and not an xterm option.
var PAGE_KEYS = ['tmuxPrefix'];
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap'];
var IGNORED_KEYS = ['isWindows'];
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
@@ -770,12 +784,57 @@ body.touch #terminal {
return !!coarsePointer;
}
// Whether tapping the terminal should raise the soft keyboard.
//
// Off by default where the pointer is coarse, which is the whole reason this
// exists: on a phone, tapping is how you scroll and select, and every one of
// those taps used to summon the keyboard and cost a second tap to dismiss.
// The KBD button is the deliberate way to ask for it. On a fine pointer there
// is no soft keyboard to raise and focus-on-click is simply what a terminal
// does, so nothing changes there.
//
// ?focus=1 / ?focus=0 override per client, and focusOnTap in ttyd's
// preferences overrides for every client, the same shape as the toolbar
// switch above.
function focusOnTapWanted(search, coarsePointer, preference) {
var params = new URLSearchParams(search || '');
var value = params.get('focus');
if (value === '1') return true;
if (value === '0') return false;
// "on"/"off" first, because that is dejima's own bool spelling and what
// ttyd_focus_on_tap actually sends. The others are accepted because a
// preference can also arrive from anything that writes ttyd -t directly.
if (preference === 'on' || preference === true || preference === 'true' ||
preference === '1') return true;
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return !coarsePointer;
}
// The address of the session picker: this page with the run-id dropped.
//
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
// loaded without it runs dejima-attach with no argument, which is the picker.
// Every other parameter is kept, because they configure this page rather than
// the command - losing ?toolbar=1 on the way back would be a surprise.
//
// Returns a search string, "" when nothing is left, so a caller can build a
// URL without knowing whether to write the "?".
function pickerSearch(search) {
var params = new URLSearchParams(search || '');
params.delete('arg');
var rest = params.toString();
return rest ? '?' + rest : '';
}
D.config = {
DEFAULT_TMUX_PREFIX: DEFAULT_TMUX_PREFIX,
PAGE_KEYS: PAGE_KEYS,
parsePrefix: parsePrefix,
splitPreferences: splitPreferences,
toolbarWanted: toolbarWanted
toolbarWanted: toolbarWanted,
focusOnTapWanted: focusOnTapWanted,
pickerSearch: pickerSearch
};
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
@@ -1280,6 +1339,114 @@ body.touch #terminal {
D.viewport = { createViewport: createViewport };
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
// Whether a tap on the terminal may raise the soft keyboard.
//
// xterm.js focuses its textarea whenever the terminal is clicked, which on a
// desktop is exactly right and on a phone is not: tapping is also how you
// scroll and select, so every one of those taps summons the keyboard and costs
// a second tap to put it away. The keyboard is asked for explicitly instead,
// with the toolbar's KBD button.
//
// Done by letting focus land and dropping it, rather than by intercepting the
// tap. Focus arrives from several paths inside xterm.js - pointer, selection,
// programmatic - and catching each would mean knowing about each. Selection and
// scrolling are untouched, and the toolbar's own keys never go through the
// textarea, so they keep working with the keyboard down.
(function (D) {
'use strict';
// How much of the screen something has to cover before it is a keyboard
// rather than an address bar sliding into view.
var KEYBOARD_MIN_PX = 120;
// opts: {term, element, visualViewport, layoutHeight(), defer(fn)}
function createKeyboardPolicy(opts) {
// Both start permissive, so a page that never calls setRaiseOnTap behaves
// exactly as a terminal always has.
var raiseOnTap = true;
var allowed = true;
var keyboardWasUp = false;
var defer = opts.defer || function (fn) { setTimeout(fn, 0); };
var layoutHeight = opts.layoutHeight || function () { return window.innerHeight; };
function textarea() { return opts.term.textarea || null; }
// "The keyboard should stay down": taps do not raise it, and nobody has
// asked for it since it last went away.
function locked() { return !raiseOnTap && !allowed; }
function dropFocus() {
var el = textarea();
if (el && locked()) el.blur();
}
// Focus alone is not enough to hang this on. Tapping a textarea that is
// ALREADY the focused element raises the keyboard again and fires no focus
// event, and there are two ordinary ways to arrive at a focused textarea
// with the keyboard down: the terminal is focused when the connection
// opens, and a keyboard dismissed by a system gesture leaves focus where it
// was. Both were exactly the reported bug. So the tap is watched too, and
// the blur deferred to let xterm's own focus call happen first.
function onPointerDown() {
if (locked()) defer(dropFocus);
}
// Dismissing the keyboard means the next tap should not bring it back, so
// a blur re-locks. This is also how a dropped focus feeds back in.
function onBlur() { allowed = false; }
// A keyboard dismissed with the system's own gesture does not blur the
// element it belongs to, so KBD would otherwise unlock things for good.
// The visual viewport is the only thing that sees a keyboard at all: it is
// covered by one rather than resized by it.
function onViewportResize() {
var vv = opts.visualViewport;
if (!vv) return;
var up = (layoutHeight() - vv.height) > KEYBOARD_MIN_PX;
if (keyboardWasUp && !up && !raiseOnTap) {
allowed = false;
var el = textarea();
if (el) el.blur();
}
keyboardWasUp = up;
}
// Listeners are attached once, and the policy they consult is a variable.
// Re-attaching per policy change would stack duplicates, since preferences
// arrive again on every reconnect.
function attach() {
var el = textarea();
if (el) {
el.addEventListener('focus', dropFocus);
el.addEventListener('blur', onBlur);
}
if (opts.element) opts.element.addEventListener('pointerdown', onPointerDown);
if (opts.visualViewport) opts.visualViewport.addEventListener('resize', onViewportResize);
}
// Called at startup with the pointer-type default and again when ttyd's
// preferences arrive. Dropping focus here is what handles the terminal
// being focused already, which it is by the time preferences land.
function setRaiseOnTap(wanted) {
raiseOnTap = !!wanted;
allowed = raiseOnTap;
if (!raiseOnTap) dropFocus();
}
return {
attach: attach,
setRaiseOnTap: setRaiseOnTap,
raisesOnTap: function () { return raiseOnTap; },
// The one deliberate request for the keyboard, so the one thing that
// unlocks it.
allow: function () { allowed = true; }
};
}
D.keyboard = { createKeyboardPolicy: createKeyboardPolicy, KEYBOARD_MIN_PX: KEYBOARD_MIN_PX };
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
// The toolbar: keys a phone keyboard does not have, and the two tmux windows
// buttons that replace a status-bar entry a few pixels wide.
//
@@ -1287,6 +1454,8 @@ body.touch #terminal {
// whole reason tapping a button neither moves focus nor dismisses the soft
// keyboard - the default action of a pointerdown on a button is to focus it,
// which blurs the terminal's textarea and takes the keyboard down with it.
// Requires config.js, for the picker address the SESS button navigates to.
(function (D) {
'use strict';
@@ -1389,7 +1558,24 @@ body.touch #terminal {
refreshLatches();
},
paste: paste,
// Carriage return, not a newline. A terminal's Enter key is CR; sending
// LF here would submit nothing in a shell and insert a line in an editor.
enter: function () { opts.sendText('\r'); },
// Back to dejima-attach with no run-id, which is the picker. A full
// navigation rather than anything cleverer: the run-id reaches the
// command through ttyd's --url-arg on the WebSocket URL, so the only way
// to run a different one is a new connection.
//
// Safe for the session being left. dejima-attach gives each client its
// own tmux session grouped onto the real one and sets destroy-unattached
// on THAT, so detaching reaps the view and leaves the sandbox pane alone.
sessions: function () {
if (opts.navigate) opts.navigate(D.config.pickerSearch(opts.search()));
},
kbd: function () {
// The one deliberate request for the keyboard, so it is the one place
// that unlocks it - see focusOnTapWanted.
if (opts.allowKeyboard) opts.allowKeyboard();
opts.term.focus();
if (opts.term.textarea) opts.term.textarea.focus();
}
@@ -1485,6 +1671,30 @@ body.touch #terminal {
var viewport = D.viewport.createViewport({ app: app, onFit: fitNow });
// Whether this is a touch device, which decides both the toolbar below and
// the keyboard policy that follows. Probed here rather than beside its first
// use because applyPreferences needs it, and that runs on a message from
// ttyd - later in wall-clock time, but easier to reason about declared
// before the function that reads it.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
var keyboard = D.keyboard.createKeyboardPolicy({
term: term,
element: terminalHost,
visualViewport: window.visualViewport,
layoutHeight: function () { return window.innerHeight; }
});
keyboard.attach();
// Decided at startup from the pointer type, and again below when ttyd's
// preferences arrive. Both are needed: the connection opens before the
// preferences do, and what happens on open depends on this.
function tapFocusPolicy(preference) {
keyboard.setRaiseOnTap(D.config.focusOnTapWanted(
window.location.search, coarsePointer, preference));
}
tapFocusPolicy(undefined);
function applyPreferences(prefs) {
var split = D.config.splitPreferences(prefs);
Object.keys(split.terminal).forEach(function (key) {
@@ -1505,6 +1715,11 @@ body.touch #terminal {
}
prefixByte = parsed;
// ttyd sends preferences after the connection opens, so a focusOnTap set
// server-side always arrives later than the startup decision and has to be
// able to change it.
tapFocusPolicy(split.page.focusOnTap);
// A font size arriving in the preferences changes the cell size, so the
// column count computed before it was applied is now wrong.
viewport.refresh();
@@ -1519,7 +1734,9 @@ body.touch #terminal {
onStatus: function (status) {
if (status === 'open') {
clearNotice();
term.focus();
// Focusing here is what put the keyboard up on a phone before a single
// tap: it happens on every connect, including reconnects.
if (keyboard.raisesOnTap()) term.focus();
} else if (status === 'closed') {
notify('Disconnected. Reconnecting...', true);
}
@@ -1571,7 +1788,6 @@ body.touch #terminal {
// Touch affordances only where there is a touch. A desktop browser gets a
// plain terminal, which is what lets this ship as the default frontend.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
if (D.config.toolbarWanted(window.location.search, coarsePointer)) {
document.body.classList.add('touch');
toolbar = D.toolbar.createToolbar({
@@ -1584,7 +1800,10 @@ body.touch #terminal {
// ASCII, which UTF-8 leaves alone.
sendText: function (text) { client.sendInput(text); },
prefix: function () { return prefixByte; },
notify: notify
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
allowKeyboard: keyboard.allow
});
toolbar.attach();
+6 -5
View File
@@ -1,14 +1,15 @@
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
aa2a54392a0ab6bceae342ac0b12494d401b11574db12c013d0c164ebd10e018 src/config.js
38b24a901a43db06854133f4de11094e30c590ff08074bb35bf0d664975a8cb2 src/config.js
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
248b4b6e72eed31eda11d466e1483b33ed2c6796895c221f1d0aecfea9243eac src/main.js
7e2f5a89c28b048c6cc26d0a4a2a2411501f092dab11529a32ae3856f1350d63 src/main.js
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
6dddb5ed652fd5189916225115083fc38ae0380a45f82f59f0c388591aa74ccc src/page.css
7af901e2df49772c42fc1e083b4db8616e7d5f04bedf21f482da766a169165b7 src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
2369fbfdcd1d162c372f3dc361dce09a758db9671cbde64cb0debe57c12468a2 src/template.html
a87dc2980dc647c04464dff0ea859ee381f00c3caa752d3bd0ef1ba3218d97ee src/toolbar.js
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
+47 -2
View File
@@ -12,7 +12,7 @@
// an xterm.js option and is handed to the terminal untouched, which is how
// the existing TTYD_FONT_SIZE knob keeps working with no code here knowing
// about it. isWindows is ttyd's own, always present, and not an xterm option.
var PAGE_KEYS = ['tmuxPrefix'];
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap'];
var IGNORED_KEYS = ['isWindows'];
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
@@ -52,11 +52,56 @@
return !!coarsePointer;
}
// Whether tapping the terminal should raise the soft keyboard.
//
// Off by default where the pointer is coarse, which is the whole reason this
// exists: on a phone, tapping is how you scroll and select, and every one of
// those taps used to summon the keyboard and cost a second tap to dismiss.
// The KBD button is the deliberate way to ask for it. On a fine pointer there
// is no soft keyboard to raise and focus-on-click is simply what a terminal
// does, so nothing changes there.
//
// ?focus=1 / ?focus=0 override per client, and focusOnTap in ttyd's
// preferences overrides for every client, the same shape as the toolbar
// switch above.
function focusOnTapWanted(search, coarsePointer, preference) {
var params = new URLSearchParams(search || '');
var value = params.get('focus');
if (value === '1') return true;
if (value === '0') return false;
// "on"/"off" first, because that is dejima's own bool spelling and what
// ttyd_focus_on_tap actually sends. The others are accepted because a
// preference can also arrive from anything that writes ttyd -t directly.
if (preference === 'on' || preference === true || preference === 'true' ||
preference === '1') return true;
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return !coarsePointer;
}
// The address of the session picker: this page with the run-id dropped.
//
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
// loaded without it runs dejima-attach with no argument, which is the picker.
// Every other parameter is kept, because they configure this page rather than
// the command - losing ?toolbar=1 on the way back would be a surprise.
//
// Returns a search string, "" when nothing is left, so a caller can build a
// URL without knowing whether to write the "?".
function pickerSearch(search) {
var params = new URLSearchParams(search || '');
params.delete('arg');
var rest = params.toString();
return rest ? '?' + rest : '';
}
D.config = {
DEFAULT_TMUX_PREFIX: DEFAULT_TMUX_PREFIX,
PAGE_KEYS: PAGE_KEYS,
parsePrefix: parsePrefix,
splitPreferences: splitPreferences,
toolbarWanted: toolbarWanted
toolbarWanted: toolbarWanted,
focusOnTapWanted: focusOnTapWanted,
pickerSearch: pickerSearch
};
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
+107
View File
@@ -0,0 +1,107 @@
// Whether a tap on the terminal may raise the soft keyboard.
//
// xterm.js focuses its textarea whenever the terminal is clicked, which on a
// desktop is exactly right and on a phone is not: tapping is also how you
// scroll and select, so every one of those taps summons the keyboard and costs
// a second tap to put it away. The keyboard is asked for explicitly instead,
// with the toolbar's KBD button.
//
// Done by letting focus land and dropping it, rather than by intercepting the
// tap. Focus arrives from several paths inside xterm.js - pointer, selection,
// programmatic - and catching each would mean knowing about each. Selection and
// scrolling are untouched, and the toolbar's own keys never go through the
// textarea, so they keep working with the keyboard down.
(function (D) {
'use strict';
// How much of the screen something has to cover before it is a keyboard
// rather than an address bar sliding into view.
var KEYBOARD_MIN_PX = 120;
// opts: {term, element, visualViewport, layoutHeight(), defer(fn)}
function createKeyboardPolicy(opts) {
// Both start permissive, so a page that never calls setRaiseOnTap behaves
// exactly as a terminal always has.
var raiseOnTap = true;
var allowed = true;
var keyboardWasUp = false;
var defer = opts.defer || function (fn) { setTimeout(fn, 0); };
var layoutHeight = opts.layoutHeight || function () { return window.innerHeight; };
function textarea() { return opts.term.textarea || null; }
// "The keyboard should stay down": taps do not raise it, and nobody has
// asked for it since it last went away.
function locked() { return !raiseOnTap && !allowed; }
function dropFocus() {
var el = textarea();
if (el && locked()) el.blur();
}
// Focus alone is not enough to hang this on. Tapping a textarea that is
// ALREADY the focused element raises the keyboard again and fires no focus
// event, and there are two ordinary ways to arrive at a focused textarea
// with the keyboard down: the terminal is focused when the connection
// opens, and a keyboard dismissed by a system gesture leaves focus where it
// was. Both were exactly the reported bug. So the tap is watched too, and
// the blur deferred to let xterm's own focus call happen first.
function onPointerDown() {
if (locked()) defer(dropFocus);
}
// Dismissing the keyboard means the next tap should not bring it back, so
// a blur re-locks. This is also how a dropped focus feeds back in.
function onBlur() { allowed = false; }
// A keyboard dismissed with the system's own gesture does not blur the
// element it belongs to, so KBD would otherwise unlock things for good.
// The visual viewport is the only thing that sees a keyboard at all: it is
// covered by one rather than resized by it.
function onViewportResize() {
var vv = opts.visualViewport;
if (!vv) return;
var up = (layoutHeight() - vv.height) > KEYBOARD_MIN_PX;
if (keyboardWasUp && !up && !raiseOnTap) {
allowed = false;
var el = textarea();
if (el) el.blur();
}
keyboardWasUp = up;
}
// Listeners are attached once, and the policy they consult is a variable.
// Re-attaching per policy change would stack duplicates, since preferences
// arrive again on every reconnect.
function attach() {
var el = textarea();
if (el) {
el.addEventListener('focus', dropFocus);
el.addEventListener('blur', onBlur);
}
if (opts.element) opts.element.addEventListener('pointerdown', onPointerDown);
if (opts.visualViewport) opts.visualViewport.addEventListener('resize', onViewportResize);
}
// Called at startup with the pointer-type default and again when ttyd's
// preferences arrive. Dropping focus here is what handles the terminal
// being focused already, which it is by the time preferences land.
function setRaiseOnTap(wanted) {
raiseOnTap = !!wanted;
allowed = raiseOnTap;
if (!raiseOnTap) dropFocus();
}
return {
attach: attach,
setRaiseOnTap: setRaiseOnTap,
raisesOnTap: function () { return raiseOnTap; },
// The one deliberate request for the keyboard, so the one thing that
// unlocks it.
allow: function () { allowed = true; }
};
}
D.keyboard = { createKeyboardPolicy: createKeyboardPolicy, KEYBOARD_MIN_PX: KEYBOARD_MIN_PX };
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
+36 -3
View File
@@ -61,6 +61,30 @@
var viewport = D.viewport.createViewport({ app: app, onFit: fitNow });
// Whether this is a touch device, which decides both the toolbar below and
// the keyboard policy that follows. Probed here rather than beside its first
// use because applyPreferences needs it, and that runs on a message from
// ttyd - later in wall-clock time, but easier to reason about declared
// before the function that reads it.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
var keyboard = D.keyboard.createKeyboardPolicy({
term: term,
element: terminalHost,
visualViewport: window.visualViewport,
layoutHeight: function () { return window.innerHeight; }
});
keyboard.attach();
// Decided at startup from the pointer type, and again below when ttyd's
// preferences arrive. Both are needed: the connection opens before the
// preferences do, and what happens on open depends on this.
function tapFocusPolicy(preference) {
keyboard.setRaiseOnTap(D.config.focusOnTapWanted(
window.location.search, coarsePointer, preference));
}
tapFocusPolicy(undefined);
function applyPreferences(prefs) {
var split = D.config.splitPreferences(prefs);
Object.keys(split.terminal).forEach(function (key) {
@@ -81,6 +105,11 @@
}
prefixByte = parsed;
// ttyd sends preferences after the connection opens, so a focusOnTap set
// server-side always arrives later than the startup decision and has to be
// able to change it.
tapFocusPolicy(split.page.focusOnTap);
// A font size arriving in the preferences changes the cell size, so the
// column count computed before it was applied is now wrong.
viewport.refresh();
@@ -95,7 +124,9 @@
onStatus: function (status) {
if (status === 'open') {
clearNotice();
term.focus();
// Focusing here is what put the keyboard up on a phone before a single
// tap: it happens on every connect, including reconnects.
if (keyboard.raisesOnTap()) term.focus();
} else if (status === 'closed') {
notify('Disconnected. Reconnecting...', true);
}
@@ -147,7 +178,6 @@
// Touch affordances only where there is a touch. A desktop browser gets a
// plain terminal, which is what lets this ship as the default frontend.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
if (D.config.toolbarWanted(window.location.search, coarsePointer)) {
document.body.classList.add('touch');
toolbar = D.toolbar.createToolbar({
@@ -160,7 +190,10 @@
// ASCII, which UTF-8 leaves alone.
sendText: function (text) { client.sendInput(text); },
prefix: function () { return prefixByte; },
notify: notify
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
allowKeyboard: keyboard.allow
});
toolbar.attach();
+15 -3
View File
@@ -53,16 +53,28 @@ body.touch #terminal {
touch-action: manipulation;
}
/* Flex rather than a grid with a column count in it. The count was 7, which
was the number of buttons at the time - adding one wrapped the row and broke
both. Equal-basis flex children divide the row however many there are, so a
button added later needs no change here. */
#toolbar .row {
display: grid;
grid-template-columns: repeat(7, 1fr);
display: flex;
gap: 4px;
}
#toolbar .row > button {
flex: 1 1 0;
min-width: 0;
}
#toolbar button {
min-height: 40px;
padding: 0 2px;
font: 600 13px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Clamped rather than fixed: eight buttons on a narrow phone leave about
forty pixels each, and the longest label is six characters. The lower
bound is where a monospace PREFIX still fits; the upper is the size this
has always been, so a wide screen is unchanged. */
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #d7dce3;
background: #232830;
border: 1px solid #343b45;
+2
View File
@@ -30,11 +30,13 @@
<button type="button" data-action="down" aria-label="Down">&#8595;</button>
<button type="button" data-action="up" aria-label="Up">&#8593;</button>
<button type="button" data-action="right" aria-label="Right">&#8594;</button>
<button type="button" data-action="enter" aria-label="Enter">&#9166;</button>
</div>
<div class="row">
<button type="button" data-action="prefix">PREFIX</button>
<button type="button" data-action="prev-window" aria-label="Previous window">&#9664;W</button>
<button type="button" data-action="next-window" aria-label="Next window">W&#9654;</button>
<button type="button" data-action="sessions" aria-label="Back to the session picker">SESS</button>
<button type="button" data-action="zoom">ZOOM</button>
<button type="button" data-action="drag">DRAG</button>
<button type="button" data-action="paste">PASTE</button>
+19
View File
@@ -5,6 +5,8 @@
// whole reason tapping a button neither moves focus nor dismisses the soft
// keyboard - the default action of a pointerdown on a button is to focus it,
// which blurs the terminal's textarea and takes the keyboard down with it.
// Requires config.js, for the picker address the SESS button navigates to.
(function (D) {
'use strict';
@@ -107,7 +109,24 @@
refreshLatches();
},
paste: paste,
// Carriage return, not a newline. A terminal's Enter key is CR; sending
// LF here would submit nothing in a shell and insert a line in an editor.
enter: function () { opts.sendText('\r'); },
// Back to dejima-attach with no run-id, which is the picker. A full
// navigation rather than anything cleverer: the run-id reaches the
// command through ttyd's --url-arg on the WebSocket URL, so the only way
// to run a different one is a new connection.
//
// Safe for the session being left. dejima-attach gives each client its
// own tmux session grouped onto the real one and sets destroy-unattached
// on THAT, so detaching reaps the view and leaves the sandbox pane alone.
sessions: function () {
if (opts.navigate) opts.navigate(D.config.pickerSearch(opts.search()));
},
kbd: function () {
// The one deliberate request for the keyboard, so it is the one place
// that unlocks it - see focusOnTapWanted.
if (opts.allowKeyboard) opts.allowKeyboard();
opts.term.focus();
if (opts.term.textarea) opts.term.textarea.focus();
}
+65 -2
View File
@@ -3,8 +3,8 @@ const { test } = require('node:test');
const assert = require('node:assert');
require('../src/keys.js'); // config.js parses the tmux prefix through it
require('../src/config.js');
const { DEFAULT_TMUX_PREFIX, parsePrefix, splitPreferences, toolbarWanted } =
globalThis.DEJIMA.config;
const { DEFAULT_TMUX_PREFIX, parsePrefix, splitPreferences, toolbarWanted,
focusOnTapWanted, pickerSearch } = globalThis.DEJIMA.config;
test('a tmux prefix is parsed into the byte tmux would receive', () => {
assert.equal(parsePrefix('C-b'), '\x02');
@@ -53,3 +53,66 @@ test('the toolbar follows the pointer type unless the query string overrides it'
assert.equal(toolbarWanted('?arg=x&toolbar=1', false), true);
assert.equal(toolbarWanted('?toolbar=maybe', false), false, 'an unparseable value is not an override');
});
// Tapping the terminal used to raise the soft keyboard, which on a phone is
// where scrolling and selecting also happen - so every one of those taps cost
// a second tap to put the keyboard away again. The KBD button is the explicit
// way to ask for it.
test('a tap does not raise the keyboard on a touch device', () => {
assert.equal(focusOnTapWanted('', true, undefined), false);
});
test('a tap still focuses on a desktop, where there is no keyboard to raise', () => {
assert.equal(focusOnTapWanted('', false, undefined), true);
});
test('a client can override either way with ?focus=', () => {
assert.equal(focusOnTapWanted('?focus=1', true, undefined), true,
'a phone that wants the old behaviour can have it');
assert.equal(focusOnTapWanted('?focus=0', false, undefined), false);
});
test('the server preference is read in dejima\'s own bool spelling', () => {
// ttyd_focus_on_tap is a dejima bool, so what reaches the page is "on" or
// "off" - not "true"/"false". Accepting only the latter would have made the
// documented config key do nothing.
assert.equal(focusOnTapWanted('', true, 'on'), true);
assert.equal(focusOnTapWanted('', false, 'off'), false);
});
test('the server preference decides when the client says nothing', () => {
assert.equal(focusOnTapWanted('', true, 'true'), true);
assert.equal(focusOnTapWanted('', false, 'false'), false);
// ttyd hands -t values through as strings; booleans arrive from tests and
// from anything that parses the JSON itself.
assert.equal(focusOnTapWanted('', true, true), true);
assert.equal(focusOnTapWanted('', false, false), false);
});
test('the query string beats the server preference', () => {
assert.equal(focusOnTapWanted('?focus=0', false, 'true'), false);
assert.equal(focusOnTapWanted('?focus=1', true, 'false'), true);
});
// The session picker is this page with the run-id dropped: ttyd passes ?arg=
// to dejima-attach as argv, and dejima-attach with no argument is the picker.
test('the picker address drops the run-id', () => {
assert.equal(pickerSearch('?arg=abc123'), '');
});
test('the picker address keeps every other parameter', () => {
// ?toolbar=1 configures this page, not the command. Losing it on the way
// back to the picker would silently change the UI.
const r = pickerSearch('?arg=abc123&toolbar=1&focus=0');
const p = new URLSearchParams(r);
assert.equal(p.get('arg'), null, 'the run-id is what we are dropping');
assert.equal(p.get('toolbar'), '1');
assert.equal(p.get('focus'), '0');
});
test('the picker address is already the picker when there is no run-id', () => {
assert.equal(pickerSearch(''), '');
assert.equal(pickerSearch('?toolbar=1'), '?toolbar=1');
});
+163
View File
@@ -0,0 +1,163 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
require('../src/keyboard.js');
const { createKeyboardPolicy, KEYBOARD_MIN_PX } = globalThis.DEJIMA.keyboard;
// An element that records focus the way the DOM does, including the part that
// caused the bug: focusing something already focused fires no focus event.
function makeElement() {
const listeners = {};
return {
focused: false,
listeners: listeners,
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
fire: function (type) { (listeners[type] || []).forEach((fn) => fn()); },
focus: function () {
if (this.focused) return;
this.focused = true;
this.fire('focus');
},
blur: function () {
if (!this.focused) return;
this.focused = false;
this.fire('blur');
}
};
}
// term, terminal element and visual viewport, plus a defer() that runs on
// demand so a test can see the state between the tap and the blur.
function makeWorld(layoutHeight) {
const textarea = makeElement();
const element = makeElement();
const visualViewport = makeElement();
visualViewport.height = layoutHeight || 800;
const deferred = [];
const policy = createKeyboardPolicy({
term: { textarea: textarea },
element: element,
visualViewport: visualViewport,
layoutHeight: () => layoutHeight || 800,
defer: (fn) => deferred.push(fn)
});
policy.attach();
return {
policy, textarea, element, visualViewport,
tap: function () {
element.fire('pointerdown');
// xterm focuses the textarea from its own handler, synchronously, before
// anything deferred runs.
textarea.focus();
while (deferred.length) deferred.shift()();
},
keyboardTo: function (up) {
visualViewport.height = up ? (layoutHeight || 800) - KEYBOARD_MIN_PX - 1 : (layoutHeight || 800);
visualViewport.fire('resize');
}
};
}
test('with no policy set, a tap focuses the terminal as it always has', () => {
// The desktop path, and the one that ships if setRaiseOnTap is never called.
const w = makeWorld();
w.tap();
assert.equal(w.textarea.focused, true);
});
test('a tap does not leave the terminal focused when taps must not raise the keyboard', () => {
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.tap();
assert.equal(w.textarea.focused, false);
});
test('a tap on an ALREADY focused terminal still drops the focus', () => {
// The reported bug. Focusing a focused element fires no focus event, so a
// focus handler alone never runs - and the terminal is focused as a matter
// of course, because the page focuses it when the connection opens.
const w = makeWorld();
w.textarea.focus();
w.policy.setRaiseOnTap(false);
assert.equal(w.textarea.focused, false, 'the standing focus is dropped when the policy lands');
w.textarea.focused = true; // as if the system had put it back without an event
w.tap();
assert.equal(w.textarea.focused, false);
});
test('KBD is what raises the keyboard, and it survives a tap afterwards', () => {
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.allow();
w.textarea.focus();
assert.equal(w.textarea.focused, true, 'the deliberate request is honoured');
// Having asked for the keyboard, tapping to move the cursor must not take it
// away again.
w.tap();
assert.equal(w.textarea.focused, true);
});
test('dismissing the keyboard re-locks it, so the next tap does not bring it back', () => {
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.allow();
w.textarea.focus();
w.textarea.blur();
w.tap();
assert.equal(w.textarea.focused, false);
});
test('a keyboard closed by a system gesture re-locks, though it fires no blur', () => {
// Android's back gesture closes the keyboard and leaves the element focused.
// Without the viewport watching, one KBD would unlock taps permanently.
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.allow();
w.textarea.focus();
w.keyboardTo(true);
w.keyboardTo(false);
assert.equal(w.textarea.focused, false, 'focus is dropped when the keyboard goes');
w.textarea.focused = true;
w.tap();
assert.equal(w.textarea.focused, false);
});
test('the viewport is left alone where taps are meant to raise the keyboard', () => {
// A desktop window shrinking, or a phone with focus_on_tap = on. Nothing
// here should be stealing focus from a terminal that is meant to have it.
const w = makeWorld();
w.policy.setRaiseOnTap(true);
w.textarea.focus();
w.keyboardTo(true);
w.keyboardTo(false);
assert.equal(w.textarea.focused, true);
});
test('setting the policy again does not stack up listeners', () => {
// Preferences arrive on every reconnect, so this runs more than once.
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.setRaiseOnTap(false);
w.policy.setRaiseOnTap(true);
assert.equal(w.textarea.listeners.focus.length, 1);
assert.equal(w.textarea.listeners.blur.length, 1);
assert.equal(w.element.listeners.pointerdown.length, 1);
});
test('raisesOnTap reports the policy, so the page knows whether to focus on connect', () => {
const w = makeWorld();
assert.equal(w.policy.raisesOnTap(), true);
w.policy.setRaiseOnTap(false);
assert.equal(w.policy.raisesOnTap(), false);
});
test('a terminal with no textarea yet is not a crash', () => {
// term.textarea is null until the terminal is opened.
const policy = createKeyboardPolicy({ term: {}, element: makeElement() });
policy.attach();
policy.setRaiseOnTap(false);
policy.allow();
});
+83 -1
View File
@@ -1,8 +1,47 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
require('../src/keys.js'); // config.js parses the tmux prefix through it
require('../src/config.js'); // toolbar.js asks it for the picker address
require('../src/toolbar.js');
const { pasteSequence } = globalThis.DEJIMA.toolbar;
const { pasteSequence, createToolbar } = globalThis.DEJIMA.toolbar;
// A toolbar wired to spies, with an element stub standing in for the DOM the
// real one attaches to. fire() reaches the action table the way a tap does,
// through the pointerdown listener, so a button wired to the wrong name fails
// here rather than only on a phone.
function makeToolbar(overrides) {
const listeners = {};
const root = {
hidden: true,
addEventListener: (type, fn) => { listeners[type] = fn; },
// refreshLatches paints the CTRL and DRAG buttons; nothing here asserts on
// that, but the toolbar calls it on attach, so it has to find something.
querySelector: () => ({ classList: { toggle: () => {} } })
};
const opts = Object.assign({
root: root,
term: { focus: () => {}, textarea: { focus: () => {} } },
modes: { bracketedPaste: () => false },
latch: { state: () => 'off', armed: () => false, tap: () => {}, consume: () => {}, reset: () => {} },
sendText: () => {},
prefix: () => '\x02',
notify: () => {},
search: () => '',
navigate: () => {},
allowKeyboard: () => {}
}, overrides || {});
const toolbar = createToolbar(opts);
toolbar.attach();
return {
fire: function (action) {
listeners.pointerdown({
target: { closest: () => ({ dataset: { action: action } }) },
preventDefault: () => {}
});
}
};
}
// The clipboard is session-writable through OSC 52, so what PASTE sends is
// content that may not be the operator's. Without bracketed paste a trailing
@@ -41,3 +80,46 @@ test('stripping does not turn a multi-line paste into a silent one', () => {
// single-line and has nothing to warn about.
assert.equal(pasteSequence('single\n', false).warn, false);
});
// The two buttons added for a phone: Enter, because it is the key a harness
// wants most and no soft keyboard row has it where a thumb is; and SESS, to
// get back to the picker without closing the tab.
test('ENTER sends a carriage return, not a newline', () => {
// A terminal's Enter is CR. LF would submit nothing at a shell prompt and
// insert a line in an editor, which is the kind of difference that reads as
// "the button is broken".
const sent = [];
const t = makeToolbar({ sendText: (s) => sent.push(s) });
t.fire('enter');
assert.deepEqual(sent, ['\r']);
});
test('SESS navigates to the picker, dropping only the run-id', () => {
const went = [];
const t = makeToolbar({
search: () => '?arg=run-7&toolbar=1',
navigate: (s) => went.push(s)
});
t.fire('sessions');
assert.equal(went.length, 1);
const p = new URLSearchParams(went[0]);
assert.equal(p.get('arg'), null, 'the run-id is what sends it back to the picker');
assert.equal(p.get('toolbar'), '1', 'page options survive the trip');
});
test('KBD is what unlocks the keyboard, and it does so before focusing', () => {
// The order matters: the focus handler that drops an unwanted focus reads
// the flag, so setting it afterwards would blur the focus KBD just asked for.
const order = [];
const t = makeToolbar({
allowKeyboard: () => order.push('allow'),
term: {
focus: () => order.push('focus'),
textarea: { focus: () => order.push('textarea') }
}
});
t.fire('kbd');
assert.equal(order[0], 'allow', 'the keyboard is unlocked first');
assert.ok(order.includes('focus'));
});