Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a918047f93 | ||
|
|
68aedd157a | ||
|
|
e8de5edbf0 | ||
|
|
deabec04d4 | ||
|
|
7c1e95ab97 | ||
|
|
5a4a72ffb8 | ||
|
|
77f41fb60e | ||
|
|
ddedcf0c15 | ||
|
|
c8968a3c80 | ||
|
|
937b697d07 | ||
|
|
a7a4f0b39d | ||
|
|
e3eb54b97a | ||
|
|
0a671a5faa | ||
|
|
9998fe2679 | ||
|
|
3b14a4ad17 | ||
|
|
7b76de039c | ||
|
|
7070ca6f67 | ||
|
|
49205f50d4 | ||
|
|
43973f3c2f | ||
|
|
246921ace2 | ||
|
|
3bc9143918 | ||
|
|
71593180ad | ||
|
|
f55cc70e62 | ||
|
|
37483236fb | ||
|
|
a9dafeca41 | ||
|
|
f371c4d173 | ||
|
|
4c7967fbc9 | ||
|
|
00f1497cec | ||
|
|
423f7103c8 | ||
|
|
dc9d311b32 | ||
|
|
c7cb7e1e15 | ||
|
|
f2c1d79341 | ||
|
|
74a1c8c0d3 | ||
|
|
98ca03e3ee | ||
|
|
0bd28d6b38 | ||
|
|
eb16b39bf5 | ||
|
|
0f440b8817 | ||
|
|
135d06345a | ||
|
|
791f3bfcff | ||
|
|
bc418366a9 | ||
|
|
bb3c2a16d6 | ||
|
|
336f0e0b3c | ||
|
|
d3cc1b7d2e | ||
|
|
a0d6eb69ff | ||
|
|
fa4cd3cf76 | ||
|
|
fbe7c3a214 | ||
|
|
96b3912667 | ||
|
|
831b346b70 | ||
|
|
3e5c94743f | ||
|
|
964d27d385 | ||
|
|
5b27628fab | ||
|
|
5e3e2e0fe9 | ||
|
|
d5dd7357f3 | ||
|
|
dc0841e1c3 | ||
|
|
e638a5db23 | ||
|
|
c722b44ff6 | ||
|
|
992d87e06c | ||
|
|
f987286d01 | ||
|
|
37cda6ce5c | ||
|
|
75519c886e | ||
|
|
3801d3d359 | ||
|
|
c877b8c92d | ||
|
|
af0bc2f7d9 | ||
|
|
28fbc1973c | ||
|
|
de7f541d00 | ||
|
|
0ae85e04ee | ||
|
|
0503680fb1 | ||
|
|
9354287b1c | ||
|
|
3e5fc982bb | ||
|
|
106eec8b8b | ||
|
|
88a485a51d | ||
|
|
6f9485733a | ||
|
|
8eda94f997 | ||
|
|
b31c6aa540 | ||
|
|
c941abb1a4 | ||
|
|
d9a99a0285 | ||
|
|
60e95a4659 | ||
|
|
6dbcf4a6a7 | ||
|
|
7a57989a69 | ||
|
|
4b73b370a8 | ||
|
|
fec440f326 | ||
|
|
8cbdb79f86 | ||
|
|
2a1ab6e530 | ||
|
|
cedf3614d3 | ||
|
|
2de990e2a6 | ||
|
|
d33be93a27 | ||
|
|
b9e65838c5 | ||
|
|
3707cc2f52 | ||
|
|
2d615481d6 | ||
|
|
678c8104ac | ||
|
|
6d2bd73efc | ||
|
|
3652af7c6a | ||
|
|
1d60e9222f | ||
|
|
db99d0c986 | ||
|
|
2152bf7a11 | ||
|
|
dde5d78ec6 | ||
|
|
0f38edccb1 | ||
|
|
535460e337 | ||
|
|
2c38562d8d | ||
|
|
dfe1b42678 | ||
|
|
dde767ba0d | ||
|
|
d362e2d5b8 | ||
|
|
78f1afadda | ||
|
|
55edbadb98 | ||
|
|
08263f101f |
+14
-7
@@ -214,15 +214,22 @@ Branches here are opened against `development`, which merges to `main`
|
||||
periodically. `main` stays releasable while work lands quickly; nothing else
|
||||
about the workflow changes.
|
||||
|
||||
`gitea-pr` defaults its base to the repository's default branch, so say so:
|
||||
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.
|
||||
|
||||
```sh
|
||||
gitea-pr create --base development --title ... --body-file -
|
||||
```
|
||||
`gitea-pr` prints the base it is opening against, every time, and reads
|
||||
`git config gitea.prBase` before falling back to the repository's default
|
||||
branch. On a checkout with that set, `gitea-pr create` targets the right
|
||||
branch with no flag; on one without it, the line it prints is what tells you
|
||||
otherwise. Pass `--base development` when you want to be sure.
|
||||
|
||||
Forgetting is the likely mistake, and it is recoverable - a pull request's
|
||||
base can be changed after the fact, in the web UI or through the API. Check
|
||||
what you opened rather than assuming.
|
||||
Report the base it printed, not the one you meant. A pull request URL looks
|
||||
identical whichever branch it is against, so nothing else will contradict a
|
||||
wrong claim.
|
||||
|
||||
Rebase onto `origin/development` too, for the same reason: rebasing onto
|
||||
`main` puts anything already merged to `development` back in your diff.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,23 +27,23 @@
|
||||
**Agents work best without you babysitting them. Bound the blast radius, let them
|
||||
work, and assume they are hostile.**
|
||||
|
||||
An agent with permission prompts turned off gets a lot done. It can also be
|
||||
turned against you mid-session, by a prompt injection or a poisoned
|
||||
dependency, and it will not announce that it has been. dejima is built for
|
||||
that case: the agent works without asking, and what it can reach, take and
|
||||
break was decided before it started.
|
||||
Most sandboxes stop at the container. The blast radius does not. A contained
|
||||
session still opens pull requests, and your CI runs that commit before anyone
|
||||
reads it; an escape from the container lands on a machine with its own reach
|
||||
across your network. **dejima puts a control on every layer it passes
|
||||
through.**
|
||||
|
||||
dejima is a **sandboxed development environment for unsupervised agentic
|
||||
work.** It runs a coding agent (Claude Code, or whatever command you point it
|
||||
at) inside a firewalled Docker container with `--dangerously-skip-permissions`
|
||||
(no permission prompts, all tool use auto-accepted), against a project
|
||||
bind-mounted from the host. The agent gets to work without stopping to ask;
|
||||
the container is what makes that trade a reasonable one.
|
||||
work**: a coding agent (Claude Code, or whatever command you point it at)
|
||||
running with `--dangerously-skip-permissions` inside a firewalled Docker
|
||||
container, against a project bind-mounted from your host. A prompt injection or
|
||||
a poisoned dependency can turn it mid-session and it will not announce that, so
|
||||
what it can reach, take and break is decided before it starts.
|
||||
|
||||
A container on its own is only confinement. What makes this a development
|
||||
environment is the rest: a forge workflow the agent drives itself, project
|
||||
context written for the agent rather than for you, and enough of a trail left
|
||||
behind that you can walk away from a session and still pick up what it did.
|
||||
A container alone is only confinement. The rest is what makes it somewhere to
|
||||
work: a forge workflow the agent drives itself, project context written for the
|
||||
agent rather than for you, and enough of a trail that you can walk away from a
|
||||
session and still pick up what it did.
|
||||
|
||||
**What bounds it:**
|
||||
|
||||
@@ -82,7 +82,9 @@ behind that you can walk away from a session and still pick up what it did.
|
||||
- **A PR and issue workflow the agent drives itself.** `gitea-pr` and
|
||||
`gitea-issue` let a session open pull requests and issues against Gitea or
|
||||
Forgejo and pull review feedback back in, on a token scoped for exactly
|
||||
that. Merging stays a human decision, made outside the sandbox.
|
||||
that; on GitHub the same work goes through `gh`, plus a `github-pr` for the
|
||||
one thing `gh` cannot do. Merging stays a human decision, made outside the
|
||||
sandbox.
|
||||
- **Project context written for the agent.** A project ships a `.dejima.md`
|
||||
saying how work is done in it - `CLAUDE.md`'s job under a harness-agnostic
|
||||
name - and dejima loads it alongside a briefing on where the session is,
|
||||
@@ -422,6 +424,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
|
||||
@@ -659,7 +681,10 @@ finding is something to read and clean up before your next `git` or `claude`
|
||||
in that repo. Its diff is also time-windowed: it reports that a path changed
|
||||
while the session was running, not that the session changed it.
|
||||
|
||||
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it.
|
||||
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it,
|
||||
one finding at a time, with `[o]` to open the file before deciding. A report
|
||||
walked to the end stops being counted as waiting; it stays on disk as the
|
||||
record of what that session did, and rotation is what eventually removes it.
|
||||
Approving records the exact content, so any later change re-fires; ignoring
|
||||
records the surface, so that path is fine whatever it later contains; and
|
||||
skipping decides nothing, which means you are asked again next session.
|
||||
@@ -699,6 +724,7 @@ covers, and how to run them.
|
||||
| [responsible-use.md](docs/responsible-use.md) | How to use this without making things worse |
|
||||
| [security.md](docs/security.md) | Provider trust, the egress firewall, identity, and logging |
|
||||
| [threat-model.md](docs/threat-model.md) | What is assumed hostile, and where each layer's compromise stops |
|
||||
| [research-review.md](docs/research-review.md) | Checking the threat model against the field's, and the standing questions that are the agenda |
|
||||
| [configuration.md](docs/configuration.md) | Per-project declaration, dependencies, config parity |
|
||||
| [providers.md](docs/providers.md) | Forge, workflow and harness axes, and writing your own |
|
||||
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
|
||||
|
||||
+447
-9
@@ -9,6 +9,59 @@
|
||||
# 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):
|
||||
--harness <name> which harness to run
|
||||
--forge <name> which forge integration to use
|
||||
--workflow <name> what the agent is told to do with its work
|
||||
--cmd <command> run this instead of the harness's own command
|
||||
--identity <name> which [identity.<name>] supplies the credentials
|
||||
-i, --include <tool> add an optional tool to this launch (repeatable);
|
||||
see docs/tools.md for what dejima ships
|
||||
--model <id> the selected harness's model, where it has one;
|
||||
--base-url <url> its endpoint, and how much context it may use.
|
||||
--context-window <n> Refused for a harness with no such setting
|
||||
--ro <path> mount read-only; --rw read-write. Repeatable. Long
|
||||
forms --extra-readonly and --extra-read-write. Both
|
||||
refuse a path overlapping dejima's own stores
|
||||
--allow-github-push open GitHub's push path (port 22)
|
||||
--strict-audit refuse to start over unacknowledged CRITICAL findings
|
||||
--no-log ship nothing to the log endpoint this launch
|
||||
--no-conversation keep this session's transcript out of the log
|
||||
--no-epitaph keep nothing if this session compacts
|
||||
--no-firewall disable the egress firewall entirely (debugging only)
|
||||
--print-config print the resolved configuration and exit
|
||||
--print-briefing print the briefing the agent will get, and exit
|
||||
-h, --help this message
|
||||
|
||||
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
|
||||
config that says off is a decision, not a default to override in passing.
|
||||
|
||||
Config lives in ~/.config/dejima/config and [core] harness must be set before
|
||||
a launch starts. --print-config prints every key as it resolves.
|
||||
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
|
||||
@@ -66,10 +119,14 @@ fi
|
||||
FLAG_OVERRIDES=()
|
||||
_set_override() {
|
||||
local key="$1" value="$2" type entry
|
||||
# $3 is the flag to blame, for the flags whose spelling is not their key's:
|
||||
# --context-window sets context_window, and reporting the key back would
|
||||
# name an option the operator cannot type.
|
||||
local flag="${3:---${1##*.}}"
|
||||
local -a kept=()
|
||||
type="$(config_type "$key")" || { echo "unknown config key: $key" >&2; exit 1; }
|
||||
if ! _config_valid_value "$type" "$value"; then
|
||||
echo "invalid value for --${key##*.}: $value" >&2
|
||||
echo "invalid value for $flag: $value" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A repeated flag takes the last value, same as a repeated key in the
|
||||
@@ -145,9 +202,26 @@ _mount_label() {
|
||||
esac
|
||||
}
|
||||
|
||||
# A flag naming one of the SELECTED harness's keys, deferred because which
|
||||
# harness is selected is not known until every flag has been read - --harness
|
||||
# may come after --model. Recorded here, resolved below the loop.
|
||||
#
|
||||
# Deferred rather than resolved eagerly so the error can name the harness:
|
||||
# "claude-code has no model setting" is actionable where "unknown config key"
|
||||
# is not.
|
||||
HARNESS_KEY_FLAGS=()
|
||||
_defer_harness_key() {
|
||||
HARNESS_KEY_FLAGS+=("$1=$2=$3")
|
||||
}
|
||||
|
||||
PRINT_CONFIG=0
|
||||
PRINT_BRIEFING=0
|
||||
ALLOW_GITHUB_PUSH_FLAG=0
|
||||
# Additive, like --ro and --rw and unlike every other flag here: an optional
|
||||
# tool asked for on the command line is one MORE than the config and the
|
||||
# project already agreed on, not a replacement for them. Repeatable.
|
||||
TOOL_FLAGS=()
|
||||
NO_LOG=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift; break ;;
|
||||
@@ -155,8 +229,27 @@ while [ $# -gt 0 ]; do
|
||||
--print-briefing) PRINT_BRIEFING=1; shift ;;
|
||||
--no-firewall) _set_override core.firewall off; shift ;;
|
||||
--allow-github-push) ALLOW_GITHUB_PUSH_FLAG=1; shift ;;
|
||||
# Narrowing flags. Each has no counterpart that turns the setting back
|
||||
# on: a config saying off is a decision, and a flag that could undo it
|
||||
# in passing would make the file's answer the weaker one.
|
||||
--strict-audit) _set_override audit.strict on; shift ;;
|
||||
--no-epitaph) _set_override epitaph.enabled off; shift ;;
|
||||
--no-conversation) _set_override logging.conversation off; shift ;;
|
||||
--no-log) _set_override logging.loki_url ""
|
||||
_set_override logging.conversation off
|
||||
NO_LOG=1; shift ;;
|
||||
--model) _need_value --model "${2:-}"
|
||||
_defer_harness_key --model model "$2"; shift 2 ;;
|
||||
--base-url) _need_value --base-url "${2:-}"
|
||||
_defer_harness_key --base-url base_url "$2"; shift 2 ;;
|
||||
--context-window) _need_value --context-window "${2:-}"
|
||||
_defer_harness_key --context-window context_window "$2"; shift 2 ;;
|
||||
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
|
||||
--harness) _need_value --harness "${2:-}"; _set_override core.harness "$2"; shift 2 ;;
|
||||
--identity) _need_value --identity "${2:-}"; _set_override core.identity "$2"; shift 2 ;;
|
||||
--include|-i)
|
||||
_need_value --include "${2:-}"
|
||||
TOOL_FLAGS+=("$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)
|
||||
@@ -174,15 +267,52 @@ done
|
||||
# Defined here rather than in the library because an override is a property
|
||||
# of one invocation of this launcher, not of the config file.
|
||||
config_get() {
|
||||
local key="$1.$2" entry
|
||||
local key="$1.$2" entry value
|
||||
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
|
||||
[ "${entry%%=*}" = "$key" ] || continue
|
||||
printf '%s' "${entry#*=}"
|
||||
return 0
|
||||
done
|
||||
# The selected identity, for the handful of keys an identity owns: the
|
||||
# credentials and the ssh key a PERSON brings, which is the half a shared
|
||||
# project file cannot carry. Below a flag, because a flag is this launch
|
||||
# and an identity is this project; above the file, because the file is the
|
||||
# machine's default and naming an identity is how you say "not that one".
|
||||
#
|
||||
# Every consumer goes through config_get, including forge_env_exports in
|
||||
# the library, so this one wrapper is the whole insertion point.
|
||||
if value="$(identity_get "$1" "$2")"; then
|
||||
printf '%s' "$value"
|
||||
return 0
|
||||
fi
|
||||
_config_get_from_file "$1" "$2"
|
||||
}
|
||||
|
||||
# The deferred harness-key flags, now that --harness has been read and
|
||||
# config_get honours it. A key the selected harness does not declare is
|
||||
# refused by name rather than ignored: a --model that silently did nothing
|
||||
# because the harness has no such setting is the failure worth avoiding, and
|
||||
# it is the whole reason these are not resolved inside the loop above.
|
||||
if [ "${#HARNESS_KEY_FLAGS[@]}" -gt 0 ]; then
|
||||
_flag_harness="$(config_get core harness)"
|
||||
for _entry in "${HARNESS_KEY_FLAGS[@]}"; do
|
||||
_flag="${_entry%%=*}"
|
||||
_rest="${_entry#*=}"
|
||||
_key="${_rest%%=*}"
|
||||
_value="${_rest#*=}"
|
||||
if [ -z "$_flag_harness" ]; then
|
||||
echo "$_flag needs a harness: set [core] harness or pass --harness." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! config_type "harness.$_flag_harness.$_key" >/dev/null 2>&1; then
|
||||
echo "the $_flag_harness harness has no '$_key' setting, so $_flag does not apply to it." >&2
|
||||
exit 1
|
||||
fi
|
||||
_set_override "harness.$_flag_harness.$_key" "$_value" "$_flag"
|
||||
done
|
||||
unset _flag_harness _entry _flag _rest _key _value
|
||||
fi
|
||||
|
||||
# Retired environment variables. Named, never honoured: silently ignoring an
|
||||
# exported LOKI_URL means logs stop shipping with nothing saying why, and
|
||||
# silently ignoring SANDBOX_ANTHROPIC_TRANSPORT means a session quietly gets
|
||||
@@ -311,6 +441,18 @@ fi
|
||||
# testable without booting Docker.
|
||||
if [ "$PRINT_CONFIG" = 1 ]; then
|
||||
config_validate || exit 1
|
||||
# Before printing, not only before launching. Every value below resolves
|
||||
# THROUGH the identity, so printing them under a name that resolves to
|
||||
# nothing would show this machine's own credentials as though they were
|
||||
# the identity's - the exact confusion the refusal exists to prevent, in
|
||||
# the one command an operator runs to check what they configured.
|
||||
_identity=""
|
||||
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
|
||||
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
|
||||
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
|
||||
echo "anything in $(config_path)." >&2
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r _key; do
|
||||
_section="${_key%.*}"
|
||||
_leaf="${_key##*.}"
|
||||
@@ -412,6 +554,44 @@ case " $_corpus_approved " in
|
||||
esac
|
||||
unset _corpus_approved
|
||||
|
||||
# The optional tools this launch has: [core] tools, plus whatever the project
|
||||
# declared AND the operator approved, plus --include. A union rather than a
|
||||
# precedence chain, because each of the three answers a different question -
|
||||
# what this machine always wants, what this project needs, what this run is
|
||||
# for - and none of them is a correction of another.
|
||||
#
|
||||
# The project's half comes from the APPROVAL STORE and never from the
|
||||
# declaration in the workspace, for the reason the corpus block above gives:
|
||||
# that file is session-writable, and a session that could add a tool would be
|
||||
# choosing what the next session's image contains. This file may not name that
|
||||
# directory at all, and a static test holds it to that - which is why the path
|
||||
# is not spelled out here.
|
||||
#
|
||||
# Every name is checked against a manifest that exists before it reaches the
|
||||
# image build. approved_entries already refuses an entry its validator does
|
||||
# not accept, so the only unchecked source is the flag - which is the
|
||||
# operator's own typing, and gets the same answer.
|
||||
DEJIMA_TOOLS=""
|
||||
_tools_wanted="$(config_get core tools | tr ':' ' ')"
|
||||
_tools_wanted="$_tools_wanted $(approved_entries "$(config_root)" tools | tr '\n' ' ')"
|
||||
if [ -n "$PROJECT_STORE" ]; then
|
||||
_tools_wanted="$_tools_wanted $(approved_entries "$PROJECT_STORE" tools | tr '\n' ' ')"
|
||||
fi
|
||||
_tools_wanted="$_tools_wanted ${TOOL_FLAGS[*]-}"
|
||||
for _tool in $_tools_wanted; do
|
||||
[ -n "$_tool" ] || continue
|
||||
case " $DEJIMA_TOOLS " in *" $_tool "*) continue ;; esac
|
||||
if ! valid_tool_name "$_tool"; then
|
||||
echo "no optional tool named '$_tool'." >&2
|
||||
echo "Available: $(provider_list tool | tr '\n' ' ')" >&2
|
||||
echo "See docs/tools.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
tool_validate "$_tool" || exit 1
|
||||
DEJIMA_TOOLS="${DEJIMA_TOOLS:+$DEJIMA_TOOLS }$_tool"
|
||||
done
|
||||
unset _tools_wanted _tool
|
||||
|
||||
|
||||
AUDIT_DIR="$(config_get audit dir)"
|
||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
@@ -604,6 +784,61 @@ 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
|
||||
|
||||
# How this forge's CLI can be aimed somewhere other than the repository the
|
||||
# session was launched against, and what that repository is (#187).
|
||||
#
|
||||
# A CLI that resolves the repo from origin and refuses another host cannot be
|
||||
# aimed anywhere and declares nothing here. One that takes the repository as
|
||||
# an option makes acting on an unrelated repository the same length of command
|
||||
# as acting on this one. Which is which is the forge's to declare; core only
|
||||
# resolves the repository the declaration is compared against, and
|
||||
# docker/forge-guard.sh does the comparing.
|
||||
#
|
||||
# The last two path components of origin's URL, which is the same
|
||||
# "<owner>/<name>" both forges use and covers ssh, scp-style and https
|
||||
# remotes without parsing any of them as a URL. No origin, or a workspace
|
||||
# that is not a checkout, leaves this empty and the guard does nothing: the
|
||||
# comparison would have nothing to be right about.
|
||||
DEJIMA_FORGE_REPO_FLAGS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_flags)"
|
||||
DEJIMA_FORGE_REPO_ENV="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_env)"
|
||||
DEJIMA_FORGE_REPO=""
|
||||
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ]; then
|
||||
_origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
|
||||
if [ -n "$_origin_url" ]; then
|
||||
_origin_url="${_origin_url%/}"
|
||||
_origin_url="${_origin_url%.git}"
|
||||
_origin_name="${_origin_url##*/}"
|
||||
_origin_rest="${_origin_url%/*}"
|
||||
_origin_owner="${_origin_rest##*[:/]}"
|
||||
if [ -n "$_origin_owner" ] && [ -n "$_origin_name" ] &&
|
||||
[ "$_origin_owner" != "$_origin_url" ]; then
|
||||
DEJIMA_FORGE_REPO="$_origin_owner/$_origin_name"
|
||||
fi
|
||||
fi
|
||||
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.
|
||||
@@ -809,6 +1044,9 @@ SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
SANDBOX_CONVERSATION_SHIP_PID=""
|
||||
SANDBOX_TTY_SUSPEND_PID=""
|
||||
SANDBOX_TTY_MODES=""
|
||||
SANDBOX_CLIENT_PID=""
|
||||
|
||||
# Names each host-side task in cleanup() as it starts. Between the container
|
||||
# exiting and the operator's shell prompt returning, dejima audits every
|
||||
@@ -855,6 +1093,20 @@ 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 their watcher: the
|
||||
# container run restores what it saved when it attached, but a run that
|
||||
# was killed rather than exited restores nothing, and what that leaves
|
||||
# behind is a terminal with no interrupt character.
|
||||
if [ -n "${SANDBOX_TTY_MODES:-}" ]; then
|
||||
stty "$SANDBOX_TTY_MODES" < /dev/tty 2>/dev/null || true
|
||||
# And the cursor, for the same reason the modes are restored here: a
|
||||
# run that was killed rather than exited put neither back.
|
||||
printf '\033[?25h' > /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
|
||||
@@ -903,7 +1155,20 @@ cleanup() {
|
||||
# Ahead of the audit rather than after it, so the audit keeps the last
|
||||
# word (see below): this prints a line of its own whenever it keeps
|
||||
# something, and that line was landing under the verdict.
|
||||
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
||||
# Gated here as well as inside the tool, because a flag override lives in
|
||||
# this process and the tool re-reads the config for itself - it is also
|
||||
# run by hand, where an environment this launcher happened to have would
|
||||
# be the wrong authority. Same reason the shipper is gated at its start.
|
||||
#
|
||||
# DEJIMA_EPITAPH_NO_SHIP carries the one part of --no-log that this
|
||||
# process cannot decide for the tool: whether to push what it kept. It
|
||||
# only ever suppresses, so a stale export makes epitaph quieter and never
|
||||
# louder, which is what makes it safe to honour from the environment at
|
||||
# all.
|
||||
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
|
||||
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
|
||||
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
||||
fi
|
||||
# Last writer, structurally: the operator reads bottom-up from their
|
||||
# shell prompt, so anything printed after the verdict pushes it out of
|
||||
# view. The marker-directory warning above is the one line a session can
|
||||
@@ -938,6 +1203,23 @@ if [ -f "$HOME/.gitconfig" ]; then
|
||||
else
|
||||
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
|
||||
fi
|
||||
# The selected identity's git name, appended to the COPY. Appended rather than
|
||||
# rewritten because git takes the last value for a key, so this needs no
|
||||
# knowledge of what the operator's own file already says - and because the
|
||||
# copy is the only thing being edited, the real ~/.gitconfig is untouched
|
||||
# whatever happens here.
|
||||
#
|
||||
# `git config -f` rather than a printf of "[user]\n\tname = ...": the value is
|
||||
# the operator's own text, and git quotes and escapes its own file format
|
||||
# correctly where a printf would produce something git reads differently the
|
||||
# moment a name contains a quote or a backslash.
|
||||
for _git_field in name email; do
|
||||
_git_value="$(identity_git_field "user_$_git_field")" || _git_value=""
|
||||
[ -n "$_git_value" ] || continue
|
||||
git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" "user.$_git_field" "$_git_value" \
|
||||
|| { echo "could not set user.$_git_field from [identity] - is git installed?" >&2; exit 1; }
|
||||
done
|
||||
unset _git_field _git_value
|
||||
|
||||
export WORKSPACE_DIR
|
||||
# The image's user is built to match this uid/gid/username exactly (see
|
||||
@@ -1088,8 +1370,26 @@ LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL
|
||||
extra_mounts=()
|
||||
|
||||
# The forge's own env vars, generated from its manifest's env_export list
|
||||
# rather than hardcoded - see forge_env_exports. -e here: passed straight
|
||||
# through to `docker compose run`, which takes -e alongside -v.
|
||||
# rather than hardcoded - see forge_env_exports.
|
||||
#
|
||||
# `-e NAME`, never `-e NAME=value`, for the ones that carry a secret - which
|
||||
# is every `@` reference, since those resolve to a credential FILE'S CONTENTS
|
||||
# rather than its path. A value in the argument list lands in
|
||||
# /proc/<pid>/cmdline, which is world-readable: `ps` prints the token to any
|
||||
# process on the host, and from there into whatever captures ps output. That
|
||||
# is not a hypothetical - it is how #226 was found, in a paste an operator
|
||||
# made while debugging something unrelated.
|
||||
#
|
||||
# The bare form takes the value from THIS process's environment instead,
|
||||
# where it sits in /proc/<pid>/environ - mode 0400, owner only.
|
||||
#
|
||||
# This repository already made the argument one layer down: a forge's own CLI
|
||||
# feeds curl its auth header on stdin rather than as an argument, and says why
|
||||
# in almost these words. The reasoning was right and it had not reached here.
|
||||
#
|
||||
# Docker still shows the resolved value to anyone who can `docker inspect`
|
||||
# the container, which the bare form does not change and cannot. What it
|
||||
# changes is the audience: daemon access rather than any process at all.
|
||||
_forge_env_lines=""
|
||||
if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
|
||||
# forge_env_exports has already explained why on stderr. It can fail
|
||||
@@ -1099,8 +1399,15 @@ if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
|
||||
fi
|
||||
while IFS= read -r _forge_env; do
|
||||
[ -n "$_forge_env" ] || continue
|
||||
extra_mounts+=(-e "$_forge_env")
|
||||
# forge_env_exports emits nothing at all for a value that resolved empty,
|
||||
# deliberately - see its own comment on why an empty -e for a variable
|
||||
# compose already declares is not harmless. So every name reaching here
|
||||
# has a value, and exporting it is what makes the bare -e below carry one.
|
||||
_forge_env_name="${_forge_env%%=*}"
|
||||
export "${_forge_env_name}=${_forge_env#*=}"
|
||||
extra_mounts+=(-e "$_forge_env_name")
|
||||
done <<< "$_forge_env_lines"
|
||||
unset _forge_env_name
|
||||
|
||||
# The resolved combination, for the briefing and the hooks that read it
|
||||
# (git-push-guard.sh's host allowlist and no-direct-push branches,
|
||||
@@ -1113,6 +1420,10 @@ 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")
|
||||
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
|
||||
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
|
||||
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
|
||||
|
||||
# The harness's own configuration, mounted at the mode it declared, and -
|
||||
# for the directories it marked scannable - the symlink targets that have to
|
||||
@@ -1647,6 +1958,7 @@ SANDBOX_HARNESS_IMAGE_FILES="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image
|
||||
|
||||
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
||||
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ] \
|
||||
|| [ -n "$DEJIMA_TOOLS" ] \
|
||||
|| [ -s "$(config_root)/dependencies" ] \
|
||||
|| { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
|
||||
SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
|
||||
@@ -1675,6 +1987,34 @@ if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
||||
SANDBOX_IMAGE="$tag"
|
||||
fi
|
||||
|
||||
# One layer per tool, above the harness and below the dependencies. Above the
|
||||
# harness because a tool is meant to be on PATH for it; below the operator's
|
||||
# own dependencies because those are the last word on what the image holds.
|
||||
#
|
||||
# Tagged by name and version-of-spec so two projects wanting the same tool
|
||||
# share a layer, and a manifest that changes what it installs gets a new one
|
||||
# rather than a stale cached hit.
|
||||
for _tool in $DEJIMA_TOOLS; do
|
||||
_tool_install="$(manifest_get tool "$_tool" install)"
|
||||
_tool_env="$(manifest_get tool "$_tool" image_env)"
|
||||
_tool_layout="$(manifest_get tool "$_tool" install_layout)"
|
||||
tag="dejima:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
|
||||
DEJIMA_TARBALL_STRIP="$_tool_layout" \
|
||||
generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
|
||||
> "$SANDBOX_DERIVED_DOCKERFILE"
|
||||
echo "Building $tag ($_tool: $(manifest_get tool "$_tool" description))..."
|
||||
if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
|
||||
# No fallback, for the reason the harness layer gives: a session told
|
||||
# it has a tool and given an image without one fails inside the
|
||||
# container, where it reads as dejima being broken.
|
||||
echo "Failed to build $tag." >&2
|
||||
echo "$_tool installs from: $_tool_install" >&2
|
||||
exit 1
|
||||
fi
|
||||
SANDBOX_IMAGE="$tag"
|
||||
done
|
||||
unset _tool _tool_install _tool_env _tool_layout
|
||||
|
||||
if [ -s "$(config_root)/dependencies" ]; then
|
||||
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
|
||||
fi
|
||||
@@ -1772,10 +2112,108 @@ compose_status=0
|
||||
#
|
||||
# `|| true` and a backgrounded start: shipping is a bonus on top of the
|
||||
# session, so a shipper that cannot start must not stop the session running.
|
||||
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
|
||||
SANDBOX_CONVERSATION_SHIP_PID=$!
|
||||
#
|
||||
# Gated here as well as inside the shipper, which checks the same two keys for
|
||||
# itself: --no-conversation and --no-log are overrides held in this process,
|
||||
# and the shipper re-reads the config rather than inheriting them.
|
||||
if [ "$(config_get_bool logging conversation)" = "1" ] &&
|
||||
[ -n "$(config_get logging loki_url)" ]; then
|
||||
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
|
||||
SANDBOX_CONVERSATION_SHIP_PID=$!
|
||||
fi
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
|
||||
# Ctrl-Z (#179). Two things have to be true at once, and the second is the
|
||||
# one that took four rounds of measurement to find.
|
||||
#
|
||||
# The keystroke has to become a signal at all. Under a container run the
|
||||
# terminal is raw, ISIG is off, and Ctrl-Z is the byte 0x1a delivered into the
|
||||
# container - where a harness that implements suspend stops itself, with no
|
||||
# job-control shell in there to continue it. dejima-tty-suspend takes that one
|
||||
# character back for the host.
|
||||
#
|
||||
# And the container client must never receive SIGTSTP. Measured, holding
|
||||
# everything else equal: the client survives SIGSTOP and does not survive
|
||||
# SIGTSTP. A catchable stop signal interrupts the read it is blocked in, its
|
||||
# input forwarding never recovers, and the session comes back alive but deaf -
|
||||
# resumed, with nothing typed reaching the harness. That is what shipped once
|
||||
# and had to be taken out again.
|
||||
#
|
||||
# So the client is started with SIGTSTP ignored, which it inherits and keeps
|
||||
# across exec, and the launcher converts the keystroke into an explicit
|
||||
# SIGSTOP for it before stopping itself. The job still stops completely, so
|
||||
# the prompt comes back; the client is stopped by a signal that interrupts
|
||||
# nothing.
|
||||
#
|
||||
# < /dev/tty because a background command is given /dev/null for stdin, and a
|
||||
# container client with no terminal reads EOF and exits at once.
|
||||
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=$!
|
||||
|
||||
trap '' TSTP
|
||||
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" < /dev/tty &
|
||||
SANDBOX_CLIENT_PID=$!
|
||||
trap - TSTP
|
||||
|
||||
# The pid $! names is the docker CLI; `docker compose run` then runs the
|
||||
# compose plugin as a child of it, and the child is what holds the
|
||||
# terminal. Stopping only the pid leaves that child in raw mode with the
|
||||
# shell also on the terminal - a cursorless prompt that resumes to
|
||||
# nothing, which is what the first version of this fix produced.
|
||||
#
|
||||
# Deepest first, so nothing is left running long enough to fork again;
|
||||
# shallowest first on the way back, so a parent is ready before its child
|
||||
# resumes.
|
||||
_stop_tree() {
|
||||
local p="$1" kid
|
||||
for kid in $(pgrep -P "$p" 2>/dev/null); do _stop_tree "$kid"; done
|
||||
kill -STOP "$p" 2>/dev/null || true
|
||||
}
|
||||
_cont_tree() {
|
||||
local p="$1" kid
|
||||
kill -CONT "$p" 2>/dev/null || true
|
||||
for kid in $(pgrep -P "$p" 2>/dev/null); do _cont_tree "$kid"; done
|
||||
}
|
||||
|
||||
_on_tstp() {
|
||||
_stop_tree "$SANDBOX_CLIENT_PID"
|
||||
# Show the cursor again. A full-screen harness hides it (DECTCEM,
|
||||
# \033[?25l) and puts it back when it exits - but a suspend is not an
|
||||
# exit, so the shell prompt inherits an invisible cursor and the
|
||||
# terminal looks like a connection that died. The harness hides it
|
||||
# again on resume, when the SIGWINCH below makes it repaint.
|
||||
printf '\033[?25h' > /dev/tty 2>/dev/null || true
|
||||
# Default disposition to actually stop, then put the handler back for
|
||||
# the next time. Everything after the re-raise runs on resume.
|
||||
trap - TSTP
|
||||
kill -TSTP $$
|
||||
trap _on_tstp TSTP
|
||||
_cont_tree "$SANDBOX_CLIENT_PID"
|
||||
}
|
||||
trap _on_tstp TSTP
|
||||
|
||||
# Looped because `wait` returns early when a trap runs, which is exactly
|
||||
# what happens on every suspend.
|
||||
while kill -0 "$SANDBOX_CLIENT_PID" 2>/dev/null; do
|
||||
if wait "$SANDBOX_CLIENT_PID"; then
|
||||
compose_status=0
|
||||
else
|
||||
compose_status=$?
|
||||
fi
|
||||
# 128+n means a signal interrupted the wait rather than the client
|
||||
# exiting; anything else is the client's own status.
|
||||
case "$compose_status" in
|
||||
146|148) continue ;;
|
||||
esac
|
||||
break
|
||||
done
|
||||
trap - TSTP
|
||||
else
|
||||
# No terminal - a CI job, or output redirected. Nothing to suspend, and
|
||||
# /dev/tty would not open.
|
||||
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
|
||||
fi
|
||||
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
|
||||
echo "the session may have been failing. Check for proxy errors in the output above." >&2
|
||||
|
||||
+76
-3
@@ -17,6 +17,43 @@
|
||||
# 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.
|
||||
With several running, they are listed by project and by
|
||||
the number the dejima-attach picker gives each one.
|
||||
--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"
|
||||
@@ -94,25 +131,61 @@ fi
|
||||
# This is the only thing that decides. Deliberately not an environment
|
||||
# variable as well: a firewall CLI with an environment-triggered no-op path
|
||||
# is one an env var can silently disarm.
|
||||
# A container list an operator can act on without asking the agent anything.
|
||||
#
|
||||
# The name alone is a run id, which is exactly what someone with three
|
||||
# sessions open does not know. What they do know is which project each one is
|
||||
# working on, and - where remote access is on - the number the dejima-attach
|
||||
# picker gives it, because that is the list they are already reading.
|
||||
#
|
||||
# The project comes from the container's own working directory rather than
|
||||
# from the session registry, so it is there for every session and not only a
|
||||
# remote-enabled one. The picker number comes from `dejima-attach --list`,
|
||||
# whose rows the picker numbers 1..N in the order they are printed; absent
|
||||
# where remote access is off, which is a column missing rather than a failure.
|
||||
#
|
||||
# Reads only. Nothing here decides which container is acted on - that is the
|
||||
# caller's --container, or the single running one.
|
||||
describe_containers() {
|
||||
local listing="$1" name id project row n=0 idx
|
||||
local -A attach_index=()
|
||||
while IFS=$'\t' read -r _ _ _ _ _ row; do
|
||||
[ -n "$row" ] || continue
|
||||
n=$((n + 1))
|
||||
attach_index["$row"]="$n"
|
||||
done < <(dejima-attach --list 2>/dev/null || true)
|
||||
while IFS=$'\t' read -r name id; do
|
||||
[ -n "$name" ] || continue
|
||||
project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
|
||||
[ -n "$project" ] && project="$(basename "$project")"
|
||||
idx="${attach_index[${id:0:12}]:-}"
|
||||
printf ' %-28s %-24s %s\n' "$name" "${project:-?}" \
|
||||
"${idx:+(dejima-attach $idx)}"
|
||||
done <<< "$listing"
|
||||
}
|
||||
|
||||
session_wanted=1
|
||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||
[ -n "$container" ] || session_wanted=0
|
||||
fi
|
||||
if [ "$session_wanted" -eq 1 ]; then
|
||||
if [ -z "$container" ]; then
|
||||
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" --filter "status=running" --format '{{.Names}}')
|
||||
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" \
|
||||
--filter "status=running" --format '{{.Names}} {{.ID}}')
|
||||
count=$(echo "$containers" | grep -c . || true)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "No running dejima container found." >&2
|
||||
exit 1
|
||||
elif [ "$count" -gt 1 ]; then
|
||||
echo "Multiple running dejima containers found; specify one with --container:" >&2
|
||||
echo "$containers" >&2
|
||||
echo "" >&2
|
||||
describe_containers "$containers" >&2
|
||||
echo "" >&2
|
||||
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
|
||||
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
|
||||
exit 1
|
||||
fi
|
||||
container="$containers"
|
||||
container="${containers%%$'\t'*}"
|
||||
fi
|
||||
|
||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||
|
||||
@@ -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"
|
||||
|
||||
+259
-24
@@ -8,6 +8,45 @@
|
||||
# `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
|
||||
([o] at the prompt opens the file in $VISUAL/$EDITOR)
|
||||
--print-paths the resolved store paths, one per line, for scripts
|
||||
|
||||
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
|
||||
@@ -105,17 +144,78 @@ report_key() {
|
||||
printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
|
||||
}
|
||||
|
||||
# How many reports are on disk regardless of whether anything is outstanding.
|
||||
# The two numbers answer different questions and both get asked: an operator
|
||||
# told "no reports" while looking at a directory of them is being told
|
||||
# something false, even when nothing there wants a decision.
|
||||
count_reports_total() {
|
||||
find "$1" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l
|
||||
}
|
||||
|
||||
# A report walked to its end is done with, and stays on disk as the record of
|
||||
# what that session did - rotation evicts it, not `ack`. Everything below
|
||||
# therefore has to distinguish "on disk" from "still wants a decision", or the
|
||||
# operator is told work is waiting that no keystroke can clear. That was #195:
|
||||
# five reports waiting, "nothing new - 32 already dealt with", forever, with
|
||||
# the other four unreachable because selection kept returning the same one.
|
||||
report_dealt_with() {
|
||||
baseline_reviewed "$(report_key "$1")"
|
||||
}
|
||||
|
||||
# Reports that still want a decision, oldest first. The caller gets a list
|
||||
# rather than one path so counting and choosing cannot disagree about what is
|
||||
# outstanding.
|
||||
outstanding_reports() {
|
||||
local dir="$1" r
|
||||
while IFS= read -r r; do
|
||||
[ -n "$r" ] || continue
|
||||
report_dealt_with "$r" || printf '%s\n' "$r"
|
||||
done < <(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
|
||||
}
|
||||
|
||||
count_reports() {
|
||||
local dir="$1"
|
||||
printf '%s %s\n' \
|
||||
"$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l)" \
|
||||
"$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | wc -l)"
|
||||
local dir="$1" all crit r
|
||||
all=0; crit=0
|
||||
while IFS= read -r r; do
|
||||
[ -n "$r" ] || continue
|
||||
all=$((all + 1))
|
||||
case "$r" in *-CRITICAL.txt) crit=$((crit + 1)) ;; esac
|
||||
done < <(outstanding_reports "$dir")
|
||||
printf '%s %s\n' "$all" "$crit"
|
||||
}
|
||||
|
||||
# Like select_report, but skipping whatever this run has already been
|
||||
# through. A report can be left outstanding on purpose - the operator skipped
|
||||
# a finding in it, and skipping means ask again next time - and without this
|
||||
# the run would be handed that same report back, decide it is not a new one,
|
||||
# and stop, leaving every report behind it unread. That is the shape of #195
|
||||
# again, one step along: not "cannot retire" but "cannot move on".
|
||||
#
|
||||
# Reads the walked_this_run array the ack loop maintains; harmless where that
|
||||
# array does not exist, which is every other caller.
|
||||
select_next_report() {
|
||||
local dir="$1" r out unwalked=""
|
||||
out="$(outstanding_reports "$dir")"
|
||||
[ -n "$out" ] || return 1
|
||||
while IFS= read -r r; do
|
||||
[ -n "$r" ] || continue
|
||||
[ -n "${walked_this_run[$r]:-}" ] && continue
|
||||
unwalked="${unwalked}${unwalked:+$'\n'}$r"
|
||||
done <<< "$out"
|
||||
[ -n "$unwalked" ] || return 1
|
||||
r="$(printf '%s\n' "$unwalked" | grep -- '-CRITICAL\.txt$' | head -1)"
|
||||
[ -n "$r" ] || r="$(printf '%s\n' "$unwalked" | tail -1)"
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
|
||||
select_report() {
|
||||
local dir="$1" r
|
||||
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
|
||||
[ -n "$r" ] || r="$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort | tail -1)"
|
||||
local dir="$1" r out
|
||||
out="$(outstanding_reports "$dir")"
|
||||
[ -n "$out" ] || return 1
|
||||
# An unacknowledged CRITICAL first, whatever its age, then the newest of
|
||||
# the rest - the same order as before, over the outstanding ones only.
|
||||
r="$(printf '%s\n' "$out" | grep -- '-CRITICAL\.txt$' | head -1)"
|
||||
[ -n "$r" ] || r="$(printf '%s\n' "$out" | tail -1)"
|
||||
[ -n "$r" ] || return 1
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
@@ -1044,6 +1144,46 @@ show_capped() {
|
||||
fi
|
||||
}
|
||||
|
||||
# The file a finding is about, as it is on disk now, or empty when the finding
|
||||
# is not about a file at all - a git config key has no path, and a surface that
|
||||
# has since been removed has nothing to open.
|
||||
#
|
||||
# Deliberately the current file rather than the text the report captured: the
|
||||
# question `ack` asks is about what is on the host now, and an approval pins
|
||||
# the content that is there at the moment it is recorded. Reading a stale copy
|
||||
# and approving would be approving something nobody read.
|
||||
path_for_id() {
|
||||
local id="$1" f _hp
|
||||
if _hp="$(host_exec_path_for_id "$id")"; then
|
||||
f="$_hp"
|
||||
else
|
||||
case "$id" in
|
||||
gitconfig:*) return 1 ;;
|
||||
tcb:*) f="$ws/${id#tcb:}" ;;
|
||||
*) f="$ws/$id" ;;
|
||||
esac
|
||||
fi
|
||||
[ -f "$f" ] || return 1
|
||||
printf '%s\n' "$f"
|
||||
}
|
||||
|
||||
# The operator's own editor, read-only where the editor understands how.
|
||||
#
|
||||
# Read-only is the default because the file being opened is the thing under
|
||||
# audit: editing it mid-decision changes what an approval would then pin, and
|
||||
# an approval recorded against content the session did not write is a worse
|
||||
# record than no approval. -R is only added for the vi family, whose spelling
|
||||
# of it is known; anything else is run as the operator configured it.
|
||||
open_in_editor() {
|
||||
local file="$1" ed base
|
||||
ed="${VISUAL:-${EDITOR:-vi}}"
|
||||
base="$(basename "${ed%% *}")"
|
||||
case "$base" in
|
||||
vi|vim|nvim|view) "$ed" -R "$file" <&3 || true ;;
|
||||
*) "$ed" "$file" <&3 || true ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Where the operator can read the whole thing, when the display was capped.
|
||||
# Never sanitized through a path that would make it unfindable: it is the
|
||||
# audit's OWN text about a location, not workspace-controlled content.
|
||||
@@ -1498,8 +1638,17 @@ case "$cmd" in
|
||||
show)
|
||||
color_init 1
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
report="$(select_report "$report_dir")" || {
|
||||
_total="$(count_reports_total "$report_dir")"
|
||||
if [ "$_total" -gt 0 ]; then
|
||||
printf 'dejima audit: nothing waiting for %s.\n' "$ws" >&2
|
||||
printf ' %s report(s) kept in %s as the record of what those sessions did.\n' \
|
||||
"$_total" "$report_dir" >&2
|
||||
else
|
||||
echo "dejima audit: no reports for $ws" >&2
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
# Default is an index, not a dump. `cat` of one report was a wall of
|
||||
# text that answered neither question an operator actually has - how
|
||||
# much is waiting, and which of it still needs a decision - and it
|
||||
@@ -1537,8 +1686,16 @@ case "$cmd" in
|
||||
color_init 1
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
report="$(select_report "$report_dir")" || {
|
||||
_total="$(count_reports_total "$report_dir")"
|
||||
if [ "$_total" -gt 0 ]; then
|
||||
printf 'dejima audit: nothing waiting - every finding in %s report(s) has been dealt with.\n' \
|
||||
"$_total" >&2
|
||||
else
|
||||
echo "dejima audit: no reports for $ws" >&2
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
if [ "$n_all" -gt 1 ]; then
|
||||
printf '%s reports waiting' "$n_all"
|
||||
@@ -1546,10 +1703,25 @@ case "$cmd" in
|
||||
"$C_CRIT" "$n_crit" "$C_RESET"
|
||||
printf '.\n\n'
|
||||
fi
|
||||
# What has already been put in front of the operator during THIS
|
||||
# invocation, keyed by surface and the content that is on disk now.
|
||||
#
|
||||
# Reports overlap: the same file can appear in several, and `ack`
|
||||
# walks them in one run. Once its current content has been shown, an
|
||||
# older report asking about the same file is asking about a version
|
||||
# that no longer exists - the operator has seen what is there and
|
||||
# decided, or declined to. Approving covers this through the baseline
|
||||
# already; skipping does not, and that is the case this handles.
|
||||
declare -A shown_this_run=()
|
||||
# Reports this invocation has already walked, so it moves on rather
|
||||
# than being handed one of them again.
|
||||
declare -A walked_this_run=()
|
||||
|
||||
# One report per pass, every report in one invocation. Walking one and
|
||||
# exiting meant clearing a backlog took as many runs as there were
|
||||
# reports, with nothing on screen to say another was behind it.
|
||||
while :; do
|
||||
walked_this_run["$report"]=1
|
||||
sc="$(sidecar_dir "$report")"
|
||||
# Removed only at the end, and only for findings actually
|
||||
# acknowledged - an abort must leave the operator exactly where they
|
||||
@@ -1572,6 +1744,11 @@ case "$cmd" in
|
||||
# Like unackable_shown, it means the report has been dealt with as far
|
||||
# as it can be, so it earns a `reviewed` marker.
|
||||
removed_cleared=0
|
||||
# Findings the operator chose to be asked about again, and un-ackable
|
||||
# ones that were shown but could not be recorded either way. The first
|
||||
# keeps the report outstanding; the second must not, or it re-prompts
|
||||
# forever - which is the distinction the reviewed marker turns on.
|
||||
left_undecided=0
|
||||
# Findings passed over because the baseline already covers them, or
|
||||
# because this report has been walked before - counted so a run that
|
||||
# displays nothing can say why instead of looking broken.
|
||||
@@ -1712,6 +1889,23 @@ case "$cmd" in
|
||||
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
|
||||
continue
|
||||
fi
|
||||
# Already shown this run, for the content that is on disk now.
|
||||
# Counted as dealt with rather than silently dropped, so a report
|
||||
# that displays nothing still says why.
|
||||
#
|
||||
# crit_acked is deliberately NOT advanced: nothing was approved,
|
||||
# so a report whose CRITICAL was only superseded must keep its
|
||||
# -CRITICAL.txt. And left_undecided is set, so this report is not
|
||||
# retired on the strength of a question that was never answered -
|
||||
# it comes back on the next run, where the operator meets it once.
|
||||
if [ "$ackable" -eq 1 ]; then
|
||||
_seen_key="$rid"$'\t'"$(current_sha_for_id "$rid")"
|
||||
if [ -n "${shown_this_run[$_seen_key]:-}" ]; then
|
||||
already=$((already + 1))
|
||||
left_undecided=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
# A finding cleared on an earlier pass because its surface was
|
||||
# gone. Nothing was recorded for it - there is no content to pin -
|
||||
# so baseline_suppresses cannot cover it, and it is ackable, so
|
||||
@@ -1744,7 +1938,10 @@ case "$cmd" in
|
||||
else
|
||||
printf ' %s[%s]%s %s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" "$body"
|
||||
fi
|
||||
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
|
||||
if [ "$ackable" -eq 1 ]; then
|
||||
render_finding "$rid" "$sc"
|
||||
shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
|
||||
fi
|
||||
# Each option says what it does to FUTURE findings, because that
|
||||
# is the whole difference between the two that record something
|
||||
# and it was not visible: approve pins this exact content and
|
||||
@@ -1761,16 +1958,46 @@ case "$cmd" in
|
||||
esac
|
||||
k_a="${C_KEY}[a]$C_RESET"; k_i="${C_KEY}[i]$C_RESET"
|
||||
k_s="${C_KEY}[s]$C_RESET"; k_q="${C_KEY}[q]$C_RESET"
|
||||
k_o="${C_KEY}[o]$C_RESET"
|
||||
# Offered only where there is a file to open. The report shows the
|
||||
# part that changed, which is chosen by what changed and not by
|
||||
# what a person needs in order to judge it - three changed lines in
|
||||
# a hook script are only judgeable by reading the script.
|
||||
open_path="$(path_for_id "$rid")" || open_path=""
|
||||
[ "$ackable" -eq 1 ] || open_path=""
|
||||
open_hint=""
|
||||
[ -n "$open_path" ] && open_hint=" $k_o open the file"
|
||||
# Looped, so opening the file returns to the same decision rather
|
||||
# than counting as one.
|
||||
key=""
|
||||
while :; do
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
printf ' %s skip %s quit (this finding cannot be acknowledged): ' "$k_s" "$k_q"
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
printf ' %s approve this exact content %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$k_s" "$k_q"
|
||||
printf ' %s approve this exact content%s %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$open_hint" "$k_s" "$k_q"
|
||||
else
|
||||
printf ' %s approve this exact content %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$k_i" "$ign_noun" "$k_s" "$k_q"
|
||||
printf ' %s approve this exact content%s %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$open_hint" "$k_i" "$ign_noun" "$k_s" "$k_q"
|
||||
fi
|
||||
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
|
||||
{ read -r key <&3; } 2>/dev/null || { aborted=1; break 2; }
|
||||
case "$key" in
|
||||
o)
|
||||
if [ -z "$open_path" ]; then
|
||||
echo " nothing to open: this finding is not about a file that exists."
|
||||
continue
|
||||
fi
|
||||
open_in_editor "$open_path"
|
||||
# Re-read after the editor: the content may have moved on,
|
||||
# and what gets approved below is whatever is there when
|
||||
# the key is pressed, not what was rendered above.
|
||||
render_finding "$rid" "$sc"
|
||||
shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
|
||||
continue ;;
|
||||
esac
|
||||
break
|
||||
done
|
||||
[ "$aborted" -eq 1 ] && break
|
||||
case "$key" in
|
||||
q|$'\e'|'') aborted=1; break ;;
|
||||
i)
|
||||
@@ -1783,7 +2010,7 @@ case "$cmd" in
|
||||
sc_done+=("$rid")
|
||||
fi
|
||||
;;
|
||||
s) ;;
|
||||
s) [ "$ackable" -eq 1 ] && left_undecided=1 ;;
|
||||
a)
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
echo " cannot acknowledge: this finding cannot be acknowledged."
|
||||
@@ -1826,11 +2053,19 @@ case "$cmd" in
|
||||
echo ""; echo "dejima audit: nothing recorded."
|
||||
exit 0
|
||||
fi
|
||||
# Written once the report has been walked to its end, and only when
|
||||
# it actually showed something that cannot be acknowledged - there is
|
||||
# nothing else to stop those from re-prompting forever.
|
||||
if { [ "$unackable_shown" -eq 1 ] || [ "$removed_cleared" -eq 1 ]; } \
|
||||
&& [ "$report_reviewed" -eq 0 ]; then
|
||||
# Written once the report has been walked to its end with nothing
|
||||
# left undecided. It used to be written only for a report carrying
|
||||
# something that cannot be acknowledged, which left a report whose
|
||||
# findings were all approved with no record that it had been finished
|
||||
# - so selection kept returning it and the ones behind it were never
|
||||
# reached (#195).
|
||||
#
|
||||
# The undecided check is what keeps "skip (ask again next time)"
|
||||
# honest. Retiring the report retires every finding in it, so a marker
|
||||
# written over a skipped finding would turn that promise into "never
|
||||
# ask again" - silently, and for a finding the operator had looked at
|
||||
# and declined to approve.
|
||||
if [ "$report_reviewed" -eq 0 ] && [ "$left_undecided" -eq 0 ]; then
|
||||
pending+=("reviewed $(report_key "$report")")
|
||||
fi
|
||||
if [ "${#pending[@]}" -gt 0 ]; then
|
||||
@@ -1873,7 +2108,7 @@ case "$cmd" in
|
||||
# Next report, if there is one. Asked rather than assumed: an operator
|
||||
# who has decided enough for now should not have to abort out of a
|
||||
# report they have not read to stop.
|
||||
next_report="$(select_report "$report_dir")" || next_report=""
|
||||
next_report="$(select_next_report "$report_dir")" || next_report=""
|
||||
if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
printf '\n%s report(s) still waiting' "$n_all"
|
||||
|
||||
@@ -42,6 +42,8 @@ core.harness provider:harness
|
||||
core.forge provider:forge none
|
||||
core.workflow provider:workflow local-only
|
||||
core.command ?identifier
|
||||
core.identity ?identifier
|
||||
core.tools ?list:identifier
|
||||
core.firewall bool on
|
||||
core.share_dir path ~/.local/share/dejima/shared
|
||||
core.ssh_key ?path
|
||||
@@ -68,6 +70,8 @@ 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.ttyd_button_colors ?bool
|
||||
remote-access.debounce int:0:86400 60
|
||||
EOF
|
||||
}
|
||||
@@ -247,8 +251,23 @@ valid_tarball_spec() {
|
||||
# A declared domain has to survive being written into a dnsmasq
|
||||
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
|
||||
# there is one definition and no pair of them to drift apart.
|
||||
# A hostname, label by label. The matcher is a trust boundary: what it accepts
|
||||
# becomes a dnsmasq `server=` line and an ipset entry, so it should accept only
|
||||
# what it means to rather than only what it can see a use for.
|
||||
#
|
||||
# The previous form allowed an EMPTY label - `a..b.com` matched - because its
|
||||
# repeated group could consume a dot having matched no characters before it.
|
||||
# Harmless in practice, since nothing that shape resolves, and fixed on the
|
||||
# principle rather than on a known abuse: the published bypass in this space
|
||||
# came from a matcher accepting a string the resolver read differently.
|
||||
#
|
||||
# Per label: 1-63 characters, starting and ending alphanumeric, hyphens in
|
||||
# between. That also newly refuses a label with a leading or trailing hyphen,
|
||||
# which is not a hostname either. A single label with no dot stays legal - an
|
||||
# internal name resolved by the host's own resolver is a real case.
|
||||
valid_domain() {
|
||||
[[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]
|
||||
local label='[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
|
||||
[[ "$1" =~ ^$label(\.$label)*$ ]]
|
||||
}
|
||||
|
||||
# Extension point for types this layer cannot judge on its own. A stub here,
|
||||
@@ -350,6 +369,20 @@ _config_valid_value() {
|
||||
identifier)
|
||||
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
|
||||
;;
|
||||
text)
|
||||
# A sentence rather than a token - a person's name, an email
|
||||
# address, a one-line description. An identifier has no room for a
|
||||
# space and a path refuses a colon, and all of those are legal
|
||||
# here. Refuses only what cannot survive the trip: a newline splits
|
||||
# the value where every consumer expects one line, and a control
|
||||
# character reaching a terminal is the escape-sequence hazard
|
||||
# docs/security.md names. Everything else is the operator's
|
||||
# business, the same reading `path` takes of a space.
|
||||
case "$value" in
|
||||
*[$'\n\r\t']*) return 1 ;;
|
||||
esac
|
||||
[[ ! "$value" =~ [[:cntrl:]] ]]
|
||||
;;
|
||||
chord)
|
||||
# A tmux control chord, as the remote terminal page's PREFIX,
|
||||
# window and ZOOM buttons send. Narrower than tmux's own prefix
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+325
-8
@@ -21,22 +21,335 @@
|
||||
# file it came from. This preserves what was recorded, it does not attest to
|
||||
# it.
|
||||
#
|
||||
# Usage: dejima-epitaph save <workspace>
|
||||
# Usage: dejima-epitaph save <workspace>
|
||||
# dejima-epitaph list [--all] [workspace]
|
||||
# dejima-epitaph search [--all] <pattern> [workspace]
|
||||
# dejima-epitaph show [n | name] [workspace]
|
||||
#
|
||||
# `save` is what bin/dejima's exit trap runs. The other three are the
|
||||
# operator's, and they only ever read from the host: nothing here gives a
|
||||
# container a path to the store, and nothing here hands an epitaph back to a
|
||||
# session. That second one is the load-bearing half. A session can write its
|
||||
# own transcript, so it can author what its own epitaph says - delivering
|
||||
# that to a later session automatically would plant instructions in its
|
||||
# premises, which is the channel the never-mounted store exists to close
|
||||
# (#104). Printing to the operator's terminal keeps a person in the middle,
|
||||
# and that is the whole design rather than a missing feature.
|
||||
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>
|
||||
dejima-epitaph list [--all] [workspace]
|
||||
dejima-epitaph search [--all] <pattern> [workspace]
|
||||
dejima-epitaph show [n | name] [workspace]
|
||||
|
||||
save extract any compaction summaries from that workspace's transcripts
|
||||
and keep them. Run by bin/dejima when a session exits, not by hand
|
||||
list what is kept, oldest first, and whether the session behind each is
|
||||
still resumable - where it is, resuming beats reading a summary
|
||||
search case-insensitive regex over the bodies, reporting each hit's
|
||||
position in the listing list prints
|
||||
show open one in $VISUAL or $EDITOR, or print it where there is no
|
||||
terminal to open an editor on
|
||||
--all widen list and search to every project rather than this one
|
||||
|
||||
A harness that compacts a long session writes a summary of what it is about to
|
||||
forget, and it goes away with the container. This keeps it, host-side, in a
|
||||
store nothing mounts - so one session cannot read what an earlier one concluded
|
||||
or edit what this one is about to record.
|
||||
|
||||
Reading works with [epitaph] enabled off: turning the feature off is when
|
||||
reviewing and clearing the store is most likely.
|
||||
|
||||
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
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
||||
# shellcheck source=dejima-harness-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-harness-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||
config_validate || exit 1
|
||||
color_init 1
|
||||
|
||||
cmd="${1:-}"; ws_arg="${2:-}"
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: dejima-epitaph save <workspace>
|
||||
dejima-epitaph list [--all] [workspace]
|
||||
dejima-epitaph search [--all] <pattern> [workspace]
|
||||
dejima-epitaph show [n | name] [workspace]
|
||||
|
||||
n is a position in the listing `list` and `search` print, so it means what
|
||||
those just showed - including under --all, which numbers every project's
|
||||
epitaphs in one sequence.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
shift 2>/dev/null || true
|
||||
# --all anywhere, so it reads naturally either side of a search pattern.
|
||||
ALL=0; args=()
|
||||
for a in ${@+"$@"}; do
|
||||
case "$a" in
|
||||
--all) ALL=1 ;;
|
||||
-h|--help) usage ;;
|
||||
*) args+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
set -- ${args[@]+"${args[@]}"}
|
||||
|
||||
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
|
||||
STORE="$(config_get epitaph dir)"
|
||||
|
||||
# `enabled` gates writing, not reading. Turning epitaph off must not strand
|
||||
# what it already kept behind a config key: an operator who switches it off
|
||||
# is the one most likely to want to look at, and get rid of, the existing
|
||||
# store.
|
||||
case "$cmd" in
|
||||
save) [ "$(config_get epitaph enabled)" = "on" ] || exit 0 ;;
|
||||
list|search|show) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
# Every epitaph belonging to $1, oldest first. The sort key is the basename,
|
||||
# not the path: names begin with the timestamp, so this is chronological
|
||||
# across a workspace's several slugs rather than grouped by whichever slug
|
||||
# directory each file happens to sit in.
|
||||
_epitaphs_in() {
|
||||
local f
|
||||
[ -d "$1" ] || return 0
|
||||
for f in "$1"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\n' "$(basename "$f")" "$f"
|
||||
done
|
||||
}
|
||||
epitaph_files() {
|
||||
local ws="$1" slug d
|
||||
{
|
||||
if [ "$ALL" = 1 ]; then
|
||||
for d in "$STORE"/*/; do _epitaphs_in "${d%/}"; done
|
||||
else
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
_epitaphs_in "$STORE/$slug"
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
fi
|
||||
} | sort | cut -f2-
|
||||
}
|
||||
|
||||
# The workspace to read from: the one given, else this checkout. Tries the
|
||||
# worktree top and then the main checkout that worktree belongs to, taking
|
||||
# the first that actually has epitaphs - the same shape as dejima-audit's
|
||||
# resolve_ws, and it matters more here. A worktree only has epitaphs of its
|
||||
# own if dejima was launched against the worktree itself; run from one after
|
||||
# working in the main checkout, the answer is the main checkout.
|
||||
resolve_ws() {
|
||||
local given="$1" top common main c
|
||||
if [ -n "$given" ]; then
|
||||
( cd "$given" 2>/dev/null && pwd ) || printf '%s' "$given"
|
||||
return
|
||||
fi
|
||||
top="$(git rev-parse --show-toplevel 2>/dev/null)" || top=""
|
||||
common="$(git rev-parse --git-common-dir 2>/dev/null)" || common=""
|
||||
main=""
|
||||
if [ -n "$common" ]; then
|
||||
common="$(readlink -f "$common" 2>/dev/null)" || common=""
|
||||
[ -n "$common" ] && main="$(dirname "$common")"
|
||||
fi
|
||||
for c in "$top" "$main" "$PWD"; do
|
||||
[ -n "$c" ] || continue
|
||||
[ -n "$(epitaph_files "$c")" ] || continue
|
||||
printf '%s' "$c"
|
||||
return
|
||||
done
|
||||
printf '%s' "$PWD"
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
list)
|
||||
ws="$(resolve_ws "${1:-}")"
|
||||
mapfile -t FILES < <(epitaph_files "$ws")
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
if [ "$ALL" = 1 ]; then echo "dejima epitaph: nothing kept yet." >&2
|
||||
else echo "dejima epitaph: nothing kept for $ws." >&2; fi
|
||||
exit 0
|
||||
fi
|
||||
if [ "$ALL" = 1 ]; then printf '%s epitaph(s), all projects\n\n' "${#FILES[@]}"
|
||||
else printf '%s epitaph(s) for %s%s%s\n\n' "${#FILES[@]}" "$C_PATH" "$ws" "$C_RESET"; fi
|
||||
# One python pass rather than one per file, and python rather than three
|
||||
# greps and a stat because this reads the header of every epitaph in the
|
||||
# store under --all.
|
||||
#
|
||||
# Deliberately no topic column. Every Claude Code summary opens on the
|
||||
# same boilerplate, the "Primary Request" section that follows is written
|
||||
# four different ways across the summaries here, and what it opens on is
|
||||
# usually scene-setting rather than the request. Nothing extractable
|
||||
# tells the reader which session this was, and a column repeating the
|
||||
# same sentence down eight rows reads as a broken tool while leaving
|
||||
# discoverability exactly where it was. `search` is the answer to "which
|
||||
# one was it"; this is the answer to "what is here".
|
||||
#
|
||||
# Which sessions still have a transcript is the harness's answer, not
|
||||
# something to work out from a filename: the id is in the name for one
|
||||
# harness and is the directory for the next. Under --all the workspace is
|
||||
# not passed, because a store key is a slug and a slug does not resolve
|
||||
# back to a path - the plugin answers for every key it holds instead. A
|
||||
# harness that does not implement the hook returns nothing, and every row
|
||||
# then says so rather than claiming the transcript is gone.
|
||||
harness_plugin_load "$(config_get core harness)"
|
||||
live=""
|
||||
if [ "$ALL" = 1 ]; then live="$(harness_hook harness_live_sessions)"
|
||||
else live="$(harness_hook harness_live_sessions "$ws")"; fi
|
||||
C_KEY="$C_KEY" C_DIM="$C_DIM" C_OK="$C_OK" C_RESET="$C_RESET" \
|
||||
DEJIMA_LIVE_SESSIONS="$live" DEJIMA_HAS_LIVE="$(declare -F harness_live_sessions >/dev/null && echo 1)" \
|
||||
python3 - "$ALL" ${FILES[@]+"${FILES[@]}"} <<'PY'
|
||||
import os, re, sys
|
||||
|
||||
show_slug = sys.argv[1] == "1"
|
||||
# "<key>\t<session-id>" per live transcript. The distinction that matters is
|
||||
# an empty set from a harness that answered versus no answer at all: the
|
||||
# first means aged out, the second means unknown.
|
||||
known = os.environ.get("DEJIMA_HAS_LIVE") == "1"
|
||||
live = set(os.environ.get("DEJIMA_LIVE_SESSIONS", "").splitlines())
|
||||
KEY, DIM, OK, RESET = (os.environ.get(k, "") for k in ("C_KEY", "C_DIM", "C_OK", "C_RESET"))
|
||||
|
||||
# The index is padded before it is coloured: a width in the format string
|
||||
# counts the escape sequence's bytes and knocks every row out of line the
|
||||
# moment colour is on.
|
||||
ROW = "%s %-16s %-6s %6s %5s %s"
|
||||
print(DIM + ROW % ("%4s" % "#", "when", "how", "ctx", "size", "transcript") + RESET)
|
||||
|
||||
for i, path in enumerate(sys.argv[2:], 1):
|
||||
try:
|
||||
with open(path, errors="replace") as fh:
|
||||
head = [next(fh, "") for _ in range(12)]
|
||||
except OSError:
|
||||
continue
|
||||
h = {}
|
||||
for l in head:
|
||||
m = re.match(r"- (session|at|trigger|tokens): (.*)", l)
|
||||
if m:
|
||||
h[m.group(1)] = m.group(2).strip()
|
||||
sid = h.get("session", "")
|
||||
# How much context the session had accumulated by the time it compacted:
|
||||
# the one field here that separates a long session from a short one, and
|
||||
# it is recorded rather than guessed at.
|
||||
m = re.match(r"(\d+) before", h.get("tokens", ""))
|
||||
ctx = "%dk" % (int(m.group(1)) / 1000) if m else "?"
|
||||
# The store key is the directory this epitaph sits in, which is dejima's
|
||||
# own arrangement; pairing it with the session id is what makes the answer
|
||||
# unambiguous under --all, where two projects can hold the same id.
|
||||
if not known:
|
||||
state = DIM + "?" + RESET
|
||||
elif "%s\t%s" % (os.path.basename(os.path.dirname(path)), sid) in live:
|
||||
state = OK + "resumable" + RESET
|
||||
else:
|
||||
state = DIM + "aged out" + RESET
|
||||
row = ROW % (KEY + "%4d" % i + RESET,
|
||||
h.get("at", "")[:16].replace("T", " ") or "?",
|
||||
h.get("trigger", "?")[:6], ctx,
|
||||
"%dK" % ((os.path.getsize(path) + 1023) // 1024), state)
|
||||
if show_slug:
|
||||
row += " " + DIM + os.path.basename(os.path.dirname(path))[:30] + RESET
|
||||
print(row)
|
||||
PY
|
||||
printf '\n %sdejima-epitaph show <n>%s opens one; %sdejima-epitaph search <pattern>%s finds one.\n' \
|
||||
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
|
||||
# `resumable` means the transcript this was distilled from is still on
|
||||
# disk, so `claude --resume <session>` gives the real session rather than
|
||||
# a summary of it. That is the better recovery whenever it is available;
|
||||
# the epitaph is what remains once the harness has aged the transcript out.
|
||||
exit 0
|
||||
;;
|
||||
search)
|
||||
pattern="${1:-}"; [ -n "$pattern" ] || usage
|
||||
ws="$(resolve_ws "${2:-}")"
|
||||
mapfile -t FILES < <(epitaph_files "$ws")
|
||||
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
|
||||
# Case-insensitive extended regex, so a bare word works and a pattern is
|
||||
# available without a second flag to remember.
|
||||
gcolor=never; [ -n "$C_RESET" ] && gcolor=always
|
||||
i=0; hits=0
|
||||
for f in ${FILES[@]+"${FILES[@]}"}; do
|
||||
i=$((i + 1))
|
||||
grep -qiE -- "$pattern" "$f" 2>/dev/null || continue
|
||||
hits=$((hits + 1))
|
||||
# The index is this file's position in the SAME listing `list`
|
||||
# prints, so `show <n>` after a search means what the search showed.
|
||||
printf '\n%s%4d%s %s%s%s\n' "$C_KEY" "$i" "$C_RESET" \
|
||||
"$C_DIM" "$(basename "$f")" "$C_RESET"
|
||||
grep -inE --color=$gcolor -- "$pattern" "$f" 2>/dev/null \
|
||||
| head -3 | sed 's/^/ /' | cut -c1-100
|
||||
done
|
||||
if [ "$hits" -eq 0 ]; then
|
||||
printf 'dejima epitaph: no epitaph matches %s (searched %s).\n' "$pattern" "$i" >&2
|
||||
else
|
||||
printf '\n%s of %s epitaph(s) match. %sdejima-epitaph show <n>%s opens one.\n' \
|
||||
"$hits" "$i" "$C_KEY" "$C_RESET"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
show)
|
||||
sel="${1:-}"; ws_arg="${2:-}"
|
||||
# A lone argument that names a directory is the workspace, not a
|
||||
# selector: `dejima-epitaph show ~/workspace/thing` is the obvious thing
|
||||
# to type and would otherwise be read as a name to match.
|
||||
if [ -n "$sel" ] && [ -z "$ws_arg" ] && [ -d "$sel" ]; then ws_arg="$sel"; sel=""; fi
|
||||
ws="$(resolve_ws "$ws_arg")"
|
||||
mapfile -t FILES < <(epitaph_files "$ws")
|
||||
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
|
||||
file=""
|
||||
case "$sel" in
|
||||
"") file="${FILES[-1]}" ;;
|
||||
*[!0-9]*)
|
||||
for f in ${FILES[@]+"${FILES[@]}"}; do
|
||||
case "$(basename "$f")" in "$sel"*) file="$f"; break ;; esac
|
||||
done ;;
|
||||
*)
|
||||
[ "$sel" -ge 1 ] 2>/dev/null && [ "$sel" -le "${#FILES[@]}" ] \
|
||||
&& file="${FILES[$((sel - 1))]}" ;;
|
||||
esac
|
||||
[ -n "$file" ] || { echo "dejima epitaph: no epitaph '$sel' in ${#FILES[@]} kept for $ws." >&2; exit 1; }
|
||||
# Piped or redirected, print it: `show | grep` must not try to open an
|
||||
# editor, and neither must a test.
|
||||
ed="${VISUAL:-${EDITOR:-}}"
|
||||
if [ -z "$ed" ] || [ ! -t 1 ] || [ ! -t 0 ]; then
|
||||
cat "$file"
|
||||
exit 0
|
||||
fi
|
||||
# Through `sh -c` so $EDITOR may carry flags (`code -w`, `emacsclient
|
||||
# -nw`), with the path passed as an argument rather than interpolated so
|
||||
# a space in it stays one word.
|
||||
sh -c "$ed \"\$1\"" sh "$file"
|
||||
# An editor writes through a temp file and renames it into place, which
|
||||
# leaves the result at the umask's mode rather than the 0600 `save`
|
||||
# wrote. The 0700 directory means nothing else could read it either way;
|
||||
# the mode is restated because it is what this store promises, not
|
||||
# because the directory needs the help.
|
||||
chmod 600 "$file" 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Everything below is `save`. Reading the store is dejima's own business and
|
||||
# needs the plugin only to label a row (see `list` above, which tolerates its
|
||||
# absence); extracting an epitaph is entirely the plugin's, so from here a
|
||||
# missing one is fatal rather than a blank column.
|
||||
harness_plugin_load "$(config_get core harness)"
|
||||
|
||||
# With no harness selected nothing knows what a compaction looks like, so
|
||||
@@ -52,15 +365,12 @@ if [ -z "$(config_get core harness)" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ws_arg="${1:-}"
|
||||
|
||||
ws="$ws_arg"
|
||||
[ -n "$ws" ] || ws="$(pwd)"
|
||||
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
|
||||
|
||||
case "$cmd" in
|
||||
save) ;;
|
||||
*) echo "usage: dejima-epitaph save <workspace>" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# One file per summary, named for the moment it was written, so a directory
|
||||
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
|
||||
# on every exit, and a session compacted three times has three epitaphs, two
|
||||
@@ -126,6 +436,13 @@ echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
|
||||
# operators who already centralise session logs. Host-side rather than
|
||||
# through the container's log-shipper, so the same "never in the session's
|
||||
# reach" property holds for the shipping path too.
|
||||
#
|
||||
# DEJIMA_EPITAPH_NO_SHIP is bin/dejima's --no-log reaching the one decision it
|
||||
# cannot make on this tool's behalf. Honoured from the environment, which
|
||||
# nothing else here is, because it can only ever suppress: a stale export
|
||||
# keeps an epitaph off the wire and never puts one on it. The epitaph is still
|
||||
# written either way - the store is the durable copy, and this is the bonus.
|
||||
[ "${DEJIMA_EPITAPH_NO_SHIP:-0}" != 1 ] || exit 0
|
||||
[ "$(config_get epitaph loki)" = "on" ] || exit 0
|
||||
loki="$(config_get logging loki_url)"
|
||||
[ -n "$loki" ] || exit 0
|
||||
|
||||
@@ -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 harness_seed"
|
||||
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_live_sessions 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+65
-2
@@ -8,6 +8,44 @@
|
||||
# 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
|
||||
.dejima/tools optional tools its sessions should have
|
||||
|
||||
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"
|
||||
@@ -127,12 +165,15 @@ case "$action" in
|
||||
report allowed-domains valid_domain
|
||||
echo "corpus:"
|
||||
report corpus valid_corpus_rule
|
||||
echo "tools:"
|
||||
report tools valid_tool_name
|
||||
;;
|
||||
approve)
|
||||
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
|
||||
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
|
||||
pending_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')
|
||||
pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
|
||||
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
|
||||
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
|
||||
@@ -146,7 +187,8 @@ case "$action" in
|
||||
sed 's/^/ /' <<< "$rejected" >&2
|
||||
echo >&2
|
||||
fi
|
||||
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ]; then
|
||||
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ] \
|
||||
&& [ -z "$pending_tools" ]; then
|
||||
echo "Nothing pending approval for $project."
|
||||
exit 0
|
||||
fi
|
||||
@@ -154,6 +196,12 @@ case "$action" in
|
||||
[ -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
|
||||
# Named as what it is rather than as a package: approving one adds a
|
||||
# program to the image every session on this project then has on
|
||||
# PATH. dejima hardens what it ships (docs/tools.md), which is what
|
||||
# makes this a shorter conversation than a dependency - but it is
|
||||
# still a change to what the sandbox contains.
|
||||
[ -n "$pending_tools" ] && printf ' tool: %s (installed into the image)\n' $pending_tools
|
||||
echo
|
||||
printf 'Approve all of the above? [y/N] '
|
||||
read -r reply || reply=n
|
||||
@@ -196,6 +244,15 @@ case "$action" in
|
||||
# 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.
|
||||
tool_failed=""
|
||||
tool_approved=0
|
||||
for tool in $pending_tools; do
|
||||
if approve_entry "$(ensure_project_store "$project")" tools "$tool"; then
|
||||
tool_approved=1
|
||||
else
|
||||
tool_failed="$tool_failed $tool"
|
||||
fi
|
||||
done
|
||||
corpus_failed=""
|
||||
corpus_approved=0
|
||||
for rule in $pending_corpus; do
|
||||
@@ -212,6 +269,12 @@ case "$action" in
|
||||
if [ "$corpus_approved" = 1 ]; then
|
||||
echo "Approved corpus rules for $project. They take effect at the next launch."
|
||||
fi
|
||||
if [ -n "$tool_failed" ]; then
|
||||
echo "NOT approved for $project (could not record):$tool_failed" >&2
|
||||
fi
|
||||
if [ "$tool_approved" = 1 ]; then
|
||||
echo "Approved tools for $project. The image rebuilds on 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
|
||||
|
||||
@@ -236,6 +236,7 @@ _approved_validator() {
|
||||
allowed-domains) printf 'valid_domain' ;;
|
||||
dependencies) printf 'valid_dep_spec' ;;
|
||||
corpus) printf 'valid_corpus_rule' ;;
|
||||
tools) printf 'valid_tool_name' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -249,6 +250,18 @@ _approved_validator() {
|
||||
# not. There is one today.
|
||||
DEJIMA_CORPUS_RULES="commit-per-step"
|
||||
|
||||
# An optional tool a project asks for. A name dejima ships a manifest for and
|
||||
# nothing else: an entry naming a tool that does not exist would approve
|
||||
# cleanly, sit in the store and install nothing, which reads as the tool being
|
||||
# in force when it is not - the same reasoning as the corpus rules above.
|
||||
#
|
||||
# Refusing at declaration time also means the approval prompt cannot offer an
|
||||
# operator a name that would silently do nothing.
|
||||
valid_tool_name() {
|
||||
valid_provider_name "$1" || return 1
|
||||
provider_dir tool "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
valid_corpus_rule() {
|
||||
case " $DEJIMA_CORPUS_RULES " in
|
||||
*" $1 "*) return 0 ;;
|
||||
@@ -423,8 +436,22 @@ unapprove_entry() {
|
||||
#
|
||||
# -xaf rather than -xzf: compression is detected from the archive, so xz and
|
||||
# zstd releases work without the spec having to say which it is.
|
||||
# DEJIMA_TARBALL_STRIP picks between the two archive shapes. "prefix" is the
|
||||
# default and the one every dependency uses: everything under a single
|
||||
# directory, stripped so bin/ lands in the same place whatever the release
|
||||
# called its top level. "binary" is an archive that IS the executable, with no
|
||||
# enclosing directory at all - stripping a component there strips the binary
|
||||
# and extracts nothing, silently, leaving a layer that built fine and contains
|
||||
# no tool.
|
||||
#
|
||||
# A parameter rather than a second emitter, because the digest-before-unpack
|
||||
# below is the part that must not exist twice.
|
||||
_emit_tarball_layers() {
|
||||
local spec rest name sha url
|
||||
local spec rest name sha url dest strip
|
||||
case "${DEJIMA_TARBALL_STRIP:-prefix}" in
|
||||
binary) strip="" ;;
|
||||
*) strip="--strip-components=1 " ;;
|
||||
esac
|
||||
for spec in "$@"; do
|
||||
[ -n "$spec" ] || continue
|
||||
rest="${spec#tarball:}"
|
||||
@@ -436,7 +463,12 @@ _emit_tarball_layers() {
|
||||
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
|
||||
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
|
||||
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
|
||||
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
|
||||
# A bare binary goes straight into the bin/ a prefix archive would
|
||||
# have brought with it, so the PATH line below is the same either way.
|
||||
dest="/opt/dejima/$name"
|
||||
[ -n "$strip" ] || dest="$dest/bin"
|
||||
printf ' mkdir -p %s && \\\n' "$dest"
|
||||
printf ' tar -C %s %s-xaf /tmp/%s.tar && \\\n' "$dest" "$strip" "$name"
|
||||
printf ' rm /tmp/%s.tar\n' "$name"
|
||||
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
|
||||
done
|
||||
@@ -625,6 +657,23 @@ 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.
|
||||
# Whether the terminal page colour-codes its toolbar. Unset means on, which
|
||||
# the page decides for itself - so, like the tap setting above, only a value
|
||||
# the operator actually wrote is passed on.
|
||||
remote_ttyd_button_colors() {
|
||||
config_is_set remote-access ttyd_button_colors || return 0
|
||||
config_get remote-access ttyd_button_colors
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+296
-4
@@ -44,6 +44,11 @@ _provider_axis_dir() {
|
||||
forge) printf 'forges' ;;
|
||||
workflow) printf 'workflows' ;;
|
||||
harness) printf 'harnesses' ;;
|
||||
# Not an axis in the select-one sense the other three are: a launch
|
||||
# enables zero or more tools, and none is the default. It is here
|
||||
# because everything else about it is the same - a manifest that is
|
||||
# data, validated by the same schema machinery, never sourced.
|
||||
tool) printf 'tools' ;;
|
||||
*) echo "unknown provider axis: $1" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -53,6 +58,7 @@ _provider_manifest_name() {
|
||||
forge) printf 'forge.conf' ;;
|
||||
workflow) printf 'workflow.conf' ;;
|
||||
harness) printf 'harness.conf' ;;
|
||||
tool) printf 'tool.conf' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -143,14 +149,34 @@ capabilities ?list:identifier
|
||||
pr_command ?command
|
||||
issue_command ?command
|
||||
merge_block ?command
|
||||
repo_flags ?list:flag
|
||||
repo_env ?identifier
|
||||
identity_keys ?list:identifier
|
||||
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
|
||||
;;
|
||||
# An optional tool. Deliberately small: a tool declares what it is,
|
||||
# what installs it, what its environment needs and what the session
|
||||
# should be told about it - and nothing that could change what dejima
|
||||
# permits. There is no key here for a hook, a mount, a domain or a
|
||||
# guard, because a tool that needed one of those would be a change to
|
||||
# the boundary rather than a program on PATH, and would have to be
|
||||
# argued for rather than declared.
|
||||
tool) cat <<'EOF'
|
||||
description text
|
||||
install depspec
|
||||
install_layout enum:prefix|binary prefix
|
||||
image_env ?list:envpair
|
||||
command identifier
|
||||
instructions ?relpath
|
||||
EOF
|
||||
;;
|
||||
workflow) cat <<'EOF'
|
||||
@@ -270,11 +296,69 @@ _provider_valid_value() {
|
||||
# and nothing shell-active does.
|
||||
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*( [A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]
|
||||
;;
|
||||
flag)
|
||||
# A command-line option name, compared against argv by
|
||||
# docker/forge-guard.sh. The leading dash is required: without it
|
||||
# the guard would be comparing a word that could just as well be a
|
||||
# subcommand or a value.
|
||||
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
|
||||
;;
|
||||
relpath)
|
||||
# Joined to a provider directory, so no absolute paths and no
|
||||
# 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
|
||||
@@ -548,11 +632,54 @@ _config_extra_section_known() {
|
||||
case "$section" in
|
||||
forge.*) axis=forge; name="${section#forge.}" ;;
|
||||
harness.*) axis=harness; name="${section#harness.}" ;;
|
||||
# An identity is the operator's own name for a set of credentials, so
|
||||
# there is no directory to check it against - only that it is a name
|
||||
# at all. Unlike a provider, an unused one is not a misconfiguration:
|
||||
# having [identity.oss] on a machine that never selects it is the
|
||||
# normal state of a second account.
|
||||
identity.*)
|
||||
name="${section#identity.}"
|
||||
valid_provider_name "$name"
|
||||
return
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
provider_dir "$axis" "$name" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# The keys an [identity.<name>] section may carry, as "<key>\t<type>" lines.
|
||||
#
|
||||
# ssh_key and the git identity are core's, because every forge needs the same
|
||||
# two answers to "who is pushing". Everything else is declared by a forge's
|
||||
# identity_keys and typed by that forge's own keys entry, so the identity
|
||||
# inherits the validation the forge already wrote rather than a second copy of
|
||||
# it that could disagree.
|
||||
#
|
||||
# A key name declared by two forges resolves to one type here, which is why a
|
||||
# static test refuses that collision rather than this function guessing.
|
||||
_identity_key_specs() {
|
||||
printf 'ssh_key ?path
|
||||
'
|
||||
printf 'user_name ?text
|
||||
'
|
||||
printf 'user_email ?text
|
||||
'
|
||||
local forge key spec
|
||||
for forge in $(provider_list forge); do
|
||||
for key in $(manifest_get forge "$forge" identity_keys); do
|
||||
for spec in $(_provider_keys_field forge "$forge"); do
|
||||
[ "${spec%%:*}" = "$key" ] || continue
|
||||
# The forge's own type, with its default deliberately dropped:
|
||||
# a default belongs to the forge's section, and repeating it
|
||||
# here would make an identity that names no token look like one
|
||||
# that named the default path.
|
||||
printf '%s\t?%s\n' "$key" \
|
||||
"$(cut -f2 <<< "$(_provider_keyspec_parse "$spec")" | sed 's/^?//')"
|
||||
done
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
|
||||
# provider's `keys` manifest entry instead of a row hardcoded in core.
|
||||
_config_extra_spec() {
|
||||
@@ -560,6 +687,17 @@ _config_extra_spec() {
|
||||
case "$want" in
|
||||
forge.*) axis=forge ;;
|
||||
harness.*) axis=harness ;;
|
||||
identity.*)
|
||||
rest="${want#identity.}"
|
||||
key="${rest#*.}"
|
||||
[ "$key" != "$rest" ] || return 1
|
||||
while IFS=$'\t' read -r pname ptype; do
|
||||
[ "$pname" = "$key" ] || continue
|
||||
printf '%s\t' "$ptype"
|
||||
return 0
|
||||
done < <(_identity_key_specs)
|
||||
return 1
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
rest="${want#*.}"
|
||||
@@ -580,6 +718,75 @@ _config_extra_spec() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# What the selected identity says about one key, if anything.
|
||||
#
|
||||
# An identity answers for the credentials and the git name a PERSON brings to
|
||||
# a project, which is the half a project cannot declare: .dejima/ is committed
|
||||
# and shared, and a key path is per-user and per-machine. The project (or the
|
||||
# operator) names an identity; the identity supplies the paths.
|
||||
#
|
||||
# Returns 1 for a key no identity owns, which is most of them - the caller
|
||||
# then reads the config file as before. Deliberately narrow: an identity that
|
||||
# could set any key would be a second config file with different rules about
|
||||
# what may be in it.
|
||||
#
|
||||
# core.identity itself is excluded, and not merely by convention: the caller
|
||||
# that consults this is bin/dejima's config_get wrapper, so answering for the
|
||||
# key that names the identity would recurse into itself.
|
||||
# Whether the named identity exists in the config at all - any of its keys
|
||||
# being present is enough, because a section with no keys is not a section
|
||||
# the parser records.
|
||||
#
|
||||
# The check that matters: [core] identity naming a section nobody wrote is a
|
||||
# silent fall-through to the machine's defaults, which is the wrong direction
|
||||
# to be wrong in. Pushing under the wrong key fails loudly; pushing under the
|
||||
# default key when you asked for another one does not fail at all.
|
||||
identity_exists() {
|
||||
local name="$1" key
|
||||
[ -n "$name" ] || return 1
|
||||
valid_provider_name "$name" || return 1
|
||||
while IFS=$'\t' read -r key _; do
|
||||
config_is_set "identity.$name" "$key" && return 0
|
||||
done < <(_identity_key_specs)
|
||||
return 1
|
||||
}
|
||||
|
||||
identity_get() {
|
||||
local section="$1" key="$2" name value pname
|
||||
[ "$section.$key" != "core.identity" ] || return 1
|
||||
name="$(config_get core identity)" || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
case "$section" in
|
||||
core) [ "$key" = ssh_key ] || return 1 ;;
|
||||
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
|
||||
identity.*) return 1 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
value="$(config_get "identity.$name" "$key")" || return 1
|
||||
[ -n "$value" ] || return 1
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# Whether one forge declared this key as a person's rather than a project's.
|
||||
_forge_owns_identity_key() {
|
||||
local forge="$1" key="$2" k
|
||||
for k in $(manifest_get forge "$forge" identity_keys); do
|
||||
[ "$k" = "$key" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The git name and address the identity carries, for the gitconfig copy the
|
||||
# container gets. Not core config keys: the host's own ~/.gitconfig is where
|
||||
# they come from without an identity, and adding a second place to set them
|
||||
# globally would be a new way to get the same answer.
|
||||
identity_git_field() {
|
||||
local field="$1" name
|
||||
name="$(config_get core identity)" || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
config_get "identity.$name" "$field"
|
||||
}
|
||||
|
||||
# One key of the SELECTED harness, rather than of a harness named literally
|
||||
# at the call site.
|
||||
#
|
||||
@@ -655,6 +862,23 @@ provider_validate_selection() {
|
||||
# quietly absent, because an operator who has them everywhere else would
|
||||
# otherwise have to read a manifest to find out they are gone here.
|
||||
#
|
||||
# An identity that names nothing is refused rather than ignored. Falling
|
||||
# through to the machine's own credentials would mean pushing under the
|
||||
# wrong account with nothing saying so, which is the failure this feature
|
||||
# exists to remove rather than one it may introduce.
|
||||
# Guarded on the config existing rather than swallowing config_get's
|
||||
# stderr: with no file at all it says so, and a selection validated
|
||||
# without one is a legitimate case this must stay silent for.
|
||||
local _identity=""
|
||||
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
|
||||
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
|
||||
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
|
||||
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
|
||||
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
|
||||
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# A name outside this set is a hard error rather than an unknown guard
|
||||
# treated as absent: a typo would otherwise silently turn a guard off,
|
||||
# which is the failure this whole declaration exists to prevent.
|
||||
@@ -899,6 +1123,10 @@ What this means:
|
||||
briefing.{{writable_mounts}} Everything else is disposable: installed system
|
||||
packages, anything outside the mounted paths, and the container itself go
|
||||
away when this run ends.
|
||||
- A headless browser is installed: playwright, with chromium, importable
|
||||
from any directory in either module system. If you change something with a
|
||||
user interface, look at it rather than describing what it should look like,
|
||||
and leave the screenshot in ~/shared.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, {{network_allowlist}}, plus anything
|
||||
approved via dejima-allow). Requests to anything else will simply
|
||||
@@ -911,9 +1139,9 @@ What this means:
|
||||
it appears to. If a task needs one more domain and nothing else, that's
|
||||
a quick fix, not a reason to stop. Tell the user to run, from a terminal
|
||||
outside the sandbox, whichever of these matches why the domain is needed:
|
||||
bin/dejima-allow --container {{container_id}} <domain> # just this session
|
||||
bin/dejima-allow --project <path> <domain> # this project, always
|
||||
bin/dejima-allow --global <domain> # every project
|
||||
dejima-allow --container {{container_id}} <domain> # just this session
|
||||
dejima-allow --project <path> <domain> # this project, always
|
||||
dejima-allow --global <domain> # every project
|
||||
Pick deliberately rather than asking for all three: a domain this project
|
||||
needs is --project, not --global - a global grant widens every other
|
||||
project's firewall too, which is rarely what one project's dependency
|
||||
@@ -1281,6 +1509,61 @@ _briefing_substitute() {
|
||||
# the resume command) - in that reading order. Every {{key}} is substituted
|
||||
# except {{session_id}}/{{container_id}}, deferred as @@SESSION_ID@@ and
|
||||
# @@CONTAINER_ID@@ for the hook that mounts this at session start.
|
||||
# A tool's manifest, plus the one rule its schema cannot express.
|
||||
#
|
||||
# image_env becomes a Dockerfile ENV, which every process in the container
|
||||
# inherits - not just the tool that declared it. A harness may set a bare name
|
||||
# there because a harness IS the container's main process; a tool is one
|
||||
# program among many and may not.
|
||||
#
|
||||
# This is not hypothetical. rtk shipped with NO_COLOR=1, reasoning that its
|
||||
# output lands in a model's context where an escape sequence is noise. The
|
||||
# reasoning was fine, the scope was wrong, and a launch with that tool enabled
|
||||
# lost colour in the harness, git, ls and everything else. The rule that would
|
||||
# have caught it is that a tool's variables must be namespaced to the tool.
|
||||
#
|
||||
# Uppercased, with anything that is not alphanumeric folded to an underscore,
|
||||
# so a tool named `my-tool` claims `MY_TOOL_*`.
|
||||
tool_validate() {
|
||||
local name="$1" prefix pair varname
|
||||
manifest_validate tool "$name" || return 1
|
||||
prefix="$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]' | tr -c '[:alnum:]' '_')_"
|
||||
for pair in $(manifest_get tool "$name" image_env); do
|
||||
varname="${pair%%=*}"
|
||||
case "$varname" in
|
||||
"$prefix"*) continue ;;
|
||||
esac
|
||||
echo "tool '$name' declares image_env $varname, which is not its own." >&2
|
||||
echo "A tool's image_env reaches every process in the container, so a bare" >&2
|
||||
echo "name like NO_COLOR or DO_NOT_TRACK changes the harness and everything" >&2
|
||||
echo "else too. Name it ${prefix}something, or do not set it." >&2
|
||||
return 1
|
||||
done
|
||||
}
|
||||
|
||||
# What the session is told about the optional tools this launch enabled.
|
||||
#
|
||||
# The heading is core's and the body is each tool's own instructions.md, so a
|
||||
# tool describes itself once and every harness gets the same words. Nothing
|
||||
# here can change what the session may do - a tool's manifest has no key for
|
||||
# that - which is what makes rendering a project-selectable fragment into the
|
||||
# briefing safe at all.
|
||||
_briefing_core_tools() {
|
||||
local tools="$1" tool frag dir out=""
|
||||
out="Extra tools this session has, beyond the usual shell:"
|
||||
for tool in $tools; do
|
||||
dir="$(provider_dir tool "$tool")" || continue
|
||||
frag="$(manifest_get tool "$tool" instructions)" || frag=""
|
||||
out+=$'\n\n'
|
||||
out+="- **$tool** - $(manifest_get tool "$tool" description)"
|
||||
if [ -n "$frag" ] && [ -f "$dir/$frag" ]; then
|
||||
out+=$'\n\n'
|
||||
out+="$(sed 's/^/ /' "$dir/$frag")"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
briefing_compose() {
|
||||
local forge="$1" workflow="$2" harness="$3"
|
||||
local caps autonomy workflow_frag harness_frag text
|
||||
@@ -1310,6 +1593,15 @@ briefing_compose() {
|
||||
text+="$(_briefing_core_corpus)"
|
||||
text+=$'\n\n'
|
||||
fi
|
||||
# Optional tools, from DEJIMA_TOOLS. A tool the session is not told about
|
||||
# is one it will not use, so the manifest's instructions fragment is the
|
||||
# whole of the wiring for a harness: dejima renders it rather than letting
|
||||
# the tool's own installer patch a config, which is the difference
|
||||
# docs/tools.md draws between a hardened tool and a bare install.
|
||||
if [ -n "${DEJIMA_TOOLS:-}" ]; then
|
||||
text+="$(_briefing_core_tools "${DEJIMA_TOOLS}")"
|
||||
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
|
||||
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/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.
|
||||
#
|
||||
# It then has to make the harness redraw, over whatever the shell printed while
|
||||
# the session was away. That used to be `kill -WINCH`, on the reasoning that a
|
||||
# resize is what makes a full-screen program repaint. The reasoning was right
|
||||
# and the signal was not enough: a program that answers SIGWINCH by re-reading
|
||||
# the size, seeing the same numbers and doing nothing gets a poke that costs it
|
||||
# nothing to ignore.
|
||||
#
|
||||
# Measured against the real programs rather than a stand-in, on a pty, counting
|
||||
# the bytes each wrote in a fixed window after each kind of poke:
|
||||
#
|
||||
# idle same-size SIGWINCH size changed and back
|
||||
# Claude Code 0 0 3337
|
||||
# Cline 0 0 17810
|
||||
# nvim (control) 0 3356 6724
|
||||
#
|
||||
# Both shipped harnesses ignore the signal outright. So the size is changed
|
||||
# and changed straight back, which is a genuine resize twice over: TIOCSWINSZ
|
||||
# raises SIGWINCH on the foreground process group by itself, the container
|
||||
# client answers a resize by resizing the container's TTY, and the harness
|
||||
# inside sees dimensions that actually moved.
|
||||
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
|
||||
|
||||
# One row shorter, held, and back. Only the kernel's idea of the size
|
||||
# changes - the terminal emulator is not told and does not need to be,
|
||||
# because what a program reads is the kernel's value. It ends where it
|
||||
# started, so a failure partway leaves the size wrong rather than the
|
||||
# session unusable, which is why the restore is unconditional.
|
||||
#
|
||||
# The hold is the whole of it, and the first version of this did not have
|
||||
# one. Two ioctls back to back are not a resize a program can see: the
|
||||
# signal arrives, the handler reads the size as it is NOW, and by then it
|
||||
# is back where it started. Measured on a pty, bytes written after the
|
||||
# poke:
|
||||
#
|
||||
# no hold 0.1s 0.2s
|
||||
# Cline 0 0 17810
|
||||
# Claude Code 0 3328 3328
|
||||
#
|
||||
# Cline sets the floor at something between 0.1 and 0.2 seconds. This uses
|
||||
# half a second, which is 2.5x that - headroom for the hop this could not
|
||||
# measure, since a real session's resize has to travel from the host tty
|
||||
# through the container client and the daemon to the container's own tty
|
||||
# before the harness sees it at all.
|
||||
#
|
||||
# The cost is that the terminal is one row short for half a second during
|
||||
# a resume, while the harness is redrawing anyway.
|
||||
local size rows
|
||||
size="$(stty size < /dev/tty 2>/dev/null)" || size=""
|
||||
rows="${size%% *}"
|
||||
case "$rows" in
|
||||
''|*[!0-9]*) rows="" ;;
|
||||
*) [ "$rows" -gt 1 ] || rows="" ;;
|
||||
esac
|
||||
if [ -n "$rows" ]; then
|
||||
stty rows "$((rows - 1))" < /dev/tty 2>/dev/null || true
|
||||
sleep 0.5
|
||||
stty rows "$rows" < /dev/tty 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kept as well as the resize, and not redundant: nvim is the measured
|
||||
# example of a program that repaints on the bare signal, and a session
|
||||
# running one under `--cmd` should not be worse off than a harness. 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
|
||||
@@ -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,16 @@ 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)"
|
||||
|
||||
# Whether the toolbar is colour-coded. Passed on only when set, for the same
|
||||
# reason as the line above: the page has a default and it is the right one.
|
||||
button_colors="$(remote_ttyd_button_colors)"
|
||||
|
||||
# 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 +173,15 @@ 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")
|
||||
colors_opt=()
|
||||
[ -z "$button_colors" ] || colors_opt=(-t "buttonColors=$button_colors")
|
||||
|
||||
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[@]}"} \
|
||||
${colors_opt[@]+"${colors_opt[@]}"} \
|
||||
"$SCRIPT_DIR/dejima-attach"
|
||||
|
||||
@@ -444,6 +444,36 @@ harness_transcript_files() {
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
}
|
||||
|
||||
# Which sessions still have a transcript on disk, as "<key>\t<session-id>"
|
||||
# lines. `dejima-epitaph list` matches an epitaph's own session id against
|
||||
# this to say whether resuming the real session is still on offer, and it has
|
||||
# no way to work that out for itself: the id is in the filename here and is
|
||||
# the directory name for the next harness.
|
||||
#
|
||||
# $1 is a workspace, or absent for every key this harness holds transcripts
|
||||
# under - which is what `list --all` needs, since a store key is a slug and a
|
||||
# slug does not resolve back to a path.
|
||||
harness_live_sessions() {
|
||||
local ws="${1:-}" projects key f d
|
||||
projects="$(harness_config_get projects_dir)"
|
||||
[ -n "$projects" ] || return 0
|
||||
if [ -n "$ws" ]; then
|
||||
while IFS=$'\t' read -r key f; do
|
||||
[ -n "$f" ] || continue
|
||||
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
|
||||
done < <(harness_transcript_files "$ws")
|
||||
return 0
|
||||
fi
|
||||
for d in "$projects"/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
key="$(basename "$d")"
|
||||
for f in "$d"/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Compaction summaries out of this harness's transcripts.
|
||||
#
|
||||
# Claude Code writes line-delimited JSON, one record per line, with the
|
||||
|
||||
+11
-7
@@ -167,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
|
||||
@@ -216,7 +218,7 @@ harness_prepare_config() {
|
||||
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 " 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
|
||||
@@ -246,22 +248,24 @@ harness_prepare_config() {
|
||||
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 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"},
|
||||
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 t "$now" --arg c "$ctx" \
|
||||
'{version: 1, lastUsedProvider: "anthropic", modes: {},
|
||||
providers: {anthropic: {
|
||||
settings: {provider: "anthropic", model: $m,
|
||||
apiKey: "sandbox-credential-proxy-placeholder"},
|
||||
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"
|
||||
|
||||
@@ -301,6 +301,17 @@ COPY docker/install-forge-bins.sh /tmp/install-forge-bins.sh
|
||||
RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr/local/bin \
|
||||
&& rm -rf /tmp/forges /tmp/install-forge-bins.sh
|
||||
|
||||
# The other half of NODE_PATH, which only ever applied to require(): ESM
|
||||
# resolution ignores it entirely and walks up from the importing file looking
|
||||
# for node_modules, so `import { chromium } from "playwright"` fails from
|
||||
# every directory a script is likely to be written in while require() of the
|
||||
# same package succeeds. At the root, that walk always ends here.
|
||||
#
|
||||
# It is worth a line because of how the failure reads. An agent writing the
|
||||
# modern form gets ERR_MODULE_NOT_FOUND, concludes the image has no browser,
|
||||
# and describes what a page should look like instead of going and seeing.
|
||||
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
|
||||
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
|
||||
|
||||
@@ -42,9 +42,50 @@ services:
|
||||
# ten per run - becomes a permanent zombie, and a long session eventually
|
||||
# exhausts the cgroup pids limit and can no longer fork at all.
|
||||
init: true
|
||||
# An explicit floor rather than Docker's default set plus two. The default
|
||||
# carries about fourteen capabilities and this container uses seven: it
|
||||
# writes and chowns files, runs the firewall, drops privilege twice (gosu
|
||||
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
|
||||
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
|
||||
# NET_BIND_SERVICE are used by nothing here - the last one because the
|
||||
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
|
||||
#
|
||||
# KILL is the one worth explaining, because it is not obvious from reading
|
||||
# entrypoint.sh and was missing from the first version of this list. It is
|
||||
# not for anything the entrypoint does. `dejima-allow --container` adds a
|
||||
# domain to a RUNNING session by appending a server= line and sending
|
||||
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
|
||||
# own user (`user=dnsmasq`), so root in this container cannot signal it
|
||||
# without CAP_KILL. Without this line, adding a domain mid-session fails
|
||||
# with "Operation not permitted", which is a documented feature every
|
||||
# session briefing tells the agent to ask for.
|
||||
#
|
||||
# The general lesson, since the next capability will be the same shape:
|
||||
# what the container needs is not only what its entrypoint does. Things
|
||||
# reach into it later.
|
||||
#
|
||||
# This narrows the ROOT phase. The session user's capabilities were already
|
||||
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
|
||||
# reading CapEff of the running process. What this adds is that the set was
|
||||
# never there to be regained.
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETUID
|
||||
- SETGID
|
||||
- KILL
|
||||
# No path back up. gosu drops privilege rather than gaining it, so this
|
||||
# does not interfere with it - what it forbids is a setuid binary or a file
|
||||
# capability raising privileges on exec, which is the shape any escalation
|
||||
# from the session user would have to take. The gosu claim becomes
|
||||
# structural instead of only observed.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# The credential-proxy TLS terminator binds :443 as the unprivileged
|
||||
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
|
||||
# pinning it makes the dependency explicit: a future default change then
|
||||
|
||||
+38
-20
@@ -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,
|
||||
|
||||
+280
-36
@@ -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 three rules hold whatever ran it: the
|
||||
# one subcommand a workflow blocks is refused, a command aimed at another
|
||||
# repository 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,14 @@
|
||||
# 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_REPO the "<owner>/<name>" of the repository the session
|
||||
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
|
||||
# DEJIMA_FORGE_REPO_ENV the spellings this forge's CLI
|
||||
# accepts for naming a different one.
|
||||
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
|
||||
set -u
|
||||
|
||||
@@ -26,37 +41,266 @@ 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:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ]; }; 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 repository the session was launched against ------------------------
|
||||
#
|
||||
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
|
||||
# repo_env). The gitea CLIs declare neither, because they resolve the repo
|
||||
# from origin and refuse another host; `gh` declares both, so on that path the
|
||||
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||
# file an issue on a repository the session has nothing to do with (#187).
|
||||
#
|
||||
# Fast-fail, not a boundary, for the same reason the merge block is not one:
|
||||
# the session holds the token and could call the API directly. What bounds
|
||||
# this is the scope of push_token_file.
|
||||
#
|
||||
# Refused rather than skipped when the launch resolved no repository at all: a
|
||||
# workspace with no origin has nothing for --repo to legitimately name, and
|
||||
# "cannot confirm" is not a reason to let it through.
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
refuse_repo() {
|
||||
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
|
||||
exit 1
|
||||
}
|
||||
# Case-insensitively: forges treat an owner and a repository name as
|
||||
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||
# would be a false one.
|
||||
same_repo() {
|
||||
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||
}
|
||||
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
|
||||
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||
args_left=("$@")
|
||||
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||
arg="${args_left[0]}"
|
||||
args_left=("${args_left[@]:1}")
|
||||
for flag in "${repo_flags[@]}"; do
|
||||
value=""
|
||||
case "$arg" in
|
||||
"$flag")
|
||||
# The value is the next argument, and its absence is
|
||||
# the CLI's error to report rather than this one's.
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# A one-dash flag may carry its value with no
|
||||
# separator - `-Rowner/name` - which two-dash flags do
|
||||
# not do.
|
||||
case "$flag" in
|
||||
--*) continue ;;
|
||||
-?) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case "$arg" in "$flag"?*) value="${arg#"$flag"}" ;; *) continue ;; esac
|
||||
;;
|
||||
esac
|
||||
[ -n "$value" ] || continue
|
||||
same_repo "$value" || refuse_repo "$flag" "$value"
|
||||
done
|
||||
done
|
||||
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[@]}"
|
||||
|
||||
+46
-1
@@ -49,14 +49,59 @@ ipset destroy github-domains 2>/dev/null || true
|
||||
# level, e.g. no ip6_tables module) - once it's confirmed to work at all, a
|
||||
# failure partway through must fail the whole script (set -e, no more
|
||||
# blanket `|| true`) rather than silently leaving IPv6 wide open.
|
||||
#
|
||||
# The `else` below used to log that the family was presumably off at the
|
||||
# kernel level, and continue. That inferred a fact about the WORLD from a fact
|
||||
# about a TOOL, and the two are not the same condition: a container whose kernel
|
||||
# lacks ip6_tables, on a host with IPv6 enabled, took that branch, logged a
|
||||
# reassuring line, and ran with IPv6 live and unfiltered. Nothing failed,
|
||||
# because the only thing that had happened was a check that could not tell.
|
||||
#
|
||||
# So the branch asks the kernel instead. /proc/net/if_inet6 lists every IPv6
|
||||
# address on every interface, and it is the netns's own view - present with
|
||||
# `::1` alone when the stack is up and nothing is reachable, absent entirely
|
||||
# when IPv6 is compiled out.
|
||||
#
|
||||
# Loopback does not count. Refusing to start over `::1` would refuse every
|
||||
# container that has no IPv6 connectivity at all, which is a false refusal
|
||||
# for the common case. What matters is an address on an interface that leaves
|
||||
# the box.
|
||||
_ipv6_off_box() {
|
||||
[ -e /proc/net/if_inet6 ] || return 1
|
||||
# address ifindex prefixlen scope flags devname
|
||||
awk '$6 != "lo" { found = 1 } END { exit !found }' /proc/net/if_inet6
|
||||
}
|
||||
|
||||
if ip6tables -L >/dev/null 2>&1; then
|
||||
ip6tables -F
|
||||
ip6tables -X
|
||||
ip6tables -P INPUT DROP
|
||||
ip6tables -P OUTPUT DROP
|
||||
ip6tables -P FORWARD DROP
|
||||
elif _ipv6_off_box; then
|
||||
echo "ip6tables is unusable here, and IPv6 is live on an interface that" >&2
|
||||
echo "leaves this container. Turning the family off in this network" >&2
|
||||
echo "namespace, since it cannot be filtered." >&2
|
||||
# Written to /proc directly rather than through sysctl(8), which is one
|
||||
# more thing that has to be in the image for the firewall to hold. Per
|
||||
# interface as well as `all`, because which of those flushes existing
|
||||
# addresses has varied across kernels.
|
||||
for _conf in /proc/sys/net/ipv6/conf/*/disable_ipv6; do
|
||||
[ -w "$_conf" ] || continue
|
||||
echo 1 > "$_conf" 2>/dev/null || true
|
||||
done
|
||||
if _ipv6_off_box; then
|
||||
echo "" >&2
|
||||
echo "IPv6 is still up and there is no way to filter it from here, so" >&2
|
||||
echo "every rule below would be bypassable by an AAAA-resolved address." >&2
|
||||
echo "Refusing to start rather than reporting a boundary that is not" >&2
|
||||
echo "there. Either give the container a usable ip6tables (the" >&2
|
||||
echo "ip6_tables module on the host), or disable IPv6 for its network." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "IPv6 is off in this namespace."
|
||||
else
|
||||
echo "ip6tables unusable - assuming IPv6 is unavailable at the kernel level"
|
||||
echo "ip6tables unusable, and no IPv6 address outside loopback - nothing to filter"
|
||||
fi
|
||||
|
||||
# Reinsert just the rules captured above - nothing else Docker had set up in
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
+133
-11
@@ -16,12 +16,18 @@ 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. 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] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, 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` |
|
||||
| `[core] identity` | unset | which `[identity.<name>]` section supplies this project's credentials and git name; also settable per launch with `--identity`. A name with no section is refused rather than falling back |
|
||||
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
|
||||
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
|
||||
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
|
||||
@@ -29,6 +35,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. Also settable per launch with `--model` |
|
||||
| `[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. Also settable per launch with `--base-url` |
|
||||
| `[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. Also settable per launch with `--context-window` |
|
||||
| `[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) |
|
||||
@@ -39,20 +49,24 @@ what a given launch will actually use.
|
||||
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
|
||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
|
||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
|
||||
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
|
||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
|
||||
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
|
||||
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
||||
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
|
||||
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
|
||||
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md. `--no-conversation` turns it off for one launch, and `--no-log` implies it |
|
||||
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
||||
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
||||
| `[audit] keep` | `10` | how many past findings/baselines to retain |
|
||||
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
|
||||
| `[audit] diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
|
||||
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
|
||||
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
|
||||
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
|
||||
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
|
||||
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
|
||||
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
|
||||
| `[remote-access] ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
|
||||
| `[remote-access] ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
|
||||
| `[remote-access] ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
|
||||
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
|
||||
|
||||
**No forge is the default**: `[core] forge = none` with
|
||||
@@ -377,12 +391,23 @@ 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, Cline has no
|
||||
context budget for it either, and will keep growing the conversation until the
|
||||
server rejects the request rather than compacting. dejima does not set one:
|
||||
`contextWindow` belongs to the model you are serving, and a wrong value is
|
||||
worse than none. If you hit `exceeds the available context size`, that is what
|
||||
it is.
|
||||
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
|
||||
@@ -399,6 +424,55 @@ editing the base `Dockerfile`: a dependency added that way is declared,
|
||||
approved and layered like any other, and it survives a rebuild. Editing the
|
||||
Dockerfile directly puts it outside that record.
|
||||
|
||||
## Identities
|
||||
|
||||
One machine, several accounts. A `[identity.<name>]` section groups the things
|
||||
that belong to a **person** rather than to a project - the SSH key, the forge
|
||||
token, the name on the commits - so that selecting an account is one word
|
||||
rather than four edits.
|
||||
|
||||
```ini
|
||||
[identity.work]
|
||||
ssh_key = ~/.ssh/id_work
|
||||
user_name = A Name
|
||||
user_email = name@work.example
|
||||
token_file = ~/.config/gitea/work-token
|
||||
|
||||
[identity.oss]
|
||||
ssh_key = ~/.ssh/id_oss
|
||||
user_email = name@personal.example
|
||||
push_token_file = ~/.config/gh/oss-push
|
||||
```
|
||||
|
||||
Select one with `[core] identity = work`, or per launch with
|
||||
`--identity oss`.
|
||||
|
||||
**What an identity may set** is deliberately narrow: `ssh_key`, `user_name`,
|
||||
`user_email`, and whichever keys the forges declare as a person's. A forge
|
||||
says which of its own keys those are (`identity_keys` in its manifest), so
|
||||
`token_file` belongs to an identity on the gitea path and `push_token_file`
|
||||
does on the GitHub one, and neither is a list core keeps its own copy of.
|
||||
|
||||
**A key an identity does not set falls through to the file.** The `oss`
|
||||
identity above names no `token_file`, so `[forge.gitea] token_file` answers
|
||||
for it. An identity is an override for what it names, not a replacement for
|
||||
the section.
|
||||
|
||||
**A name with no section is refused.** `[core] identity = wrok` does not
|
||||
quietly use this machine's own credentials - it stops the launch and says so.
|
||||
Pushing under the wrong key fails loudly; pushing under your default key when
|
||||
you asked for another one does not fail at all, which is the direction worth
|
||||
being careful about.
|
||||
|
||||
**`user_name` and `user_email` reach the container's git config**, appended to
|
||||
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
|
||||
touched, which was already true and is what the copy exists for.
|
||||
|
||||
This is the half of per-project configuration that a project cannot carry: a
|
||||
project's `.dejima/` is committed and shared with everyone working on it, and
|
||||
a key path is yours and your machine's. A project can say which identity it
|
||||
wants; only you can say where that identity's key lives.
|
||||
|
||||
## Headless git push without agent forwarding
|
||||
|
||||
**The reason to do this is identity, not uptime.** A forwarded agent
|
||||
@@ -562,6 +636,54 @@ means anything you left in it is readable by every session after the one you
|
||||
left it for. A credential dropped in there to get one job done, and then
|
||||
forgotten, is a credential you have handed to every session since.
|
||||
|
||||
## Reading what a session left behind
|
||||
|
||||
When a session compacts, the summary it writes is the only durable account of
|
||||
what a long session worked out - and it goes with the container. dejima keeps
|
||||
a copy host-side, in `[epitaph] dir`, from `bin/dejima`'s exit trap. That
|
||||
directory is never mounted into a container.
|
||||
|
||||
Three commands read it, all defaulting to the repository you are standing in:
|
||||
|
||||
```sh
|
||||
dejima-epitaph list # what is kept, oldest first
|
||||
dejima-epitaph search "dns proxy" # which one was it
|
||||
dejima-epitaph show 12 # open it in $VISUAL or $EDITOR
|
||||
```
|
||||
|
||||
`show` takes a position from the listing that `list` and `search` print, or
|
||||
an epitaph's filename, and defaults to the newest. It opens an editor when
|
||||
there is a terminal to open one on and writes to stdout when there is not, so
|
||||
`dejima-epitaph show | grep` behaves. `--all` widens `list` and `search` to
|
||||
every project on the machine, which is also how to see everything the store
|
||||
holds.
|
||||
|
||||
**`list` says whether each session is still resumable.** Where it is, the
|
||||
transcript the summary came from is still on disk, and `claude --resume
|
||||
<session>` gives you the session itself rather than a summary of it. The
|
||||
epitaph is what remains after the harness ages that transcript out.
|
||||
|
||||
**Nothing here hands an epitaph back to a session, and that is deliberate.**
|
||||
A session can write its own transcript, so it can author what its own epitaph
|
||||
says. Delivering that to a later session automatically would plant text in
|
||||
its premises as established background, which is the channel the
|
||||
never-mounted store exists to close. Reading one and deciding what to do with
|
||||
it is the operator's, which is why `show` opens an editor rather than
|
||||
injecting anything: the header it opens on records that this preserves what
|
||||
was written rather than vouching for it.
|
||||
|
||||
**Kept until you delete them.** There is no rotation. Epitaphs are an
|
||||
archive, and a count-based cap would delete oldest-first - the entries most
|
||||
likely to hold reasoning nobody remembers. They are small (tens of KB each,
|
||||
against transcripts a hundred times that), so the store is a housekeeping
|
||||
question rather than a disk one: `[epitaph] dir` is a plain directory of
|
||||
plain markdown, and `rm` is the current answer.
|
||||
|
||||
Setting `[epitaph] enabled = off` stops new ones being kept. It does not hide
|
||||
what is already there: `list`, `search` and `show` keep working, because
|
||||
turning the feature off is the point at which wanting to review and clear the
|
||||
store is most likely.
|
||||
|
||||
## Testing frontend changes
|
||||
|
||||
Playwright (Chromium) is pre-installed at image build time, browser
|
||||
|
||||
+6
-1
@@ -32,12 +32,15 @@ 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) |
|
||||
| `tools/` | Optional tools a launch can add to its image - not an axis, since a launch enables zero or more. Same manifest rules, and a bar for what may be added: see [docs/tools.md](tools.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) |
|
||||
| `forges/github/bin/` | The github forge's one CLI: `github-pr comments`, which shows unresolved review threads. `gh` covers everything else, but thread resolution lives only in GitHub's GraphQL API, so `gh pr view --comments` cannot tell a closed thread from an open one |
|
||||
| `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 |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
||||
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
|
||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
|
||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
|
||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
|
||||
@@ -47,6 +50,7 @@ run them.
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
||||
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
|
||||
| `docs/research-review.md` | When to check dejima's reasoning against the field's, the standing questions that are the agenda, and the queue of bypasses not yet run against this implementation |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point: it resolves the project path,
|
||||
@@ -494,6 +498,7 @@ harness-specific path resolving to nothing is the honest answer.
|
||||
| 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` |
|
||||
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). In the plugin | `bin/harness/claude-code.sh` |
|
||||
| Transcript discovery | where this workspace's transcripts are, what they are called and how they are nested. `harness_transcript_files` in the plugin, answering one `<key>\t<path>` line per file; core takes the key as the store it files under and refuses one that is not a slug this workspace owns. Both `dejima-epitaph` and `dejima-conversation-ship` used to carry Claude Code's layout directly | `bin/harness/<name>.sh`, `bin/dejima-epitaph`, `bin/dejima-conversation-ship` |
|
||||
| Live sessions | which sessions still have a transcript, as `<key>\t<session-id>` lines. `harness_live_sessions` in the plugin, because the id is in the filename for one harness and is the directory name for the next; `dejima-epitaph list` matches an epitaph's own session id against the answer to say whether resuming the real session is still on offer. Optional: a plugin that does not implement it leaves the column unknown rather than claiming a transcript is gone | `bin/harness/<name>.sh`, `bin/dejima-epitaph` |
|
||||
| Transcript update model | whether a transcript is appended to or replaced wholesale. The manifest's `transcript_update`; the shipper implements both and neither is guessed | `harnesses/<name>/harness.conf` |
|
||||
| Transcript format | the transform from transcript bytes to log lines. In the plugin; the discovery, shipping, batching and Loki labels stay core's | `bin/harness/<name>.sh` |
|
||||
| Compaction format | what a compaction looks like in a transcript, given one file at a time. In the plugin; the store, which keys are legitimate, its modes and the provenance note stay core's | `bin/harness/<name>.sh` |
|
||||
|
||||
+125
-2
@@ -122,14 +122,18 @@ 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. |
|
||||
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling (`--repo x`, `--repo=x`, and a one-dash flag with its value attached). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
|
||||
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` belong to a **person** rather than to the project - the credential paths. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
|
||||
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
|
||||
| `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`:
|
||||
@@ -256,6 +260,26 @@ resuming the session on the host is `claude --resume <session id>`, with
|
||||
`{{session_id}}` filled in at composition time; and its briefing fragment is
|
||||
`briefing.md`.
|
||||
|
||||
## Tool
|
||||
|
||||
Not an axis: a launch selects one forge, one workflow and one harness, and
|
||||
enables zero or more tools. Everything else about it is the same - a manifest
|
||||
that is data, validated by the same schema machinery, never sourced.
|
||||
|
||||
The keys are deliberately few. A tool declares what it is, what installs it,
|
||||
what its environment needs and what the session should be told about it. There
|
||||
is no key for a hook, a mount, a domain or a guard, because a tool that needed
|
||||
one of those would be a change to the boundary rather than a program on PATH.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `description` | `text` | *(required)* | One line, shown when the layer builds and in the session briefing. |
|
||||
| `install` | `depspec` | *(required)* | The same grammar a dependency uses. In practice a `tarball:` spec, because a tool has to be pinned by digest - see [docs/tools.md](tools.md) for why that is a rule rather than a preference. |
|
||||
| `install_layout` | `enum:prefix\|binary` | `prefix` | The archive's shape. `prefix` is one enclosing directory, stripped so `bin/` lands in the same place whatever the release called its top level. `binary` is an archive that IS the executable: stripping a component there would strip the binary and extract nothing, silently. |
|
||||
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs the layer sets. **Every name must be namespaced to the tool** - `RTK_*` for `rtk` - and a launch refuses one that is not. This becomes a Dockerfile `ENV`, which every process in the container inherits, so a bare `NO_COLOR` or `DO_NOT_TRACK` changes the harness and everything else too. A harness manifest may use bare names there because a harness is the container's main process; a tool is one program among many. |
|
||||
| `command` | `identifier` | *(required)* | What it puts on PATH. |
|
||||
| `instructions` | `?relpath` | (empty) | A markdown fragment appended to the session briefing under a core-owned heading, so a tool describes itself once and every harness gets the same words. This is the whole of the per-harness wiring: dejima renders it rather than letting the tool's installer patch a harness config. |
|
||||
|
||||
## Session briefing composition
|
||||
|
||||
The text a `SessionStart` hook injects into the agent is assembled
|
||||
@@ -345,6 +369,12 @@ itself:
|
||||
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
|
||||
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
|
||||
workflow's `block_merge = on`; otherwise empty.
|
||||
- `DEJIMA_FORGE_REPO_FLAGS` and `DEJIMA_FORGE_REPO_ENV` - the forge's
|
||||
`repo_flags` and `repo_env`, with `DEJIMA_FORGE_REPO` holding the
|
||||
`<owner>/<name>` of the workspace's `origin` remote. Empty when the forge
|
||||
declares neither, and also when the workspace has no origin to read - in
|
||||
which case a command naming any repository is refused, since there is
|
||||
nothing it could legitimately be naming.
|
||||
- Whatever the forge's own `env_export` names (e.g. `GITEA_API`,
|
||||
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
|
||||
|
||||
@@ -412,6 +442,84 @@ 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 differences from the gitea path remain, both now closed in code and worth
|
||||
knowing about because they are what a second forge costs:
|
||||
|
||||
- **Review feedback needs its own tool.** `gh pr view --comments` prints
|
||||
reviews and comments with no resolution state anywhere in the output,
|
||||
because the REST API it uses does not carry any - `isResolved` exists only
|
||||
on `reviewThreads` in GraphQL. So this forge ships `github-pr comments`,
|
||||
which does that one query and otherwise matches `gitea-pr comments`:
|
||||
unresolved threads by default, `--all` to include the resolved ones marked
|
||||
as such, and a count of what was hidden. Everything else `gitea-pr` does has
|
||||
a `gh` subcommand already.
|
||||
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
|
||||
gitea CLIs refuse an origin that is not on the configured host, so they can
|
||||
only act on the repo the sandbox was launched against. `gh` takes `--repo`
|
||||
on nearly every subcommand and reads `GH_REPO`, so this forge declares both
|
||||
(`repo_flags`, `repo_env`) and `docker/forge-guard.sh` refuses a value that
|
||||
is not the launched-against repository. Fast-fail, not a boundary, exactly
|
||||
as `merge_block` is: the session holds the token and could call the API
|
||||
directly. What actually bounds it is the token's scope, so a per-repository
|
||||
fine-grained PAT is the expected shape of `push_token_file`.
|
||||
|
||||
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
|
||||
@@ -423,6 +531,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)
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
|
||||
## What is actually required, at this size
|
||||
|
||||
This project has one maintainer and few users, and a release gate nobody has
|
||||
the bandwidth to complete is a gate that gets skipped rather than met. So the
|
||||
bar is deliberately two things:
|
||||
|
||||
1. **An agentic security review of the whole diff**, against the invariants in
|
||||
section 2. This is the gate. It is cheap, it is repeatable, and it is the
|
||||
one thing that catches the class of mistake this project cannot afford.
|
||||
2. **The maintainer runs the build.** Normal usage for a while, and a launch on
|
||||
the other configured harness to confirm it starts. That is the smoke test,
|
||||
and it is stated as what it is rather than dressed up as coverage.
|
||||
|
||||
Everything in section 4 is what a mature version of this project would verify
|
||||
and this one does not. It is printed by `release-pass.sh` and kept in this
|
||||
document because knowing what is unverified is worth more than pretending
|
||||
otherwise - not because a release is blocked on it. When there are more users
|
||||
than one person can apologise to, that changes.
|
||||
|
||||
## 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, which is mostly not done
|
||||
|
||||
`test/release-pass.sh` prints the list for the paths this release touches. It
|
||||
is an inventory of what nothing has checked, not a checklist that must be
|
||||
completed - see "What is actually required" above. 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`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
|
||||
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
|
||||
| `harnesses/` | Launch the harness against a real model |
|
||||
|
||||
## 5. The release pull request
|
||||
|
||||
Say what was run and what was not, in the body. At this size that will mostly
|
||||
be "not", and writing it down is the whole point: an unverified path that is
|
||||
named is one somebody can reach for when a bug turns up, and an unverified
|
||||
path that is implied to be tested costs a day of looking in the wrong place.
|
||||
|
||||
Worth its own line: **whether the release can be rolled back.** A config key
|
||||
added in a release makes the previous `main` refuse to start once an operator
|
||||
adopts it, because an unknown key fails validation - so the rollback that
|
||||
looks like the safety net is not one. Name the keys. A queued privileged job, a
|
||||
manual check that was skipped, a forge that has still never been launched -
|
||||
those are the things a reader of `main` is entitled to know, and the release
|
||||
PR is where they are still true and still remembered.
|
||||
+37
-2
@@ -63,6 +63,8 @@ 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_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
|
||||
| `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 +496,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 +509,48 @@ 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.
|
||||
|
||||
**The buttons are colour-coded by what they are for**, in the same gruvbox
|
||||
palette `dejima-attach` uses for the session picker - the two sit either side
|
||||
of one attach and should not look like different programs.
|
||||
|
||||
The colours are a hierarchy, not decoration. `KBD` is filled orange because on
|
||||
a phone it is how you type at all, and it is the one key that has to be
|
||||
findable without looking for it. `⏎` is filled green, being the key a session
|
||||
asks for most. The window and arrow keys are outlined in blue: reached for
|
||||
constantly, but never the thing you are hunting for. Everything else is
|
||||
furniture and stays grey. A latched `CTRL` or `DRAG` still overrides whichever
|
||||
tier its button is in, because the state a key is in matters more than the job
|
||||
it does.
|
||||
|
||||
Set `ttyd_button_colors = off` for the grey every key had before - for a
|
||||
terminal colour scheme this clashes with, or if you would rather it were
|
||||
quiet.
|
||||
|
||||
**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
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# Reviewing the state of the art
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
dejima's first month, from late July 2026, was spent reasoning from its own
|
||||
threat model without checking it against anyone else's. The first sweep, in
|
||||
August, turned up two things worth having found sooner than that:
|
||||
|
||||
- **A parser-differential bypass class against egress allowlists**, disclosed
|
||||
against another vendor's agent network sandbox: a null byte in a SOCKS5
|
||||
hostname, so the policy check read `attacker-host\x00.google.com` and matched
|
||||
a `.google.com` suffix while the resolver read `attacker-host`. dejima's
|
||||
matcher refused that shape and every neighbouring one, for structural reasons
|
||||
now recorded beside it - it matches whole domains rather than suffixes, and
|
||||
the decision and the dial are bound by the ipset being filled from the
|
||||
answer the resolver returned. One laxity turned up and was fixed.
|
||||
- **The closest comparable project ships the opposite default.** Trail of Bits'
|
||||
Claude Code devcontainer lists network under "not sandboxed" and has full
|
||||
outbound access unless an operator opts in. Working out why was worth more
|
||||
than the comparison: their scope is the host filesystem for an auditor
|
||||
watching a session, and network was never the thing they were containing.
|
||||
|
||||
Neither changed a control, which is the point. A month is not long to have gone
|
||||
unchecked, and finding out was cheap - which is the argument for doing it on a
|
||||
schedule rather than when something goes wrong.
|
||||
|
||||
This is the process. It is deliberately small: the failure mode of a research
|
||||
review is producing a reading list nobody acts on.
|
||||
|
||||
## When to run one
|
||||
|
||||
**Before locking a control.** This repository's rule is to measure the premise
|
||||
before designing on it. Extended one step: ask what measurement would falsify
|
||||
the control, *and* whether someone has already run it - or already bypassed it.
|
||||
|
||||
- Before adding or changing a boundary: a mount, an egress path, a guard, a
|
||||
credential that crosses.
|
||||
- When a control is being justified by reasoning rather than by a test. That is
|
||||
the moment to find out whether the reasoning is known to be wrong.
|
||||
- When a bypass is published for anything in the same class. Another project's
|
||||
egress allowlist bypass is a test case for this one.
|
||||
- Quarterly as a standing sweep, whether or not anything is pending. A threat
|
||||
model written eighteen months ago describes a different attacker.
|
||||
|
||||
That last one is the reason the schedule exists at all. Reported model
|
||||
capability on offensive security tasks has moved fast enough that a sandbox
|
||||
designed against a 2024 agent is not obviously adequate against a 2026 one, and
|
||||
nothing else here re-examines that assumption on any schedule.
|
||||
|
||||
## The standing questions
|
||||
|
||||
The agenda. Each entry is a live claim dejima rests on, phrased so the field can
|
||||
contradict it. Keep it current: a question nobody would answer "no" to is not
|
||||
doing any work.
|
||||
|
||||
1. **Is a hardened container an adequate boundary for an agent assumed
|
||||
hostile?** The field says no for untrusted code, and tiers gVisor and
|
||||
per-workload VM kernels above it. dejima's answer is that escape moves a
|
||||
layer - true where the layer exists. See
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||
2. **Does a name-based egress allowlist hold?** It holds against an agent that
|
||||
must resolve a name. It does not hold against exfiltration through an
|
||||
allowed domain or against DNS query-name channels, both of which are listed
|
||||
in [What dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
|
||||
The open part is parser differentials between the matcher and the resolver.
|
||||
3. **Where must egress enforcement sit?** Below the harness: a subprocess with
|
||||
its own HTTP client bypasses anything the harness mediates. dejima enforces
|
||||
at the container's network layer, which satisfies this. Settled unless the
|
||||
architecture changes.
|
||||
4. **Is "the operator merges" the right shape for human-in-the-loop?** The
|
||||
field's phrasing is "human approval on irreversible actions", which is what
|
||||
the merge and push guards implement. Corroborated; stop re-deriving it.
|
||||
5. **What is the current bypass literature for agent sandboxes?** The most
|
||||
valuable question and the one that ages fastest.
|
||||
6. **Does anyone else treat the previous session's output as hostile input to
|
||||
the next one?** The positioning question, and as of the first sweep the
|
||||
answer is no.
|
||||
7. **What does the field consider baseline that dejima does not do?** The
|
||||
cheapest question to ask. It found the absence of timeouts, which is now a
|
||||
[stated non-goal](security.md#why-there-are-no-timeouts) rather than a gap.
|
||||
8. **Is a control argued in one layer honoured in the others?** The three
|
||||
projects are three repositories with three vocabularies, and nothing checks
|
||||
that a claim made in one reaches the next. The first sweep produced the
|
||||
worked example: dejima blocks IPv6 outright and says why, `dejima-ci-hardener`
|
||||
makes the same argument for its job network - and both sibling projects'
|
||||
node-side firewalls were IPv4-only, written later, by people who had already
|
||||
made that argument twice. Each project's tests pass, because each tests its
|
||||
own layer against its own template. This question is the only thing looking
|
||||
at the seams.
|
||||
|
||||
## How to run one
|
||||
|
||||
**1. Sweep, do not read.** Search broadly and read only summaries. This pass
|
||||
produces a shortlist, not understanding. Search the standing questions, not
|
||||
dejima's vocabulary - our terms are ours, and "guarded store" finds nothing.
|
||||
|
||||
**2. Read in full only what could change a control.** A source that agrees
|
||||
changes nothing and can stay a citation. Read the ones describing a bypass of a
|
||||
control that exists here, quantifying something asserted here, or occupying
|
||||
ground assumed empty.
|
||||
|
||||
**3. Get the mechanism, not the headline.** "Egress allowlists are bypassable"
|
||||
is not actionable. "The policy check saw one hostname and the resolver saw
|
||||
another" is a test to write.
|
||||
|
||||
**4. Sort every finding into a bucket.**
|
||||
|
||||
| bucket | what it means | what to do |
|
||||
| --- | --- | --- |
|
||||
| **Prior art** | Someone built this | Read it properly, and start from where they ended. |
|
||||
| **Corroboration** | They hold the same premise | Cite it, stop re-deriving it, and stop treating it as a differentiator. |
|
||||
| **Their control** | They defend something dejima does not | Take the *distinction* if it is real. Do not take the *control* without asking what threat model produced it. |
|
||||
| **Contradiction** | Their default is the opposite | The most valuable bucket. Work out whether the threat models differ before conceding or dismissing. |
|
||||
| **Open ground** | Nobody does this | Say so, and say *why*. Usually a constraint others do not have; occasionally it means everyone else knows something. |
|
||||
|
||||
**5. Write down the contradiction even when it resolves in dejima's favour.**
|
||||
The default-network difference above is the worked example: it reads as dejima
|
||||
being ahead, and the reason turned out to be scope rather than rigour.
|
||||
Recording the reason is what stops a different scope being mistaken for a
|
||||
weaker one - by us, in six months, reading our own note.
|
||||
|
||||
**6. Turn every finding that changes a control into an issue.** A review that
|
||||
ends in a document has not finished.
|
||||
|
||||
## What to record, and where it does not go
|
||||
|
||||
**A sweep is not committed here.** It is written to the shared mount on the
|
||||
operator's machine, alongside the specs, plans and design notes that live
|
||||
there for the same reason: a review is ephemeral, and what belongs in this
|
||||
repository is the *action* it produced, not the reasoning that produced it.
|
||||
|
||||
What lands here is the outcome - an issue, a pull request, a paragraph in the
|
||||
documentation section the finding actually bears on. This page is itself an
|
||||
example: the August 2026 sweep produced a matcher fix, a
|
||||
[not-sandboxed list](security.md#what-dejima-does-not-sandbox), a
|
||||
[statement of which deployment the threat model describes](threat-model.md#which-deployment-this-describes),
|
||||
and a stated non-goal on timeouts. The sweep that produced them stayed on the
|
||||
operator's machine.
|
||||
|
||||
The sweep itself is one page, dated and sourced, in the house style, written
|
||||
where the other ephemeral documents go. Two rules specific to reviews, because
|
||||
both distinctions vanish within a week of writing:
|
||||
|
||||
- **Mark what was read in full and what came from a search summary.** A summary
|
||||
of a bypass is enough to write a test case from and not enough to claim
|
||||
dejima is safe from it.
|
||||
- **Quote claims with their conditions.** "Containers are inadequate" is false
|
||||
without "for untrusted code, where the host kernel is the boundary". The
|
||||
honesty rules here apply to what is read as much as to what is written.
|
||||
|
||||
## The standing test queue
|
||||
|
||||
A bypass from the literature is a hypothesis about *their* implementation until
|
||||
it is run against this one. dejima differs in ways that plausibly change the
|
||||
answer: enforcement is `iptables` and a DNS proxy rather than an in-process
|
||||
proxy, the matcher is an anchored shell regex rather than a string-suffix test,
|
||||
the allowlist matches whole domains rather than suffixes, and the ipset is
|
||||
populated from the answer the resolver returned rather than from a second parse
|
||||
of a string.
|
||||
|
||||
Each entry states a prediction that can fail.
|
||||
|
||||
- **Hostile hostnames against `valid_domain`.** Null bytes, percent-encoding,
|
||||
trailing dots, embedded newlines, IDNA, empty labels. Prediction: all refused.
|
||||
*Run 2026-08-25: all refused except an empty label, which was accepted and
|
||||
has since been fixed.*
|
||||
- **A published SOCKS-style parser differential, run end to end** rather than
|
||||
against the validator alone. Prediction: no path, because nothing in dejima
|
||||
speaks SOCKS and the decision and the dial are bound by the ipset. Not yet
|
||||
run.
|
||||
- **Redirect chains to a host outside the allowlist.** Prediction: fail at the
|
||||
ipset, because dejima does not mediate HTTP and the connection is judged by
|
||||
address. Not yet run.
|
||||
@@ -16,6 +16,61 @@ person thought to check, and no outside audit or adversarial testing has
|
||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||
|
||||
## What dejima does not sandbox
|
||||
|
||||
Every item here is documented somewhere below, and a document organised by
|
||||
control makes a reader assemble the negative space themselves. Most will not.
|
||||
So it is collected here, at the top, where it is the first thing read rather
|
||||
than the thing inferred from silence.
|
||||
|
||||
- **Exfiltration through a domain that is allowed.** The egress allowlist
|
||||
decides *where* a session may connect, not what it may put in the request. A
|
||||
session that can reach a host can encode data into a path or a query it
|
||||
sends there. Allowing a domain at all creates that channel.
|
||||
- **Data encoded into DNS query names.** The DNS proxy answers for allowlisted
|
||||
domains; the names asked for are a channel of their own.
|
||||
- **Anything below the container, where nothing is arranged there.** dejima's
|
||||
blast-radius table has rows beneath the container, and their controls belong
|
||||
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
|
||||
Run on a laptop with no VM layer, the row below the container is the
|
||||
operator's own machine, and dejima has nothing to say about it. See
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||
- **Anything finer than a session.** The unit of isolation is one container for
|
||||
one session. An MCP server, a hook, or a tool the session runs inherits the
|
||||
session's whole reach - dejima reports that a session *added* an MCP server
|
||||
(see the audit surfaces below) and does not constrain what that server then
|
||||
does. Per-tool granularity would need a second sandbox inside this one.
|
||||
- **Resource exhaustion.** No time limit, no memory limit, no CPU limit. A
|
||||
session that loops until someone notices is not prevented, and this is a
|
||||
deliberate omission rather than an oversight - see
|
||||
[Why there are no timeouts](#why-there-are-no-timeouts).
|
||||
- **The first layer's controls against an agent that has stopped
|
||||
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
|
||||
cooperating agent does and stop nothing that has decided otherwise. Only the
|
||||
layers below them are enforcement.
|
||||
|
||||
## Why there are no timeouts
|
||||
|
||||
Every published checklist for sandboxing an agent lists timeouts alongside
|
||||
non-root, egress filtering and read-only mounts. dejima has none, and the
|
||||
reason is a judgement about what dejima is for rather than an item not yet
|
||||
reached.
|
||||
|
||||
**A runaway session costs money and does not cross the boundary.** It burns
|
||||
tokens and holds a CPU until someone looks. Nothing about it reaches the host,
|
||||
widens the allowlist, or writes where a session may not write - the guarantees
|
||||
dejima makes are all still held while it happens. A timeout would protect a
|
||||
budget, which dejima has never claimed to protect.
|
||||
|
||||
**And "unattended" here does not mean "unobserved".** The sessions this is
|
||||
built for run for days, and are checked at every issue and every pull request.
|
||||
They need less babysitting, not none. A wall clock tuned for that is either so
|
||||
long it prevents nothing or short enough to kill work in progress, and killing
|
||||
a session mid-commit is a failure mode of its own.
|
||||
|
||||
If this changes it will be because a resource limit starts protecting the
|
||||
boundary rather than the bill.
|
||||
|
||||
## Blast radius by layer
|
||||
|
||||
Where each layer's compromise stops, with and without the thing arranged
|
||||
@@ -324,6 +379,15 @@ can make; it is one the audit reports. See
|
||||
live on disk); a removed one stores nothing at all, because a deletion
|
||||
diff *is* the whole former file.
|
||||
|
||||
What it prints is chosen by what changed, which is not always what you
|
||||
need in order to judge it - three changed lines in a hook script are only
|
||||
judgeable by reading the script. `[o]` at the prompt opens the file in
|
||||
`$VISUAL`/`$EDITOR`, read-only where the editor understands how, and
|
||||
returns to the same decision. It opens the file **as it is now**, not the
|
||||
text the report captured, for the same reason an approval pins the content
|
||||
that is on disk at the moment it is recorded: approving something nobody
|
||||
read would be the one outcome worse than not asking.
|
||||
|
||||
Staging and retained diffs live under `$AUDIT_DIR`
|
||||
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
|
||||
scheme rests on: nothing in `docker-compose.yml` covers it, so the
|
||||
@@ -701,6 +765,23 @@ session, where the credential deliberately never goes.
|
||||
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
- A forge credential reaches the container as `-e NAME`, never
|
||||
`-e NAME=value`. The value comes from the launcher's own environment, so it
|
||||
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
|
||||
`/proc/<pid>/cmdline`, which is world-readable and which `ps` prints to
|
||||
anyone. `docker inspect` still shows the resolved value to whoever can
|
||||
reach the daemon - that is daemon access rather than any process at all,
|
||||
and it is not something the launcher can change.
|
||||
- The container runs with `no-new-privileges` and an explicit capability
|
||||
floor - `cap_drop: ALL` plus the seven it actually uses. Six are the
|
||||
entrypoint's own; the seventh is `KILL`, which nothing in the entrypoint
|
||||
needs and `dejima-allow --container` does, because adding a domain to a
|
||||
running session signals dnsmasq and dnsmasq runs as its own user. The first means a setuid binary or a file capability cannot raise
|
||||
privileges on exec, which is the shape any escalation from the session user
|
||||
would have to take; the second means the capabilities nothing here uses were
|
||||
never in the bounding set to be regained. Both are asserted against a running
|
||||
container rather than against the compose file, and the capability list the
|
||||
test uses is read out of that file so the two cannot drift.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see
|
||||
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
||||
@@ -846,6 +927,14 @@ strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
|
||||
leaving it open would bypass the whole firewall on any host with IPv6
|
||||
egress.
|
||||
|
||||
Where `ip6tables` cannot be used at all, the firewall does not conclude that
|
||||
IPv6 is therefore absent - it asks the kernel, and treats an address on any
|
||||
interface other than loopback as the family being live. It then turns IPv6
|
||||
off in the container's own network namespace, and **refuses to start** if it
|
||||
cannot. A tool that will not run and a family that is not there are different
|
||||
conditions, and reporting the second on evidence of the first is how a
|
||||
boundary comes to be described rather than held.
|
||||
|
||||
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
||||
Docker host itself can resolve it: containers inherit the host's resolver.
|
||||
|
||||
|
||||
+67
-1
@@ -62,12 +62,26 @@ to instructions, because it arrives as instructions.
|
||||
4. **Cross-project context poisoning.** A session writes another project's
|
||||
agent memory or transcripts, and a later session there reads planted text
|
||||
as established background.
|
||||
5. **Container escape.** Real, and it recurs, but it needs an unpatched host
|
||||
5. **The operator running something the session asked them to run.** dejima
|
||||
tells agents to hand host-side work over rather than attempt it - a domain
|
||||
to allow, a dependency to approve, a command only the host can run. That is
|
||||
a designed, legitimate, frequent workflow, which is exactly what makes it a
|
||||
delivery mechanism: a script that does what it says and one thing more is
|
||||
indistinguishable at the moment of running it. Nothing here inspects what
|
||||
crosses that boundary, and nothing can - the whole point of the handoff is
|
||||
that a person decides.
|
||||
6. **Container escape.** Real, and it recurs, but it needs an unpatched host
|
||||
or a working zero-day, and the two rows it moves between are both already
|
||||
in the table below.
|
||||
|
||||
The ordering is the point. Nothing above the last item requires an exploit.
|
||||
|
||||
Item 5 is not an argument for making the handoff harder; a session that cannot
|
||||
ask is a session that works around you instead. It is an argument for being
|
||||
able to answer, afterwards, what that actually did to the machine - which is
|
||||
the layer below this repository's, and the reason the sister project is
|
||||
looking at detection rather than at more enforcement.
|
||||
|
||||
## Blast radius by layer
|
||||
|
||||
Read the last two columns together: the middle one is what the layer reaches
|
||||
@@ -127,6 +141,58 @@ has the two tiers and where each one's boundary sits.
|
||||
Neither is required to run dejima, and the container row holds without them.
|
||||
What they change is what the row below it costs.
|
||||
|
||||
**A survey of comparable projects found none with rows below the container at
|
||||
all** - their boundary is between the agent and the host, and what is under
|
||||
the host is outside their threat models. The same survey found nothing
|
||||
treating a forge or a CI runner as part of an agent's blast radius, which is
|
||||
what the second sister project exists for: a session that can open a pull
|
||||
request can execute code on a machine dejima never touches.
|
||||
|
||||
Recorded as an observation from one day of secondary sources rather than as a
|
||||
claim to have surveyed the field exhaustively. The reason it is here at all is
|
||||
that it changes what to defend: if the layers below and beside the container
|
||||
are ones nobody else is examining, they are also ones where nobody else's
|
||||
mistakes are available to learn from.
|
||||
|
||||
## Which deployment this describes
|
||||
|
||||
The table above describes dejima **as deployed on a devbox VM with the two
|
||||
sister projects arranged around it**. Say that plainly, because the README
|
||||
also invites running dejima on a laptop, and there the table is shorter than
|
||||
it looks: the container row holds exactly as described, and the row below it
|
||||
is the operator's own machine with nothing arranged against it.
|
||||
|
||||
That matters more than it used to. The field's own ordering for isolating an
|
||||
agent whose contents are assumed hostile puts a hardened container below
|
||||
gVisor and below a per-workload VM kernel, on the grounds that a container
|
||||
shares the host kernel and that container *runtime* vulnerabilities bypass
|
||||
syscall filtering rather than being caught by it. dejima's answer to that
|
||||
ordering is not to dispute it. It is that escape moves a layer instead of
|
||||
ending the argument - which is a better answer where the next layer exists,
|
||||
and no answer at all where it does not.
|
||||
|
||||
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
|
||||
On a laptop, dejima is a hardened container and the honest description of what
|
||||
is under it is "your home directory, and dejima has nothing to say about it".
|
||||
|
||||
**Load-bearing for what, exactly**, because the field's tiering invites a
|
||||
reading that project does not accept and has never claimed. A per-workload VM
|
||||
kernel is reached for elsewhere as a *containment* boundary. That is not what
|
||||
the row below the container buys here. `dejima-devbox-hardener`'s own threat
|
||||
model states in three places that its firewall bounds where a compromise can
|
||||
**spread**, not whether it can phone home: an escape reopens internet egress,
|
||||
deliberately, because deny-by-default earns its keep where the protected set
|
||||
is enumerable and the internet is not.
|
||||
|
||||
Read the row as written, then: it cuts LAN reach to what is declared, and it
|
||||
is enforced from outside the guest so root in the VM cannot flush it. It does
|
||||
not restore the egress control the container layer provides. Anyone writing
|
||||
this sentence more confidently than that is making a promise on another
|
||||
repository's behalf.
|
||||
|
||||
**What this repository can do about that is say so**, which is the whole of
|
||||
this section. Nothing in dejima can bound its own host.
|
||||
|
||||
## What this repository does not claim
|
||||
|
||||
- **That the egress allowlist bounds what reaches the model.** It bounds what
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# Optional tools
|
||||
|
||||
A tool is a program dejima can put in a session's image that is not the
|
||||
harness and not a project dependency: something an agent uses to work, that
|
||||
dejima has looked at first.
|
||||
|
||||
None is enabled by default.
|
||||
|
||||
```ini
|
||||
# ~/.config/dejima/config - on this machine, always
|
||||
[core]
|
||||
tools = rtk
|
||||
```
|
||||
|
||||
```
|
||||
# .dejima/tools in the project - shared, and approved before it takes effect
|
||||
rtk
|
||||
```
|
||||
|
||||
```sh
|
||||
dejima --include rtk # or -i rtk, repeatable, for one launch
|
||||
```
|
||||
|
||||
The three are a union rather than a precedence chain: they answer different
|
||||
questions - what this machine always wants, what this project needs, what this
|
||||
run is for - and none is a correction of another. The project's half is a
|
||||
declaration like every other file in `.dejima/`: it grants nothing until
|
||||
`dejima-project approve` records it in a store outside the container's reach.
|
||||
|
||||
## What "optional tool" does not mean
|
||||
|
||||
**It does not mean a bare install.** This is the part worth stating first,
|
||||
because it is the whole difference between this and adding a line to
|
||||
`.dejima/dependencies`.
|
||||
|
||||
A dependency is something a project says it needs and an operator agrees to.
|
||||
Nobody claims to have read it. A tool is something **dejima ships**, and the
|
||||
bar for adding one is that dejima has hardened it. That bar applies to every
|
||||
tool added this way, not only the first:
|
||||
|
||||
- **Pinned and checksummed.** A published artifact at a fixed version with a
|
||||
`sha256` in the manifest, verified before it is unpacked. Never the
|
||||
project's own `curl | sh` installer, which fetches whatever its default
|
||||
branch points at today - an image build that produces a different binary on
|
||||
different days is one nothing has reviewed.
|
||||
- **Its network behaviour established, not assumed.** Read the source for
|
||||
outbound calls, then check the artifact you are actually shipping, because
|
||||
the two can differ. Anything it wants to reach is off unless the firewall
|
||||
allowlists it, and a tool that needs a domain allowlisted to function is one
|
||||
to argue about rather than to add.
|
||||
- **Telemetry off at the tool's own switch as well as at the firewall.** Two
|
||||
independent offs, because either one can be wrong.
|
||||
- **Wired up by dejima, not by the tool's installer.** Most of these ship an
|
||||
`init` that patches a harness's config file, and what it patches is often
|
||||
the same hook surface dejima's guards live on. dejima renders the wiring
|
||||
itself so the ordering, and what the file ends up containing, stay dejima's.
|
||||
- **The guards still hold with it enabled**, and a test says so.
|
||||
|
||||
- **It sets only its own environment.** A tool's `image_env` becomes a
|
||||
Dockerfile `ENV` that every process in the container inherits, so a bare
|
||||
name reaches the harness too. Names must be namespaced to the tool and a
|
||||
launch refuses one that is not. This is the bar's newest line, and it is
|
||||
here because rtk broke it: it shipped with `NO_COLOR=1`, and a session with
|
||||
the tool enabled lost colour in the harness, git and everything else.
|
||||
|
||||
If a tool cannot clear that, it is not an optional tool. It might still be a
|
||||
dependency a project declares and an operator approves, where the claim being
|
||||
made is much smaller.
|
||||
|
||||
## What a tool may declare
|
||||
|
||||
Deliberately little. A tool's manifest names what it is, what installs it,
|
||||
what its environment needs, and what the session should be told about it.
|
||||
There is no key for a hook, a mount, a domain or a guard - a tool that needed
|
||||
one of those would be a change to the boundary rather than a program on PATH,
|
||||
and would have to be argued for rather than declared.
|
||||
|
||||
See `docs/providers.md` for the key-by-key table.
|
||||
|
||||
## rtk
|
||||
|
||||
[rtk](https://github.com/rtk-ai/rtk) (Apache-2.0) compresses command output
|
||||
before it reaches the model: `rtk git status` runs git and prints a shorter
|
||||
form of the result. It makes no model calls of its own - it is a deterministic
|
||||
filter, so nothing about it sends your code anywhere.
|
||||
|
||||
What was checked, since the list above is only worth as much as its worst
|
||||
entry:
|
||||
|
||||
- **Pinned** to the published `x86_64-unknown-linux-musl` release by digest,
|
||||
taken from the artifact and confirmed against a second download.
|
||||
- **Telemetry exists and the project's README does not mention it.** An HTTPS
|
||||
ping is compiled into the release binary - visible in the shipped artifact's
|
||||
strings, not only in the source - gated on a consent flag that defaults to
|
||||
unset. It is off three ways here: consent unset, `RTK_TELEMETRY_DISABLED=1`
|
||||
in the image environment, and the endpoint is not allowlisted, so the
|
||||
request cannot leave the container even if the first two were wrong.
|
||||
- **It resolves wrapped commands through PATH.** Measured: `rtk git status`
|
||||
with a stub `git` earlier on PATH runs the stub. So dejima's PATH shadows -
|
||||
the git guard and the forge guard - still apply to a command run through it,
|
||||
which is the property that matters most here.
|
||||
- **Its Claude Code integration installs a `PreToolUse` hook that rewrites
|
||||
commands**, in the same slot dejima's guards occupy. dejima does not install
|
||||
that hook. See below.
|
||||
|
||||
### Why the hook is not installed
|
||||
|
||||
`rtk init` registers a hook that rewrites a Bash command before it runs -
|
||||
`git push origin main` becomes `rtk git push origin main`. That is the same
|
||||
`PreToolUse`/`Bash` slot as `deny-dangerous-bash.sh`, `git-push-guard.sh` and
|
||||
`merge-guard.sh`, and the ordering between a third-party rewrite and dejima's
|
||||
guards is not something dejima would control once the tool's installer had
|
||||
written it.
|
||||
|
||||
So dejima tells the session the tool exists instead, which is exactly what
|
||||
rtk's own Cline integration does. That is weaker: the agent chooses when to
|
||||
use it rather than getting it automatically. It is also the conservative first
|
||||
position for the first tool of this kind, and the thing that would change it
|
||||
is a measurement - establishing how Claude Code merges hooks across settings
|
||||
layers, and whether a guard sees the original command or the rewritten one -
|
||||
rather than a preference.
|
||||
|
||||
Note what is not being claimed: a session can run `rtk git push` itself
|
||||
whether or not dejima installs the hook. The guards hold because they hold at
|
||||
the PATH and hook layers, not because the tool is absent.
|
||||
|
||||
### Limitation
|
||||
|
||||
x86_64 only. The tarball spec names one architecture (issue #97), so an arm64
|
||||
host cannot use this entry.
|
||||
@@ -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}')"
|
||||
|
||||
+32
-10
@@ -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
|
||||
@@ -230,10 +233,34 @@ ${body}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Where the base comes from when --base was not given, most specific
|
||||
# first. A project whose work does not target its default branch - one
|
||||
# that merges through a development branch, say - can say so once with
|
||||
#
|
||||
# git config gitea.prBase development
|
||||
#
|
||||
# rather than every caller remembering a flag. Without that, the
|
||||
# repository's default branch is the only thing there is to go on.
|
||||
base_source="--base"
|
||||
if [ -z "$base" ]; then
|
||||
base="$(git config --get gitea.prBase 2>/dev/null)" || base=""
|
||||
base_source="git config gitea.prBase"
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
base=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#refs/remotes/origin/##' || true)
|
||||
base="${base:-main}"
|
||||
base_source="the repository's default branch"
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
base=main
|
||||
base_source="a fallback, because nothing else said"
|
||||
fi
|
||||
# Said out loud, every time. A base that is wrong is not visible in
|
||||
# anything this prints afterwards - the URL looks the same - so it was
|
||||
# possible to open a run of pull requests against the wrong branch,
|
||||
# report them as targeting the right one in good faith, and have
|
||||
# someone else quietly retarget each one. Naming it here is what makes
|
||||
# the mistake cost one line instead of a fortnight.
|
||||
echo "gitea-pr: opening against '$base' ($base_source)." >&2
|
||||
|
||||
api_all_pages "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
|
||||
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
|
||||
@@ -273,12 +300,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 \
|
||||
|
||||
@@ -2,6 +2,16 @@ 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
|
||||
# Which of the keys above belong to a PERSON rather than to the project. A
|
||||
# project's .dejima/ is committed and shared; a token path is per-user and
|
||||
# per-machine, so a project may not declare one and an [identity.<name>]
|
||||
# section may set one. One declaration drives both rules, so they cannot
|
||||
# disagree about which key is which.
|
||||
identity_keys = token_file
|
||||
keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/token
|
||||
requires = ssh_host api token_file
|
||||
host_keys = ssh_host
|
||||
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
#!/bin/bash
|
||||
# The one thing `gh` cannot do (#186).
|
||||
#
|
||||
# `gh pr view --comments` prints reviews and comments with no resolution state
|
||||
# anywhere in the output, because the REST API it uses does not carry any.
|
||||
# `isResolved` exists only on `reviewThreads` in the GraphQL API. So an agent
|
||||
# or an operator working through PR feedback re-reads and re-answers threads
|
||||
# that were closed a week ago, with nothing saying so - quiet in the direction
|
||||
# that wastes the most work.
|
||||
#
|
||||
# Everything else `gitea-pr` does has a `gh` subcommand already, which is why
|
||||
# this ships one command rather than a second CLI. It reads and never writes,
|
||||
# so there is no authorship disclosure to apply: docker/forge-guard.sh shadows
|
||||
# `gh`, not this.
|
||||
set -euo pipefail
|
||||
|
||||
function usage() {
|
||||
echo "Usage: github-pr comments PR_NUMBER [--all]"
|
||||
echo ""
|
||||
echo "Shows a pull request's comments, reviews and inline review threads."
|
||||
echo "Unresolved threads only by default; --all includes the ones already"
|
||||
echo "resolved, marked as such."
|
||||
echo ""
|
||||
echo "The repository is the 'origin' remote of the checkout you are in, and"
|
||||
echo "must be on github.com. Authentication is gh's own - GH_TOKEN, or"
|
||||
echo "whatever 'gh auth status' reports."
|
||||
}
|
||||
|
||||
# Same shape as gitea-pr's: resolve from origin and refuse another host, so
|
||||
# this tool cannot be pointed at a repository the checkout is not for. Covers
|
||||
# ssh, scp-style and https remotes by taking the last two path components
|
||||
# rather than parsing any of them as a URL.
|
||||
function github_owner_repo() {
|
||||
local remote_url stripped name rest owner
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
echo "No 'origin' remote found in this repository" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$remote_url" in
|
||||
*github.com[:/]*) ;;
|
||||
*) echo "origin remote ($remote_url) is not on github.com; github-pr only supports this host" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
stripped="${remote_url%/}"
|
||||
stripped="${stripped%.git}"
|
||||
name="${stripped##*/}"
|
||||
rest="${stripped%/*}"
|
||||
owner="${rest##*[:/]}"
|
||||
if [ -z "$owner" ] || [ -z "$name" ] || [ "$owner" = "$stripped" ]; then
|
||||
echo "Could not read an owner/name out of the origin remote ($remote_url)" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s/%s' "$owner" "$name"
|
||||
}
|
||||
|
||||
# The number reaches gh as a typed GraphQL variable rather than being spliced
|
||||
# into a path, so this is not the URL-traversal check gitea-pr's is. It is
|
||||
# here so a mistyped argument fails with something a person can act on instead
|
||||
# of a GraphQL type error.
|
||||
function require_number() {
|
||||
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Expected a PR number, got '${1:-}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
-h|--help|help) usage; exit 0 ;;
|
||||
comments) ;;
|
||||
*) echo "Unknown subcommand: $subcommand" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
require_number "${1:-}"
|
||||
pr_number="$1"
|
||||
shift || true
|
||||
show_all=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--all) show_all=1 ;;
|
||||
*) echo "Unknown argument: $1" >&2
|
||||
echo "Usage: github-pr comments PR_NUMBER [--all]" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
owner_repo="$(github_owner_repo)"
|
||||
owner="${owner_repo%%/*}"
|
||||
repo="${owner_repo##*/}"
|
||||
|
||||
# gh's own message, then one line saying what was being asked for. Captured
|
||||
# rather than piped straight into jq: on a 404 gh prints "Not Found" and exits,
|
||||
# and jq then fails on the message text, so the last thing on the terminal is
|
||||
# "Cannot index string with string" rather than the fact that the PR does not
|
||||
# exist.
|
||||
api_or_die() {
|
||||
local what="$1"; shift
|
||||
local out
|
||||
if ! out="$("$@" 2>&1)"; then
|
||||
printf '%s\n' "$out" >&2
|
||||
echo "github-pr: could not $what for ${owner_repo}#${pr_number}" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
echo "=== Issue comments ==="
|
||||
api_or_die "fetch issue comments" \
|
||||
gh api --paginate "repos/${owner}/${repo}/issues/${pr_number}/comments" \
|
||||
| jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Reviews ==="
|
||||
api_or_die "fetch reviews" \
|
||||
gh api --paginate "repos/${owner}/${repo}/pulls/${pr_number}/reviews" \
|
||||
| jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Inline review comments ==="
|
||||
|
||||
# reviewThreads, not the REST review comments, for the whole reason this tool
|
||||
# exists: `isResolved` is only here. `isOutdated` comes with it and is worth
|
||||
# printing - a thread against a hunk that no longer exists is usually already
|
||||
# handled, and reads as outstanding otherwise.
|
||||
#
|
||||
# Paginated by hand rather than with `gh api --paginate`: that flag emits one
|
||||
# JSON document per page for GraphQL, which would have to be reassembled here
|
||||
# anyway, and the bound below is the point. A server that ignored `after`
|
||||
# would otherwise loop forever, and this runs unattended.
|
||||
PAGE_SIZE=50
|
||||
PAGE_MAX=20
|
||||
read -r -d '' QUERY <<'GRAPHQL' || true
|
||||
query($owner: String!, $name: String!, $number: Int!, $size: Int!, $cursor: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: $size, after: $cursor) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
isResolved
|
||||
isOutdated
|
||||
path
|
||||
line
|
||||
comments(first: 100) {
|
||||
pageInfo { hasNextPage }
|
||||
nodes { author { login } body diffHunk createdAt }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GRAPHQL
|
||||
|
||||
threads='[]'
|
||||
cursor=""
|
||||
page=1
|
||||
while [ "$page" -le "$PAGE_MAX" ]; do
|
||||
if [ -z "$cursor" ]; then
|
||||
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
|
||||
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE")"
|
||||
else
|
||||
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
|
||||
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE" \
|
||||
-f cursor="$cursor")"
|
||||
fi
|
||||
# GraphQL answers a PR number that does not exist with a null pullRequest
|
||||
# and no error at all, so this is the check that catches it rather than
|
||||
# adding null to an array and failing with jq's wording.
|
||||
if [ "$(jq -r '.data.repository.pullRequest // "null"' <<< "$json")" = "null" ]; then
|
||||
echo "github-pr: no pull request ${owner_repo}#${pr_number}" >&2
|
||||
exit 1
|
||||
fi
|
||||
threads="$(jq -n --argjson a "$threads" --argjson b "$json" \
|
||||
'$a + $b.data.repository.pullRequest.reviewThreads.nodes')"
|
||||
if [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<< "$json")" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
cursor="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<< "$json")"
|
||||
page=$((page + 1))
|
||||
done
|
||||
if [ "$page" -gt "$PAGE_MAX" ]; then
|
||||
echo "warning: stopped reading review threads after $PAGE_MAX pages - some may be missing" >&2
|
||||
fi
|
||||
|
||||
# A thread whose replies did not all fit is reported rather than silently
|
||||
# truncated, for the same reason the page bound is: a feedback tool that drops
|
||||
# the comment someone is waiting on is worse than one that says it might have.
|
||||
truncated="$(jq '[.[] | select(.comments.pageInfo.hasNextPage)] | length' <<< "$threads")"
|
||||
[ "$truncated" -eq 0 ] || \
|
||||
echo "warning: $truncated thread(s) have more replies than were fetched" >&2
|
||||
|
||||
resolved_count="$(jq '[.[] | select(.isResolved)] | length' <<< "$threads")"
|
||||
if [ "$show_all" -eq 1 ]; then
|
||||
visible="$threads"
|
||||
else
|
||||
visible="$(jq '[.[] | select(.isResolved | not)]' <<< "$threads")"
|
||||
fi
|
||||
|
||||
# The hunk, not just the path, and the same layout gitea-pr prints: whoever
|
||||
# reads one of these tools should not have to learn the other's output.
|
||||
jq -r '.[]
|
||||
| . as $t
|
||||
| .comments.nodes[]
|
||||
| "[\(.author.login // "?") @ \($t.path):\($t.line // "?")\(if $t.isResolved then " (resolved)" else "" end)\(if $t.isOutdated then " (outdated)" else "" end)]\n\($t.comments.nodes[0].diffHunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"' \
|
||||
<<< "$visible"
|
||||
|
||||
if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
|
||||
echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
|
||||
fi
|
||||
@@ -24,9 +24,38 @@ capabilities = pr issues
|
||||
pr_command = gh pr
|
||||
issue_command = gh issue
|
||||
merge_block = gh pr merge
|
||||
# How this CLI can be pointed at a repository other than the one the session
|
||||
# was launched against. `gh` takes --repo/-R on essentially every subcommand
|
||||
# and reads GH_REPO from the environment, so the ordinary path can file an
|
||||
# issue or leave a comment anywhere the token reaches (#187). The gitea CLIs
|
||||
# have no equivalent - they resolve the repo from origin and refuse another
|
||||
# host - which is why these keys are empty for that forge rather than absent
|
||||
# from the schema.
|
||||
#
|
||||
# Fast-fail, not a boundary, exactly as merge_block is: the session holds the
|
||||
# token and could call the API directly. What actually bounds this is the
|
||||
# scope of push_token_file, which is why docs/providers.md says a
|
||||
# repository-scoped fine-grained PAT is the expected shape.
|
||||
repo_flags = --repo -R
|
||||
repo_env = GH_REPO
|
||||
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
|
||||
# See the gitea manifest's note: this is the key an identity supplies and a
|
||||
# project may not. The read-only [github] token_file is core's rather than
|
||||
# this forge's, and is not here for that reason.
|
||||
identity_keys = push_token_file
|
||||
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.
|
||||
#
|
||||
# github-pr is dejima's, and exists for one thing gh cannot do: thread
|
||||
# resolution lives only in the GraphQL API, so `gh pr view --comments` shows
|
||||
# resolved and unresolved review threads alike and an agent working through
|
||||
# feedback re-answers what was closed a week ago (#186). It reads and never
|
||||
# writes, so nothing is shadowed over it.
|
||||
install = system:gh bin/github-pr
|
||||
env_export = GH_TOKEN=@push_token_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
|
||||
|
||||
@@ -207,7 +207,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
|
||||
Tell the user to run, outside the sandbox:
|
||||
dejima-project approve \"$project_dir\"
|
||||
(or, for one domain right now:
|
||||
bin/dejima-allow --container $container_id <domain>)"
|
||||
dejima-allow --container $container_id <domain>)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
+129
-1
@@ -651,6 +651,129 @@ EOF
|
||||
[[ "$output" == *"settings.json"* ]] # the second report, same run
|
||||
}
|
||||
|
||||
@test "ack retires a report it has finished, and reaches the ones behind it" {
|
||||
# #195, as reported: "5 reports waiting" followed immediately by "nothing
|
||||
# new - 32 finding(s) already dealt with", and the five stay waiting
|
||||
# however many times ack is run. Selection kept returning a report it had
|
||||
# already finished, so every other report was unreachable.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
|
||||
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
|
||||
|
||||
# Approve through both, in one run.
|
||||
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *".envrc"* ]] && [[ "$output" == *"settings.json"* ]] \
|
||||
|| { echo "did not reach both reports: $output"; return 1; }
|
||||
|
||||
# Nothing outstanding now. The reports stay on disk - they are the record
|
||||
# of what those sessions did, and rotation evicts them, not ack - but
|
||||
# neither ack nor show may claim work is waiting.
|
||||
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
|
||||
run bash -c "'$AUDIT' ack '$WS' 2>&1"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"nothing waiting"* ]] || { echo "ack: $output"; return 1; }
|
||||
[[ "$output" != *"reports waiting"* ]] || { echo "still claims work: $output"; return 1; }
|
||||
run bash -c "'$AUDIT' show '$WS' 2>&1"
|
||||
[[ "$output" == *"nothing waiting"* ]] || { echo "show: $output"; return 1; }
|
||||
# And it says what is still there, rather than "no reports" over a
|
||||
# directory holding two.
|
||||
[[ "$output" == *"2 report(s) kept"* ]] || { echo "show: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a report still holding an undecided finding is not retired with the rest" {
|
||||
# The marker retires the report, not the decision. A finding the operator
|
||||
# skipped is unbaselined and must come back - otherwise "ask again next
|
||||
# time" silently becomes "never ask again".
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS' 2>&1"
|
||||
[[ "$output" == *".envrc"* ]] \
|
||||
|| { echo "a skipped finding did not come back: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a file already shown this run is not asked about again from an older report" {
|
||||
# Two reports about the same file, walked in one invocation. Skipping it in
|
||||
# the first leaves nothing in the baseline, so the second used to ask again
|
||||
# - about content that is no longer what is on disk, which is a question
|
||||
# with no useful answer.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
snap; echo 'export X=2' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
|
||||
|
||||
# skip, continue, then the second report must not re-ask.
|
||||
run bash -c "printf 's\ny\ns\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(grep -c '\[CRITICAL\]\|\[WARNING\]' <<< "$output")" -eq 1 ] \
|
||||
|| { echo "asked more than once about one file:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"already dealt with"* ]] \
|
||||
|| { echo "dropped it silently instead of saying why: $output"; return 1; }
|
||||
|
||||
# Skipping is still skipping: it comes back on the next run.
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS' 2>&1"
|
||||
[[ "$output" == *".envrc"* ]] \
|
||||
|| { echo "a skipped finding did not come back: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "ack opens the current file in the operator's editor, and comes back to the decision" {
|
||||
# The report shows the part that changed. What a person needs in order to
|
||||
# judge it is the file, and there was no way to get at it from the prompt.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; printf 'export X=1\n' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
local ed="$BATS_TEST_TMPDIR/fake-editor"
|
||||
cat > "$ed" <<'EOF'
|
||||
#!/bin/bash
|
||||
# Records what it was asked to open, and what was in it at the time.
|
||||
printf '%s\n' "$*" >> "$FAKE_EDITOR_LOG"
|
||||
tail -n +1 -- "${@: -1}" >> "$FAKE_EDITOR_LOG"
|
||||
EOF
|
||||
chmod +x "$ed"
|
||||
export FAKE_EDITOR_LOG="$BATS_TEST_TMPDIR/editor.log"
|
||||
|
||||
# open, then approve.
|
||||
run bash -c "printf 'o\na\n' | VISUAL='$ed' '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -f "$FAKE_EDITOR_LOG" ] || { echo "the editor was never run: $output"; return 1; }
|
||||
# The file on disk, not the text the report captured.
|
||||
grep -q "$WS/.envrc" "$FAKE_EDITOR_LOG" \
|
||||
|| { echo "opened something else: $(cat "$FAKE_EDITOR_LOG")"; return 1; }
|
||||
grep -q 'export X=1' "$FAKE_EDITOR_LOG" \
|
||||
|| { echo "did not open the current content: $(cat "$FAKE_EDITOR_LOG")"; return 1; }
|
||||
# Opening is not a decision: the approve that followed it is what recorded.
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ] \
|
||||
|| { echo "opening did not return to the decision"; cat "$BASEDIR/$slug"; return 1; }
|
||||
}
|
||||
|
||||
@test "ack offers no editor for a file that has since been removed" {
|
||||
# The surface is gone, so there is nothing to read - and offering to open
|
||||
# it would either fail or, worse, open an empty buffer that looks like the
|
||||
# file's current content and gets approved on that basis.
|
||||
snap; printf 'export X=1\n' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc"
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *".envrc"* ]] || { echo "no finding to test: $output"; return 1; }
|
||||
[[ "$output" != *"open the file"* ]] \
|
||||
|| { echo "offered to open a file that is gone: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "ack offers no editor for a finding with no file to open" {
|
||||
# A git config key has no path. Offering to open one, and then failing to,
|
||||
# is worse than not offering.
|
||||
snap
|
||||
git -C "$WS" config core.pager 'sh -c cat'
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"git config"* ]] || { echo "no gitconfig finding to test: $output"; return 1; }
|
||||
[[ "$output" != *"open the file"* ]] \
|
||||
|| { echo "offered to open a file that does not exist: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "ack stops at one report when told not to continue" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
@@ -834,7 +957,12 @@ REPORT
|
||||
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *".envrc"* ]]
|
||||
[[ "$output" == *"already dealt with"* ]]
|
||||
# The second run does not re-walk a report it finished: it says the work
|
||||
# is done and stops. That message used to come from walking the report
|
||||
# again and finding every finding covered, which is what left a finished
|
||||
# report selectable forever and the ones behind it unreachable (#195).
|
||||
[[ "$output" == *"nothing waiting"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"has been dealt with"* ]] || { echo "$output"; return 1; }
|
||||
# And no second entry for the same decision.
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
|
||||
}
|
||||
|
||||
+382
-4
@@ -487,6 +487,100 @@ write_config() {
|
||||
|| { echo "the refusal does not name the key: $output"; return 1; }
|
||||
}
|
||||
|
||||
# --- identities: the half of a project's config that belongs to a person -----
|
||||
#
|
||||
# A project's .dejima/ is committed and shared; an ssh key path and a token
|
||||
# path are per-user and per-machine. An identity is where those live, so a
|
||||
# project can name an account without naming a path.
|
||||
|
||||
_identity_fixture() {
|
||||
ID_DIR="$(mktemp -d)"
|
||||
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
|
||||
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
|
||||
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
"token_file = $ID_DIR/default-token" \
|
||||
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
|
||||
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
|
||||
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
|
||||
}
|
||||
|
||||
@test "an identity supplies the ssh key and the forge credential" {
|
||||
_identity_fixture
|
||||
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qx "forge.gitea.token_file=$ID_DIR/work-token" <<< "$output" \
|
||||
|| { echo "$output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "a key the identity does not name falls through to the file" {
|
||||
# An identity overrides what it names. Treating it as a replacement for
|
||||
# the whole section would make a half-filled one silently unset the rest.
|
||||
_identity_fixture
|
||||
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qx "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|
||||
|| { echo "oss has no token_file and did not fall through: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "an identity that names nothing is refused, not ignored" {
|
||||
# The whole point of the refusal: falling through to this machine's own
|
||||
# credentials would push under the wrong account with nothing saying so.
|
||||
# Pushing under the wrong key fails loudly; pushing under the default key
|
||||
# when you asked for another does not fail at all.
|
||||
_identity_fixture
|
||||
run "$REPO_ROOT/bin/dejima" --identity wrok --print-config
|
||||
[ "$status" -ne 0 ] || { echo "a nonexistent identity was accepted"; return 1; }
|
||||
[[ "$output" == *"wrok"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"core.ssh_key="* ]] \
|
||||
|| { echo "printed a resolved config under an identity that does not exist"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "a flag beats the identity, and the identity beats the file" {
|
||||
_identity_fixture
|
||||
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
# [core] identity in the file, overridden by the flag.
|
||||
write_config '[core]' 'identity = work' "ssh_key = $ID_DIR/default-key" \
|
||||
'[identity.work]' "ssh_key = $ID_DIR/work-key" \
|
||||
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
|
||||
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
|
||||
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" \
|
||||
|| { echo "--identity did not beat [core] identity: $output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "an identity may only set what a person owns" {
|
||||
# Not a second config file with different rules about what may be in it.
|
||||
_identity_fixture
|
||||
write_config '[core]' 'forge = gitea' '[identity.work]' 'firewall = off'
|
||||
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||
[ "$status" -ne 0 ] || { echo "an identity set [core] firewall"; return 1; }
|
||||
[[ "$output" == *"firewall"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@test "an identity's git name reaches the container's gitconfig copy" {
|
||||
# Appended to the COPY dejima already makes, so the real ~/.gitconfig is
|
||||
# untouched - which was already true and is what the copy is for.
|
||||
_identity_fixture
|
||||
grep -q 'identity_git_field' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the launcher no longer reads the identity's git name"; return 1; }
|
||||
# Written with git itself rather than printf: the value is the operator's
|
||||
# own text, and a name with a quote or a backslash in it has to survive
|
||||
# into a file git reads back the same way.
|
||||
grep -q 'git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig"' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the git name is no longer written with git"; return 1; }
|
||||
rm -rf "$ID_DIR"
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -639,6 +733,88 @@ write_config() {
|
||||
[[ "$output" == *"core.command=nvim"* ]]
|
||||
}
|
||||
|
||||
@test "the narrowing flags only ever narrow" {
|
||||
# Each of these turns something off for one launch and has no counterpart
|
||||
# that turns it on. A config saying off is a decision; a flag that could
|
||||
# undo it in passing would make the file's answer the weaker one.
|
||||
write_config '[logging]' 'loki_url = https://loki.example.com' 'conversation = on' \
|
||||
'[epitaph]' 'enabled = on' \
|
||||
'[audit]' 'strict = off'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[[ "$output" == *"logging.conversation=on"* ]] || { echo "$output"; return 1; }
|
||||
|
||||
run "$REPO_ROOT/bin/dejima" --no-conversation --print-config
|
||||
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
|
||||
# and only that: the DNS and firewall log still ships.
|
||||
[[ "$output" == *"logging.loki_url=https://loki.example.com"* ]] \
|
||||
|| { echo "--no-conversation took the whole log with it"; return 1; }
|
||||
|
||||
run "$REPO_ROOT/bin/dejima" --no-log --print-config
|
||||
grep -qx 'logging.loki_url=' <<< "$output" || { echo "$output"; return 1; }
|
||||
# --no-log implies --no-conversation: shipping the transcript needs an
|
||||
# endpoint, and leaving the key on while clearing the endpoint would read
|
||||
# as conversation logging still being in force.
|
||||
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
|
||||
|
||||
run "$REPO_ROOT/bin/dejima" --no-epitaph --print-config
|
||||
[[ "$output" == *"epitaph.enabled=off"* ]] || { echo "$output"; return 1; }
|
||||
|
||||
run "$REPO_ROOT/bin/dejima" --strict-audit --print-config
|
||||
[[ "$output" == *"audit.strict=on"* ]] || { echo "$output"; return 1; }
|
||||
|
||||
# None of them takes a value, so none can be turned back on by passing one.
|
||||
local f
|
||||
for f in --no-log --no-conversation --no-epitaph --strict-audit; do
|
||||
run "$REPO_ROOT/bin/dejima" "$f" on --print-config
|
||||
[ "$status" -ne 0 ] || { echo "$f accepted a value: $output"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "a harness-key flag sets the selected harness's key" {
|
||||
# --model is one flag over harnesses that spell the setting differently or
|
||||
# do not have it at all, so what it resolves to depends on the selection.
|
||||
write_config '[core]' 'harness = cline' \
|
||||
'[harness.cline]' 'model = from-the-file'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[[ "$output" == *"harness.cline.model=from-the-file"* ]] || { echo "$output"; return 1; }
|
||||
|
||||
run "$REPO_ROOT/bin/dejima" --model from-the-flag --base-url http://127.0.0.1:8080 \
|
||||
--context-window 60000 --print-config
|
||||
[[ "$output" == *"harness.cline.model=from-the-flag"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"harness.cline.base_url=http://127.0.0.1:8080"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"harness.cline.context_window=60000"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness-key flag is resolved after every flag, not where it appears" {
|
||||
# --harness may come after --model, so which harness the key belongs to is
|
||||
# not known until the whole line has been read. Resolving eagerly would
|
||||
# bind the flag to whatever the config happened to say.
|
||||
write_config '[core]' 'harness = claude-code'
|
||||
run "$REPO_ROOT/bin/dejima" --model chosen --harness cline --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"harness.cline.model=chosen"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness-key flag is refused for a harness without that key" {
|
||||
# Silently doing nothing is the failure worth avoiding: --model against a
|
||||
# harness with no model setting reads as having chosen one.
|
||||
write_config '[core]' 'harness = claude-code'
|
||||
run "$REPO_ROOT/bin/dejima" --model x --print-config
|
||||
[ "$status" -ne 0 ] || { echo "--model was accepted for claude-code"; return 1; }
|
||||
[[ "$output" == *"claude-code"* ]] || { echo "the refusal does not name the harness: $output"; return 1; }
|
||||
[[ "$output" == *"model"* ]] || { echo "the refusal does not name the key: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness-key flag names itself in a validation failure" {
|
||||
# _set_override reports the key by default, and --context-window's key is
|
||||
# context_window - an option nobody can type.
|
||||
write_config '[core]' 'harness = cline'
|
||||
run "$REPO_ROOT/bin/dejima" --context-window 5 --print-config
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"--context-window"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"--context_window"* ]] || { echo "reported the key, not the flag: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a flag value is validated against the same schema type" {
|
||||
write_config '[core]' 'firewall = on'
|
||||
run "$REPO_ROOT/bin/dejima" --cmd 'rm -rf /' --print-config
|
||||
@@ -1755,21 +1931,34 @@ _epitaph_fixture() {
|
||||
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$EPI_TR/$EPI_SLUG"
|
||||
# The transcripts directory reaches the tool through the harness's own
|
||||
# config key now, not as an argument: where transcripts live is the
|
||||
# harness's, and dejima-epitaph no longer knows the shape of it.
|
||||
# config key, not as an argument: where transcripts live is the harness's,
|
||||
# and dejima-epitaph no longer knows the shape of it. `list` needs the same
|
||||
# key to report whether the session behind an epitaph is still resumable.
|
||||
write_config '[epitaph]' "dir = $EPI_STORE" \
|
||||
'[harness.claude-code]' "projects_dir = $EPI_TR"
|
||||
}
|
||||
|
||||
# One compact_boundary carrying the metadata, then the summary itself.
|
||||
# One compact_boundary carrying the metadata, then the summary itself. The
|
||||
# session id matches the transcript's filename, as Claude Code's own do -
|
||||
# that pairing is what `list` resolves to decide a session is still there.
|
||||
_epitaph_transcript() {
|
||||
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
|
||||
local ts="${4:-2026-01-02T03:04:05.000Z}"
|
||||
{
|
||||
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
|
||||
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"s-1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"%s"}}\n' "$uid" "$text"
|
||||
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"%s","timestamp":"%s","message":{"content":"%s"}}\n' "$uid" "$1" "$ts" "$text"
|
||||
} > "$f"
|
||||
}
|
||||
|
||||
# Three epitaphs in a known order, so an index means something to assert on.
|
||||
_epitaph_three() {
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "the firewall drops IPv6" aaaa1111 2026-01-02T03:04:05.000Z
|
||||
_epitaph_transcript s2 "the DNS proxy rewrote resolv" bbbb2222 2026-02-03T04:05:06.000Z
|
||||
_epitaph_transcript s3 "worked out the audit baseline" cccc3333 2026-03-04T05:06:07.000Z
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
@test "an epitaph is written out of the container's reach" {
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "the thing this session worked out"
|
||||
@@ -1842,6 +2031,128 @@ _epitaph_transcript() {
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "list shows one row per epitaph, oldest first" {
|
||||
_epitaph_three
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||
# Chronological by the timestamp in the name, which is what makes an
|
||||
# index stable enough for `show <n>` to mean anything.
|
||||
grep -q '^ *1 2026-01-02' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -q '^ *3 2026-03-04' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "list says whether the session behind an epitaph is still resumable" {
|
||||
# Resuming the real session beats reading a summary of it, so the listing
|
||||
# has to say which of the two is still on offer.
|
||||
_epitaph_three
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||
[ "$(grep -c 'resumable' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||
rm -f "$EPI_TR/$EPI_SLUG/s2.jsonl"
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||
[ "$(grep -c 'resumable' <<< "$output")" -eq 2 ] || { echo "$output"; return 1; }
|
||||
grep -q '^ *2 .*aged out' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "reading the store is not gated on [epitaph] enabled" {
|
||||
# Turning epitaph off must not strand what it already kept: the operator
|
||||
# who switches it off is the one most likely to want to look at, and get
|
||||
# rid of, what is there.
|
||||
_epitaph_three
|
||||
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off' \
|
||||
'[harness.claude-code]' "projects_dir = $EPI_TR"
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||
# Writing still is.
|
||||
_epitaph_transcript s4 "should not be kept" dddd4444 2026-04-05T06:07:08.000Z
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
|
||||
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 3 ]
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "list says the resumable state is unknown rather than guessing it" {
|
||||
# Which sessions still have a transcript is the harness's answer. With no
|
||||
# harness there is nobody to ask, and "aged out" would be a claim this
|
||||
# cannot support - the transcripts may be sitting right there. The config
|
||||
# is written directly because write_config seeds a harness otherwise.
|
||||
_epitaph_three
|
||||
printf '[epitaph]\ndir = %s\n' "$EPI_STORE" > "$CONFIG_DIR/config"
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(grep -c 'resumable' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(grep -c 'aged out' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "search finds an epitaph by content and reports its listing index" {
|
||||
# The names are a timestamp and a uuid, so nothing about a filename says
|
||||
# which session it was. Search is the only way in.
|
||||
_epitaph_three
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" search "resolv" "$EPI_WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -q '1 of 3' <<< "$output" || { echo "$output"; return 1; }
|
||||
# The index it prints is the position in the same listing `list` shows,
|
||||
# so `show <n>` straight after a search opens what the search found.
|
||||
grep -qE '^ *2 20260203' <<< "$output" || { echo "$output"; return 1; }
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" show 2 "$EPI_WS"
|
||||
grep -q 'the DNS proxy rewrote resolv' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "search matching nothing says so without failing" {
|
||||
_epitaph_three
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" search "nothing-in-any-of-them" "$EPI_WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -q 'no epitaph matches' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "show writes to stdout when there is no terminal to open an editor on" {
|
||||
# `dejima-epitaph show | grep` must not try to launch an editor, and a
|
||||
# test is the same case as a pipe.
|
||||
_epitaph_three
|
||||
run env VISUAL=/bin/false EDITOR=/bin/false \
|
||||
"$REPO_ROOT/bin/dejima-epitaph" show 1 "$EPI_WS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -q 'the firewall drops IPv6' <<< "$output" || { echo "$output"; return 1; }
|
||||
# Newest by default.
|
||||
run env VISUAL=/bin/false "$REPO_ROOT/bin/dejima-epitaph" show "$EPI_WS"
|
||||
grep -q 'worked out the audit baseline' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "show opens the editor and leaves the epitaph at 0600" {
|
||||
command -v script >/dev/null 2>&1 || skip "no script(1) to allocate a pty"
|
||||
_epitaph_three
|
||||
local ed; ed="$(mktemp -d)"
|
||||
# Rewrites through a temp file and a rename, which is what an editor
|
||||
# does and what drops the mode to the umask's.
|
||||
printf '#!/bin/bash\nprintf "%%s\\n" "$1" > %s/got\nprintf edited > "$1.t" && mv "$1.t" "$1"\n' \
|
||||
"$ed" > "$ed/ed"
|
||||
chmod +x "$ed/ed"
|
||||
local f; f="$(find "$EPI_STORE" -name '*.md' | sort | head -1)"
|
||||
chmod 600 "$f"
|
||||
run env VISUAL="$ed/ed" EDITOR="$ed/ed" script -qec \
|
||||
"$REPO_ROOT/bin/dejima-epitaph show 1 $EPI_WS" /dev/null
|
||||
[ -f "$ed/got" ] || { echo "the editor was never run: $output"; return 1; }
|
||||
[ "$(cat "$ed/got")" = "$f" ] || { echo "opened $(cat "$ed/got"), wanted $f"; return 1; }
|
||||
[ "$(stat -c %a "$f")" = "600" ] \
|
||||
|| { echo "left at $(stat -c %a "$f") after the editor rewrote it"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS" "$ed"
|
||||
}
|
||||
|
||||
@test "an unknown epitaph subcommand is refused" {
|
||||
_epitaph_fixture
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" delete-everything "$EPI_WS"
|
||||
[ "$status" -eq 2 ] || { echo "$output"; return 1; }
|
||||
grep -q 'usage:' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "the epitaph store is not mounted into the container" {
|
||||
# The whole point: a session must not be able to read what earlier
|
||||
# sessions concluded, or edit what this one is about to record.
|
||||
@@ -2039,6 +2350,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.
|
||||
@@ -2165,6 +2496,53 @@ STUB
|
||||
rm -rf "$workspace" "$RW_HOME" "$root" "${docker_log:-}"
|
||||
}
|
||||
|
||||
@test "a forge credential never reaches the docker command line" {
|
||||
# /proc/<pid>/cmdline is world-readable, so a secret passed as
|
||||
# `-e NAME=value` is visible to every process on the host via ps - and
|
||||
# from there into whatever captures ps output. That is how #226 was
|
||||
# found: an operator ran ps while debugging something unrelated and
|
||||
# pasted the result.
|
||||
#
|
||||
# `-e NAME` takes the value from the launcher's own environment instead,
|
||||
# where it lives in /proc/<pid>/environ - mode 0400, owner only.
|
||||
#
|
||||
# This repository already made the argument one layer down: gitea-pr
|
||||
# feeds curl its auth header on stdin rather than as an argument, and
|
||||
# says why. The test is here so the two cannot drift apart again.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
local tokfile="$RW_HOME/tok" secret="tok-$RANDOM-do-not-leak"
|
||||
printf '%s\n' "$secret" > "$tokfile"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = gitea' 'workflow = local-only' \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
"token_file = $tokfile"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
local argv_log; argv_log="$(mktemp)"
|
||||
cat > "$STUB_DIR/docker" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >> "$SANDBOX_TEST_ARGV_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG=/dev/null SANDBOX_TEST_ARGV_LOG="$argv_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
local argv; argv="$(cat "$argv_log")"
|
||||
[ -n "$argv" ] || { echo "the stub captured no docker invocation"; return 1; }
|
||||
# The whole point.
|
||||
[[ "$argv" != *"$secret"* ]] \
|
||||
|| { echo "the token is in the docker command line"; return 1; }
|
||||
# And it is still passed, by name, or the forge simply has no credential.
|
||||
[[ "$argv" == *"-e GITEA_TOKEN"* ]] \
|
||||
|| { echo "GITEA_TOKEN is not passed at all: $argv"; return 1; }
|
||||
# Specifically the bare form. `-e GITEA_TOKEN=` would also match above.
|
||||
[[ "$argv" != *"-e GITEA_TOKEN="* ]] \
|
||||
|| { echo "passed as NAME=value after all: $argv"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR" "$argv_log"
|
||||
}
|
||||
|
||||
@test "the harness layer creates the harness's own state directories and environment" {
|
||||
# These were in the base image, imposed on every image dejima builds. They
|
||||
# are the selected harness's now, and have to actually reach its layer -
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+282
@@ -0,0 +1,282 @@
|
||||
#!/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 fcntl, os, signal, struct, sys, termios, tty
|
||||
fd = 0
|
||||
saved = termios.tcgetattr(fd)
|
||||
|
||||
def _size():
|
||||
return struct.unpack("HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\\0" * 8))[:2]
|
||||
|
||||
# Redraws only when the dimensions ACTUALLY moved, which is what both shipped
|
||||
# harnesses do. Measured on a pty, bytes written in a fixed window after each
|
||||
# kind of poke:
|
||||
#
|
||||
# idle same-size SIGWINCH size changed and back
|
||||
# Claude Code 0 0 3337
|
||||
# Cline 0 0 17810
|
||||
# nvim (control) 0 3356 6724
|
||||
#
|
||||
# The first version of this stand-in redrew on the bare signal, like nvim. That
|
||||
# is the one behaviour the programs dejima actually runs do not have, so the
|
||||
# suite could not see that the resume poke was a no-op for both of them.
|
||||
_last = [_size()]
|
||||
|
||||
def _on_winch(*a):
|
||||
now = _size()
|
||||
if now == _last[0]:
|
||||
return
|
||||
_last[0] = now
|
||||
sys.stdout.write("CONTAINER-REDRAW\\r\\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
signal.signal(signal.SIGWINCH, _on_winch)
|
||||
# The finding of #179, as something a test can observe: the client survives
|
||||
# SIGSTOP and does not survive SIGTSTP, so the launcher has to start it with
|
||||
# SIGTSTP already ignored. Read back rather than caught - installing a handler
|
||||
# would REPLACE the inherited SIG_IGN and defeat the very thing being checked,
|
||||
# which is what the first version of this did.
|
||||
_ign = 0
|
||||
for _line in open("/proc/self/status"):
|
||||
if _line.startswith("SigIgn"):
|
||||
_ign = int(_line.split()[1], 16)
|
||||
if _ign & (1 << (signal.SIGTSTP - 1)):
|
||||
sys.stdout.write("CONTAINER-TSTP-IGNORED\\r\\n")
|
||||
sys.stdout.flush()
|
||||
tty.setraw(fd)
|
||||
# What a full-screen harness does on startup, and does not undo on a suspend.
|
||||
sys.stdout.write("\\033[?25l")
|
||||
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
|
||||
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
|
||||
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
|
||||
# before the launcher stops itself. Without the conversion the client is the
|
||||
# one thing that must not be stopped by a catchable signal.
|
||||
open(LAUNCHER, "w").write('''#!/bin/bash
|
||||
sleep 300 &
|
||||
sleep 300 &
|
||||
%s
|
||||
trap '' TSTP
|
||||
python3 %s < /dev/tty &
|
||||
client=$!
|
||||
trap - TSTP
|
||||
on_tstp() {
|
||||
kill -STOP "$client" 2>/dev/null || true
|
||||
printf '\\033[?25h' > /dev/tty 2>/dev/null || true
|
||||
trap - TSTP
|
||||
kill -TSTP $$
|
||||
trap on_tstp TSTP
|
||||
kill -CONT "$client" 2>/dev/null || true
|
||||
}
|
||||
trap on_tstp TSTP
|
||||
while kill -0 "$client" 2>/dev/null; do
|
||||
wait "$client" && break
|
||||
case "$?" in 146|148) continue ;; esac
|
||||
break
|
||||
done
|
||||
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 = []
|
||||
transcript_all = ""
|
||||
|
||||
|
||||
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
|
||||
text = chunk.decode("utf-8", "replace")
|
||||
seen.append(text)
|
||||
global transcript_all
|
||||
transcript_all += text
|
||||
|
||||
|
||||
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:]))
|
||||
|
||||
# The finding, asserted: the client is stopped by SIGSTOP, which it cannot
|
||||
# observe, and never by SIGTSTP, which it cannot survive.
|
||||
# A suspended session must not leave the prompt with an invisible cursor:
|
||||
# the harness hid it and a suspend is not an exit, so somebody has to.
|
||||
check("the-cursor-is-shown-again-when-suspended",
|
||||
"\033[?25h" in transcript_all,
|
||||
"the prompt inherits a hidden cursor, like a dead ssh session")
|
||||
|
||||
check("the-client-was-started-with-sigtstp-ignored",
|
||||
"CONTAINER-TSTP-IGNORED" in transcript_all,
|
||||
"it would be stopped by the one signal it cannot survive")
|
||||
finally:
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), 9)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -112,6 +112,35 @@ dockerfile_for() {
|
||||
"$LAUNCH_DIR/dockerfile-log" 2>/dev/null
|
||||
}
|
||||
|
||||
@test "valid_domain refuses the hostname shapes a resolver would read differently" {
|
||||
# The matcher is a trust boundary: what it accepts becomes a dnsmasq
|
||||
# `server=` line and an ipset entry. The published bypass in this space
|
||||
# came from an egress control accepting a string its resolver then read as
|
||||
# a different host, so the shapes below are the ones worth asserting on
|
||||
# rather than only "does it reject evil.com".
|
||||
#
|
||||
# Run against this matcher during the 2026-08 prior-art sweep: every case
|
||||
# here was already refused except the empty label, which was not.
|
||||
local bad
|
||||
for bad in 'a..b.com' 'github.com.' '.github.com' '-bad.com' 'bad-.com' \
|
||||
'a.-b.com' 'evil.com%00.github.com' 'a_b.com' '' \
|
||||
'evil.com
|
||||
github.com'; do
|
||||
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$bad"
|
||||
[ "$status" -ne 0 ] || { echo "accepted: $(printf %q "$bad")"; return 1; }
|
||||
done
|
||||
|
||||
# And the shapes that must keep working, including the ones the tightening
|
||||
# could plausibly have broken: an IDNA label starts with a letter and
|
||||
# contains hyphens, and a single label with no dot is a real internal name.
|
||||
local good
|
||||
for good in 'crates.io' 'static.crates.io' 'xn--e1afmkfd.com' 'GitHub.com' \
|
||||
'localhost' 'a' 'llm-backend.example.com'; do
|
||||
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$good"
|
||||
[ "$status" -eq 0 ] || { echo "refused: $good"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "valid_domain accepts real domains and rejects shell-active input" {
|
||||
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -506,6 +535,85 @@ EOF
|
||||
[ ! -f "$CONFIG_DIR/dependencies" ]
|
||||
}
|
||||
|
||||
# Two running sandboxes, each with a working directory, plus a dejima-attach
|
||||
# that lists them in the order its picker numbers them.
|
||||
stub_two_containers() {
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
if [ "$1" = ps ]; then
|
||||
printf 'dejima-run-aaaa\taaaaaaaaaaaa\n'
|
||||
printf 'dejima-run-bbbb\tbbbbbbbbbbbb\n'
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = inspect ]; then
|
||||
case "$*" in
|
||||
*aaaaaaaaaaaa*) echo /home/someone/workspace/alpha ;;
|
||||
*bbbbbbbbbbbb*) echo /home/someone/workspace/beta ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
cat > "$STUB_DIR/dejima-attach" <<'EOF'
|
||||
#!/bin/bash
|
||||
[ "$1" = --list ] || exit 1
|
||||
printf 'run1\tlabel-b\tbeta\t3m\tattachable\tbbbbbbbbbbbb\n'
|
||||
printf 'run2\tlabel-a\talpha\t9m\tattachable\taaaaaaaaaaaa\n'
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/dejima-attach"
|
||||
PATH="$STUB_DIR:$PATH"
|
||||
}
|
||||
|
||||
@test "several running sandboxes are listed by project, not by run id alone" {
|
||||
# The name is a run id, which is exactly what someone with three sessions
|
||||
# open does not know. What they do know is which project each is working
|
||||
# on. Taken from the container's own working directory, so it is there
|
||||
# whether or not remote access is on.
|
||||
stub_two_containers
|
||||
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Multiple running dejima containers"* ]] || { echo "$output"; return 1; }
|
||||
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE 'dejima-run-bbbb +beta' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "the listing carries the number the dejima-attach picker gives each one" {
|
||||
# The picker numbers its rows 1..N in the order --list prints them, so a
|
||||
# position there is the number an operator would type. Deliberately not
|
||||
# this tool's own ordering: two lists that number the same sessions
|
||||
# differently is worse than one list without numbers.
|
||||
stub_two_containers
|
||||
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||
# beta is first in the attach listing, so beta is 1 - not alpha, which
|
||||
# docker happens to report first.
|
||||
grep -qE 'beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
|
||||
grep -qE 'alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "the listing drops the picker number rather than failing without one" {
|
||||
# dejima-attach refuses outright when remote access is off. That is a
|
||||
# column missing, not an error: the project name is the part that makes
|
||||
# this usable and it does not depend on the feature.
|
||||
stub_two_containers
|
||||
cat > "$STUB_DIR/dejima-attach" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "Remote access is not configured." >&2
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/dejima-attach"
|
||||
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||
[ "$status" -ne 0 ]
|
||||
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"dejima-attach 1"* ]] || { echo "invented a number: $output"; return 1; }
|
||||
[[ "$output" != *"Remote access is not configured"* ]] \
|
||||
|| { echo "leaked the attach refusal: $output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow --project persists without needing a container" {
|
||||
run "$REPO_ROOT/bin/dejima-allow" --project "$PROJ" crates.io
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
+279
-7
@@ -5,6 +5,7 @@
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
LIB_PROJECT="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
CONFIG_DIR="$(mktemp -d)"
|
||||
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
||||
}
|
||||
@@ -482,9 +483,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 +513,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 +528,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 ]
|
||||
@@ -617,7 +623,7 @@ assert_remedy_forges_exist() {
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
|
||||
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
|
||||
[ "$(lib manifest_get workflow branch-push block_merge)" = "off" ]
|
||||
[ "$(lib manifest_get workflow branch-push block_merge)" = "on" ]
|
||||
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
|
||||
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
|
||||
# Requires no capability, so it is usable against a forge that offers
|
||||
@@ -765,11 +771,51 @@ assert_remedy_forges_exist() {
|
||||
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "no shipped workflow tells the agent not to merge without blocking it" {
|
||||
# Every shipped briefing says some version of "do not merge". Two of them
|
||||
# said it with block_merge off, which made the sentence an assertion
|
||||
# nothing held: the forge's CLI is installed by the FORGE, so `<pr> merge`
|
||||
# is on PATH whenever a forge is selected, whatever the workflow tells the
|
||||
# agent to do. The manifest that was off carried a reason - "nothing here
|
||||
# ever reaches a forge's PR interface" - and the reason was false.
|
||||
#
|
||||
# Asserted as an invariant over every workflow rather than as three pinned
|
||||
# values, because a fourth workflow gets this wrong the same way. A
|
||||
# workflow that genuinely means to permit merging has to say so in its
|
||||
# briefing and change this test on purpose.
|
||||
local wf name
|
||||
for wf in "$REPO_ROOT"/workflows/*/; do
|
||||
[ -d "$wf" ] || continue
|
||||
name="$(basename "$wf")"
|
||||
[ "$(lib manifest_get workflow "$name" block_merge)" = "on" ] \
|
||||
|| { echo "$name leaves merging unguarded"; return 1; }
|
||||
grep -qi 'merg' "$wf/briefing.md" \
|
||||
|| { echo "$name's briefing says nothing about merging"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "a workflow blocking merges reaches the guard with the forge's command" {
|
||||
# block_merge is only half of it: what actually refuses the command is
|
||||
# DEJIMA_MERGE_BLOCK, resolved from the FORGE's merge_block and passed to
|
||||
# docker/forge-guard.sh. A workflow with the flag on and a forge that
|
||||
# names no command blocks nothing, which is correct - there is nothing to
|
||||
# block - but the pairing is what has to hold.
|
||||
local wf
|
||||
for wf in branch-push local-only worktree-pr; do
|
||||
[ "$(lib manifest_get workflow "$wf" block_merge)" = "on" ] || continue
|
||||
[ -n "$(lib manifest_get forge gitea merge_block)" ] \
|
||||
|| { echo "gitea names no merge command for $wf to block"; return 1; }
|
||||
done
|
||||
grep -q 'DEJIMA_MERGE_BLOCK="$(manifest_get forge "$DEJIMA_FORGE_NAME" merge_block)"' \
|
||||
"$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the launcher no longer resolves the forge's merge command"; return 1; }
|
||||
}
|
||||
|
||||
@test "local-only pushes nothing and blocks no branches" {
|
||||
run lib manifest_validate workflow local-only
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(lib manifest_get workflow local-only push)" = "off" ]
|
||||
[ "$(lib manifest_get workflow local-only block_merge)" = "off" ]
|
||||
[ "$(lib manifest_get workflow local-only block_merge)" = "on" ]
|
||||
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
|
||||
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
|
||||
# blocked_branches is empty BECAUSE push is off - there is no push to
|
||||
@@ -784,6 +830,144 @@ assert_remedy_forges_exist() {
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
# --- optional tools ----------------------------------------------------------
|
||||
|
||||
@test "every shipped tool is pinned by digest, not fetched from a moving ref" {
|
||||
# The bar docs/tools.md sets, asserted rather than described. A tool that
|
||||
# installs from whatever a branch points at today is one nothing has
|
||||
# reviewed, and it would build differently on different days with no
|
||||
# change to this repository.
|
||||
local tool spec
|
||||
for tool in $(lib provider_list tool); do
|
||||
run lib manifest_validate tool "$tool"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
spec="$(lib manifest_get tool "$tool" install)"
|
||||
[[ "$spec" == tarball:* ]] \
|
||||
|| { echo "$tool does not install from a pinned artifact: $spec"; return 1; }
|
||||
# valid_tarball_spec already requires a 64-hex digest and an https
|
||||
# URL; this is the part it cannot check - that the URL names a
|
||||
# version rather than a branch.
|
||||
[[ "$spec" != *"/refs/heads/"* && "$spec" != *"/master/"* && "$spec" != *"/main/"* ]] \
|
||||
|| { echo "$tool installs from a moving ref: $spec"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "a tool cannot declare anything that changes what dejima permits" {
|
||||
# The manifest has no key for a hook, a mount, a domain or a guard. A tool
|
||||
# that needed one would be a change to the boundary rather than a program
|
||||
# on PATH, and adding the key is the moment to argue about it - not the
|
||||
# moment a manifest quietly starts carrying one.
|
||||
local key
|
||||
for key in guards hooks mounts api_domains fixed_hosts install_hook \
|
||||
briefing no_confirm_args settings_args; do
|
||||
run lib manifest_get tool rtk "$key"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the tool schema accepts '$key', which it must not"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "rtk declares the archive shape its release actually has" {
|
||||
# Inspected: `tar tzf rtk-x86_64-unknown-linux-musl.tar.gz` lists one
|
||||
# entry, `rtk`, with no enclosing directory. Declared `prefix`, the layer
|
||||
# would strip that single component, extract nothing, and build cleanly -
|
||||
# a tool the session is told it has and does not.
|
||||
#
|
||||
# Pinned here because the emitter test below exercises both shapes with an
|
||||
# explicit setting and so cannot notice this manifest changing.
|
||||
[ "$(lib manifest_get tool rtk install_layout)" = "binary" ] \
|
||||
|| { echo "rtk's release is a bare binary and the manifest no longer says so"; return 1; }
|
||||
}
|
||||
|
||||
@test "a tool may only set environment variables that are its own" {
|
||||
# image_env becomes a Dockerfile ENV, which every process in the container
|
||||
# inherits - not just the tool that declared it. rtk shipped with
|
||||
# NO_COLOR=1, reasoning that its output lands in a model's context where an
|
||||
# escape sequence is noise. The reasoning was fine and the scope was wrong:
|
||||
# a launch with the tool enabled lost colour in the harness, in git, in ls,
|
||||
# in everything.
|
||||
#
|
||||
# A harness may set a bare name there, because a harness IS the container's
|
||||
# main process. A tool is one program among many.
|
||||
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/toolenv.XXXXXX")"
|
||||
mkdir -p "$d/tools/probe"
|
||||
local sha; sha="$(printf 'a%.0s' {1..64})"
|
||||
local bad
|
||||
for bad in 'NO_COLOR=1' 'DO_NOT_TRACK=1' 'PATH=/x' 'PROBEISH_X=1'; do
|
||||
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = %s\n' \
|
||||
"$sha" "$bad" > "$d/tools/probe/tool.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; tool_validate probe' _ "$LIB"
|
||||
[ "$status" -ne 0 ] || { echo "accepted image_env $bad"; return 1; }
|
||||
[[ "$output" == *"not its own"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
|
||||
# Namespaced to the tool, which is what a tool's own switch looks like.
|
||||
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = PROBE_TELEMETRY=0 PROBE_QUIET=1\n' \
|
||||
"$sha" > "$d/tools/probe/tool.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" bash -c 'source "$1"; tool_validate probe' _ "$LIB"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "no shipped tool takes the colour out of the rest of the container" {
|
||||
# The specific regression, pinned by name as well as by the rule above:
|
||||
# these are the bare names most likely to look like a tool's own business
|
||||
# and reach everything.
|
||||
local tool pair
|
||||
for tool in $(lib provider_list tool); do
|
||||
run lib tool_validate "$tool"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
for pair in $(lib manifest_get tool "$tool" image_env); do
|
||||
case "${pair%%=*}" in
|
||||
NO_COLOR|TERM|CLICOLOR|CLICOLOR_FORCE|FORCE_COLOR)
|
||||
echo "$tool sets ${pair%%=*} for the whole container"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
@test "rtk is declared with its telemetry switched off in the image" {@test "rtk is declared with its telemetry switched off in the image" {
|
||||
# Two independent offs, because either one can be wrong: the tool's own
|
||||
# switch here, and the firewall, which does not allowlist the endpoint.
|
||||
[[ "$(lib manifest_get tool rtk image_env)" == *"RTK_TELEMETRY_DISABLED=1"* ]] \
|
||||
|| { echo "rtk's telemetry switch is no longer set"; return 1; }
|
||||
# And the endpoint stays out of what core allowlists.
|
||||
run grep -rn 'telemetry.rtk-ai' "$REPO_ROOT/docker" "$REPO_ROOT/bin"
|
||||
[ "$status" -ne 0 ] || { echo "the telemetry endpoint reached the allowlist:"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a tool's archive shape decides how it is unpacked" {
|
||||
# A bare-binary archive stripped of a component extracts NOTHING, and does
|
||||
# it silently: the layer builds, and the tool is not in it.
|
||||
local spec
|
||||
spec="tarball:x@$(printf 'a%.0s' {1..64}):https://e.example/x.tgz"
|
||||
run env DEJIMA_TARBALL_STRIP=binary bash -c \
|
||||
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
|
||||
[[ "$output" == *"tar -C /opt/dejima/x/bin -xaf"* ]] \
|
||||
|| { echo "binary layout still strips a component: $output"; return 1; }
|
||||
run env DEJIMA_TARBALL_STRIP=prefix bash -c \
|
||||
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
|
||||
[[ "$output" == *"--strip-components=1"* ]] \
|
||||
|| { echo "prefix layout stopped stripping: $output"; return 1; }
|
||||
# Both verify before they unpack. A tar extracted first and checked after
|
||||
# has already written whatever it contained.
|
||||
[[ "$output" == *"sha256sum -c -"*"tar -C"* ]] \
|
||||
|| { echo "the digest is no longer checked before unpacking"; return 1; }
|
||||
}
|
||||
|
||||
@test "the briefing names the tools a launch enabled, and says nothing when there are none" {
|
||||
run env DEJIMA_TOOLS=rtk bash -c \
|
||||
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"Extra tools this session has"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"rtk git status"* ]] \
|
||||
|| { echo "the tool's own instructions did not reach the briefing"; return 1; }
|
||||
|
||||
run env DEJIMA_TOOLS= bash -c \
|
||||
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
|
||||
[[ "$output" != *"Extra tools this session has"* ]] \
|
||||
|| { echo "a heading with nothing under it"; return 1; }
|
||||
}
|
||||
|
||||
@test "the github forge pushes with its own token, not the read-only one" {
|
||||
run lib manifest_validate forge github
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
@@ -794,8 +978,96 @@ 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.
|
||||
# system:gh AND the one CLI this forge ships. install-forge-bins.sh fails
|
||||
# the build in both directions - a declared file that is missing, and a
|
||||
# file under bin/ that no manifest declares - so this is the declaration
|
||||
# an operator reads to know what lands in the image.
|
||||
[ "$(lib manifest_get forge github install)" = "system:gh bin/github-pr" ]
|
||||
}
|
||||
|
||||
@test "each forge declares which of its keys belong to a person" {
|
||||
# The credential paths, which a project may not declare and an identity
|
||||
# may set. One declaration drives both rules, so they cannot disagree
|
||||
# about which key is which.
|
||||
[ "$(lib manifest_get forge gitea identity_keys)" = "token_file" ]
|
||||
[ "$(lib manifest_get forge github identity_keys)" = "push_token_file" ]
|
||||
[ -z "$(lib manifest_get forge none identity_keys)" ]
|
||||
# And every one of them is a key that forge actually has, or the identity
|
||||
# would offer a setting nothing reads.
|
||||
local forge key spec found
|
||||
for forge in $(lib provider_list forge); do
|
||||
for key in $(lib manifest_get forge "$forge" identity_keys); do
|
||||
found=0
|
||||
for spec in $(lib manifest_get forge "$forge" keys); do
|
||||
[ "${spec%%:*}" = "$key" ] && found=1
|
||||
done
|
||||
[ "$found" -eq 1 ] \
|
||||
|| { echo "$forge names identity key '$key' and has no such key"; return 1; }
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
@test "no two forges claim the same identity key name" {
|
||||
# An identity's keys are unqualified, so one name resolving to two forges'
|
||||
# types would make the validation depend on which forge was read first.
|
||||
# Refused here rather than guessed at in _identity_key_specs.
|
||||
local forge key seen=""
|
||||
for forge in $(lib provider_list forge); do
|
||||
for key in $(lib manifest_get forge "$forge" identity_keys); do
|
||||
case " $seen " in
|
||||
*" $key "*) echo "two forges declare identity key '$key'"; return 1 ;;
|
||||
esac
|
||||
seen="$seen $key"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
@test "the identity key set is core's two plus whatever the forges declare" {
|
||||
# ssh_key and the git name are core's, because every forge needs the same
|
||||
# answer to "who is pushing". The credentials are not, and are typed by
|
||||
# the forge that owns them rather than by a second copy here.
|
||||
local specs
|
||||
specs="$(lib _identity_key_specs)"
|
||||
grep -qP '^ssh_key\t\?path$' <<< "$specs" || { echo "$specs"; return 1; }
|
||||
grep -qP '^user_name\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
|
||||
grep -qP '^user_email\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
|
||||
grep -qP '^token_file\t\?path$' <<< "$specs" \
|
||||
|| { echo "token_file did not inherit gitea's type: $specs"; return 1; }
|
||||
}
|
||||
|
||||
@test "the github forge declares how its CLI can be aimed at another repo" {
|
||||
# gh takes --repo on nearly every subcommand and reads GH_REPO, so the
|
||||
# ordinary path can act on a repository the session has nothing to do
|
||||
# with (#187). The guard is generic; the manifest is what makes it fire.
|
||||
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
|
||||
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
|
||||
# The gitea CLIs resolve the repo from origin and refuse another host, so
|
||||
# there is nothing to declare and nothing for the guard to compare. A
|
||||
# forge is not required to be aimable.
|
||||
[ -z "$(lib manifest_get forge gitea repo_flags)" ]
|
||||
[ -z "$(lib manifest_get forge gitea repo_env)" ]
|
||||
}
|
||||
|
||||
@test "a repo flag has to look like a flag" {
|
||||
# It is compared against argv. A bare word would be indistinguishable from
|
||||
# a subcommand or a value, and would refuse commands at random.
|
||||
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/flagtype.XXXXXX")"
|
||||
mkdir -p "$d/forges/probe"
|
||||
for bad in "repo" "--" "-" "--repo=x"; do
|
||||
printf 'repo_flags = %s\n' "$bad" > "$d/forges/probe/forge.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
|
||||
[ "$status" -ne 0 ] || { echo "accepted repo_flags = $bad"; return 1; }
|
||||
done
|
||||
# Space-separated, like every other manifest list.
|
||||
printf 'repo_flags = --repo -R\n' > "$d/forges/probe/forge.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the briefing does not call GitHub read-only when the forge can push to it" {
|
||||
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/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}"
|
||||
# Guarded, because everything below is relative to it: a cd that failed
|
||||
# silently would have this reporting on some other directory's suites and
|
||||
# diff, which for a release gate is the worst way to be wrong.
|
||||
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
|
||||
|
||||
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"; }
|
||||
|
||||
# Not a checklist to complete: an inventory of what nothing has checked. The
|
||||
# gate for a release here is the security review plus the maintainer running
|
||||
# the build - see docs/release.md. Printed anyway, because a release whose
|
||||
# unverified paths are written down is one somebody can debug later.
|
||||
note "Nothing has verified these, and this release touches them"
|
||||
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 "Required before this merges"
|
||||
echo " [ ] Security review of the whole $base...$head diff, against this"
|
||||
echo " project's own invariants - see docs/release.md. This is the gate."
|
||||
echo " [ ] Documentation review: every changed behaviour has its doc changed,"
|
||||
echo " and no doc describes something that no longer exists."
|
||||
echo " [ ] The maintainer runs the build: normal usage for a while, and a"
|
||||
echo " launch on the other configured harness to confirm it starts."
|
||||
|
||||
# A config key added in a release makes the PREVIOUS main refuse to start once
|
||||
# an operator has adopted it, because an unknown key fails validation. That
|
||||
# turns the obvious rollback into a second outage, so it is worth knowing
|
||||
# before rather than after.
|
||||
note "Config keys this release adds (a rollback refuses a config using them)"
|
||||
added="$(comm -13 \
|
||||
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
|
||||
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
|
||||
# Providers too, and this is the sharper half: a [harness.<name>] section for
|
||||
# a harness the older dejima does not ship is an unknown SECTION, which fails
|
||||
# validation exactly as an unknown key does. So adopting a new provider is
|
||||
# itself the thing that makes the rollback refuse.
|
||||
# The singular is spelled out rather than derived: "harnesses" does not lose
|
||||
# an s to become "harness", and a manifest is named for the singular.
|
||||
new_providers=""
|
||||
for pair in harnesses:harness forges:forge workflows:workflow; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
|
||||
new_providers="$new_providers $one.$name"
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done
|
||||
|
||||
if [ -n "$added" ] || [ -n "$new_providers" ]; then
|
||||
[ -z "$added" ] || printf ' %s\n' $added
|
||||
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
|
||||
else
|
||||
echo " (none - a rollback accepts any config this release accepts)"
|
||||
fi
|
||||
|
||||
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"
|
||||
+76
-4
@@ -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,8 @@ 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"
|
||||
[ -z "${5:-}" ] || printf '%s\n' -t "buttonColors=$5"
|
||||
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
|
||||
}
|
||||
# Sentinel on both sides: both are command substitutions, which strip trailing
|
||||
@@ -2226,6 +2233,70 @@ PY
|
||||
assert_ttyd_argv 12345 20 C-Space
|
||||
}
|
||||
|
||||
@test "ttyd_button_colors reaches ttyd, and unset sends nothing so the page decides" {
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
|
||||
# Unset is not "off": the page colours the toolbar unless told not to, and
|
||||
# a host-wide "off" written here would be a different thing from silence.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
|
||||
[ "$output" = "0" ]
|
||||
|
||||
local value
|
||||
for value in on off; do
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' "ttyd_button_colors = $value"
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345 '' '' '' "$value"
|
||||
done
|
||||
|
||||
# Alongside every other -t knob, since they share one splice.
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20' \
|
||||
'ttyd_tmux_prefix = C-a' 'ttyd_focus_on_tap = on' 'ttyd_button_colors = off'
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345 20 C-a on off
|
||||
}
|
||||
|
||||
@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 +2917,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 ttyd_button_colors'
|
||||
for key in $keys; do has -F "\`$key\`"; done
|
||||
|
||||
# The port default, taken from the library so the two cannot drift.
|
||||
|
||||
+125
-1
@@ -37,6 +37,18 @@ bats_require_minimum_version 1.5.0
|
||||
# The `dejima-test-` prefix is this suite's own namespace. Nothing else may use
|
||||
# it, which is what makes the reclaim in setup_file safe.
|
||||
|
||||
# The cap_add list from the compose file, one per line. A minimal reader for
|
||||
# one known block rather than a YAML parser: the alternative is this suite
|
||||
# hardcoding a second copy of the capability floor and the two disagreeing
|
||||
# silently, which is exactly the failure the floor exists to prevent.
|
||||
compose_cap_add() {
|
||||
awk '
|
||||
/^ cap_add:/ { in_block = 1; next }
|
||||
in_block && /^ - / { sub(/^ - /, ""); print; next }
|
||||
in_block && !/^ / { in_block = 0 }
|
||||
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
|
||||
}
|
||||
|
||||
IMAGE=dejima:latest
|
||||
|
||||
# What a session actually runs: the base image plus the selected harness's own
|
||||
@@ -330,8 +342,18 @@ setup_file() {
|
||||
# container started without these gets none of them. Read from the
|
||||
# manifest rather than written here, so this fixture cannot drift from
|
||||
# what a real launch supplies.
|
||||
# The capability posture from docker-compose.yml rather than a copy of it.
|
||||
# This container is started with `docker run`, so a hand-written --cap-add
|
||||
# list here would drift from the compose file the moment either changed -
|
||||
# and the whole point of the floor is that the container a session actually
|
||||
# gets is the one under test.
|
||||
local -a caps=()
|
||||
while IFS= read -r cap; do caps+=(--cap-add="$cap"); done < <(compose_cap_add)
|
||||
[ "${#caps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
|
||||
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--cap-drop=ALL "${caps[@]}" \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--network "$network" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
@@ -525,6 +547,25 @@ dexec_node() {
|
||||
[[ "$output" == *"policy DROP"* ]]
|
||||
}
|
||||
|
||||
@test "the IPv6 block is a filtered family, not an absent one" {
|
||||
# The DROP policy above is the good path. This asserts the container is
|
||||
# actually ON that path rather than on the fallback, because the two are
|
||||
# indistinguishable from the outside: a container with no IPv6 at all also
|
||||
# reaches nothing over IPv6.
|
||||
#
|
||||
# If ip6tables ever stops working as root here, the test above fails and
|
||||
# this one says why - and on a host that takes the fallback branch,
|
||||
# init-firewall.sh now either turns the family off or refuses to start,
|
||||
# rather than logging a conclusion it had not checked.
|
||||
run dexec sh -c 'ip6tables -L >/dev/null 2>&1 && echo usable'
|
||||
[[ "$output" == *usable* ]] \
|
||||
|| { echo "ip6tables is not usable as root; the fallback branch is live"; return 1; }
|
||||
# And the fallback's own condition is readable, so a failure here can be
|
||||
# diagnosed without a second run: what does the kernel say is up?
|
||||
run dexec sh -c 'cat /proc/net/if_inet6 2>/dev/null || echo "(no /proc/net/if_inet6)"'
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "a non-allowlisted domain is refused by the DNS proxy, not just left to fail later" {
|
||||
run dexec dig definitely-not-on-the-allowlist.example.org
|
||||
[[ "$output" == *"status: REFUSED"* ]]
|
||||
@@ -1034,6 +1075,69 @@ dexec_node() {
|
||||
run ! dexec_node sudo -n true
|
||||
}
|
||||
|
||||
@test "the container runs with no path back up to more privilege" {
|
||||
# no-new-privileges in docker-compose.yml. gosu drops privilege rather
|
||||
# than gaining it, so this does not interfere with the session starting -
|
||||
# what it forbids is a setuid binary or a file capability raising
|
||||
# privileges on exec, which is the shape any escalation from the session
|
||||
# user would have to take.
|
||||
#
|
||||
# Asserted on PID 1 and on the session user, because the flag is a property
|
||||
# of the process tree and both halves of it matter.
|
||||
run dexec cat /proc/1/status
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *$'NoNewPrivs:\t1'* ]] \
|
||||
|| { echo "PID 1 can still gain privileges on exec"; return 1; }
|
||||
run dexec_node sh -c 'grep NoNewPrivs /proc/self/status'
|
||||
[[ "$output" == *"1"* ]] || { echo "the session user's shell can: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the capability bounding set is the declared floor and nothing more" {
|
||||
# cap_drop: ALL plus an explicit list, rather than Docker's default set
|
||||
# plus two. The entrypoint uses six capabilities during its root phase and
|
||||
# the default carries about fourteen; this asserts the difference is real
|
||||
# in the running container rather than only present in the compose file.
|
||||
#
|
||||
# CapBnd, not CapEff: the effective set is zero for the session user
|
||||
# already (the test below), and the bounding set is what says the rest was
|
||||
# never available to regain.
|
||||
run dexec cat /proc/1/status
|
||||
[ "$status" -eq 0 ]
|
||||
local bnd; bnd=$(echo "$output" | awk '/^CapBnd:/ {print $2}')
|
||||
[ -n "$bnd" ] || { echo "no CapBnd in /proc/1/status"; return 1; }
|
||||
|
||||
# Decoded by name so a failure says which capability, not which bit.
|
||||
local have; have="$(dexec capsh --decode="$bnd" 2>/dev/null)" || have=""
|
||||
[ -n "$have" ] || skip "capsh not available in the image to decode $bnd"
|
||||
local cap
|
||||
for cap in $(compose_cap_add); do
|
||||
[[ "$have" == *"cap_$(echo "$cap" | tr '[:upper:]' '[:lower:]')"* ]] \
|
||||
|| { echo "declared $cap is missing from the bounding set: $have"; return 1; }
|
||||
done
|
||||
# Not KILL: `dejima-allow --container` signals dnsmasq, which runs as its
|
||||
# own user, so root here needs it. That absence is what failed the first
|
||||
# privileged run of this change.
|
||||
for cap in mknod setfcap setpcap sys_chroot audit_write net_bind_service sys_admin fsetid; do
|
||||
[[ "$have" != *"cap_$cap"* ]] \
|
||||
|| { echo "cap_$cap is in the bounding set and nothing declares it: $have"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "root can still signal dnsmasq, which is what adding a domain mid-session needs" {
|
||||
# Named for the thing rather than left to be caught by the ipset tests,
|
||||
# which is how it WAS caught: the first version of the capability floor
|
||||
# dropped KILL, and `dejima-allow --container` failed with "Operation not
|
||||
# permitted" because dnsmasq runs as its own user (`user=dnsmasq`) and root
|
||||
# cannot signal another user's process without CAP_KILL.
|
||||
#
|
||||
# Adding a domain to a running session is a documented feature that every
|
||||
# session briefing tells the agent to ask for, so this asserts the
|
||||
# capability by exercising what needs it.
|
||||
run dexec sh -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "root cannot signal dnsmasq - is KILL in the floor? $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the real running node process has NET_ADMIN/NET_RAW stripped, not just the bounding set" {
|
||||
run dexec cat /proc/1/status
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -1086,6 +1190,26 @@ EOF'
|
||||
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
|
||||
}
|
||||
|
||||
@test "a global package imports as ESM too, from a directory of its own" {
|
||||
# The test above proves the browser works, in the module system that
|
||||
# already resolved. NODE_PATH covers require() and nothing else: ESM walks
|
||||
# up from the importing file, so the modern form failed everywhere while
|
||||
# this suite stayed green. What an agent got was ERR_MODULE_NOT_FOUND,
|
||||
# which reads as an image without a browser.
|
||||
#
|
||||
# Written somewhere with no node_modules of its own, because anywhere
|
||||
# inside a project with one would resolve for the wrong reason.
|
||||
dexec_node bash -c 'mkdir -p /tmp/esm-probe && cat > /tmp/esm-probe/probe.mjs <<'"'"'EOF'"'"'
|
||||
import { chromium } from "playwright";
|
||||
const browser = await chromium.launch({});
|
||||
await browser.close();
|
||||
console.log("ESM_IMPORT_OK");
|
||||
EOF'
|
||||
run dexec_node node /tmp/esm-probe/probe.mjs
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"ESM_IMPORT_OK"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
|
||||
# The whole point of pre-baking the browser at build time is that a
|
||||
# session never needs new network access for it - confirm the egress
|
||||
|
||||
+23
-1
@@ -3,7 +3,7 @@
|
||||
# and by test/static.bats, so the two cannot disagree about what is covered.
|
||||
#
|
||||
# The list is discovered, not enumerated: a script added under bin/, hooks/,
|
||||
# docker/ or a forge's bin/ is covered the day it lands, which is the only
|
||||
# docker/, test/ or a forge's bin/ is covered the day it lands, which is the only
|
||||
# version of this that stays true. Discovery is by shebang rather than by
|
||||
# extension, because most of this repo's scripts have no extension.
|
||||
#
|
||||
@@ -19,6 +19,12 @@ list_scripts() {
|
||||
ls bin/dejima bin/dejima-* bin/harness/*.sh 2>/dev/null || true
|
||||
ls hooks/*.sh docker/*.sh install.sh 2>/dev/null || true
|
||||
ls forges/*/bin/* 2>/dev/null || true
|
||||
# test/, for the reason .dejima.md gives for being careful what goes
|
||||
# there: CI checks the commit out on a host and runs it, so a helper
|
||||
# in a test file is code executing outside the sandbox. That makes
|
||||
# these the last scripts that should go unchecked, not the first -
|
||||
# and leaving them out hid a real fault in the release gate itself.
|
||||
ls test/*.sh 2>/dev/null || true
|
||||
} | sort -u | while read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
# Harness plugins are sourced, not executed, so they carry no shebang
|
||||
@@ -40,6 +46,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.
|
||||
|
||||
+1239
-30
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
`rtk` is available, and reading less of a command's output is usually free:
|
||||
it runs the real command and prints a compressed form of what came back.
|
||||
Prefix a command with it where the full output is not the point.
|
||||
|
||||
rtk git status rtk git diff rtk git log -n 20
|
||||
rtk ls . rtk find "*.py" . rtk grep "pattern" .
|
||||
rtk cargo test rtk pytest rtk go test
|
||||
|
||||
It runs the real binary through PATH, so every guard that applies to a
|
||||
command applies to it wrapped in rtk too. `rtk proxy <cmd>` runs something
|
||||
unfiltered when you need the whole output, and `rtk read <file>` is a
|
||||
structure-first view of a file rather than the whole body.
|
||||
|
||||
Use judgement rather than wrapping everything: a test run you are about to
|
||||
debug line by line, or a diff you are about to apply, is output you want in
|
||||
full.
|
||||
@@ -0,0 +1,43 @@
|
||||
# rtk - a CLI proxy that compresses command output before an agent reads it.
|
||||
# https://github.com/rtk-ai/rtk, Apache-2.0, a single static Rust binary.
|
||||
#
|
||||
# The first optional tool, and the shape every later one has to follow: dejima
|
||||
# does not install what a project asks for, it installs what it has hardened.
|
||||
# See docs/tools.md for what that means and what was checked here.
|
||||
#
|
||||
# What it is NOT: rtk makes no model calls of its own. It is a deterministic
|
||||
# output filter - `rtk git status` runs git and prints less of the result - so
|
||||
# nothing here sends code anywhere.
|
||||
description = compresses command output before it reaches the model
|
||||
|
||||
# The published release, pinned by digest. Deliberately not the project's
|
||||
# `curl | sh` installer, which fetches whatever master points at today: an
|
||||
# image build that produces a different binary each time is one nothing has
|
||||
# reviewed. The digest was taken from the artifact below and checked against a
|
||||
# second download.
|
||||
#
|
||||
# x86_64 only, which is issue #97's limitation and not this tool's: the
|
||||
# tarball spec names one architecture and an arm64 host cannot use this entry.
|
||||
install = tarball:rtk@c4c036fbf181fc55ef329786c8c17e0d427972b053b825944d968a6aafef1ba4:https://github.com/rtk-ai/rtk/releases/download/v0.45.0/rtk-x86_64-unknown-linux-musl.tar.gz
|
||||
|
||||
# The archive is one bare executable with no enclosing directory, where a Go
|
||||
# or nvim release has everything under one. Stripping a component would strip
|
||||
# the binary itself and extract nothing.
|
||||
install_layout = binary
|
||||
|
||||
# rtk carries telemetry the README does not mention: an https ping compiled
|
||||
# into the release binary, gated on a consent flag that defaults to unset.
|
||||
# Disabled here as well, and the firewall does not allowlist the endpoint, so
|
||||
# it is off three ways. Verified in the shipped artifact's strings, not only
|
||||
# in the source - see docs/tools.md.
|
||||
#
|
||||
# This carried NO_COLOR=1 as well, reasoning that rtk's output lands in a
|
||||
# model's context where an escape sequence is noise. The reasoning was fine
|
||||
# and the mechanism was wrong: a tool's image_env becomes a Dockerfile ENV,
|
||||
# which every process in the container inherits, so it took the colour out of
|
||||
# the harness, git, ls and everything else. A tool may only set variables that
|
||||
# are its own, and core refuses one that is not.
|
||||
image_env = RTK_TELEMETRY_DISABLED=1
|
||||
|
||||
command = rtk
|
||||
instructions = instructions.md
|
||||
@@ -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',
|
||||
|
||||
+372
-21
@@ -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: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
|
||||
<style>
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
@@ -344,30 +344,96 @@ body.touch #terminal {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* gruvbox dark, the same values bin/dejima-attach uses for the session
|
||||
picker, each tagged with its upstream color.table name so a wrong hex is
|
||||
catchable by reading. The two sit either side of one attach: picker, then
|
||||
terminal. They should not look like different programs.
|
||||
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
|
||||
:root {
|
||||
--gb-dark0-hard: #1d2021;
|
||||
--gb-dark0: #282828;
|
||||
--gb-dark1: #3c3836;
|
||||
--gb-dark2: #504945;
|
||||
--gb-light1: #ebdbb2;
|
||||
--gb-orange: #fe8019; /* bright_orange, the wordmark */
|
||||
--gb-yellow: #fabd2f; /* bright_yellow */
|
||||
--gb-green: #b8bb26; /* bright_green */
|
||||
--gb-blue: #83a598; /* bright_blue */
|
||||
|
||||
/* What the toolbar is made of, in tiers rather than in colours, so that
|
||||
turning colour off is one block of overrides at the bottom of this file
|
||||
and not an edit to every rule.
|
||||
|
||||
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
|
||||
is how you type at all on a phone, so it is the one button that must be
|
||||
findable without looking for it. Enter is the key a session asks for most.
|
||||
The window and arrow keys are movement - reached for often, but never the
|
||||
thing you are hunting for - so they are marked out as a group without
|
||||
being filled. Everything else is furniture. */
|
||||
--key-fg: var(--gb-light1);
|
||||
--key-bg: var(--gb-dark1);
|
||||
--key-border: var(--gb-dark2);
|
||||
--key-active-bg: var(--gb-dark2);
|
||||
--key-bar-bg: var(--gb-dark0-hard);
|
||||
--key-bar-border: var(--gb-dark2);
|
||||
|
||||
--key-primary-fg: var(--gb-dark0-hard);
|
||||
--key-primary-bg: var(--gb-orange);
|
||||
--key-primary-border: var(--gb-orange);
|
||||
|
||||
--key-secondary-fg: var(--gb-dark0-hard);
|
||||
--key-secondary-bg: var(--gb-green);
|
||||
--key-secondary-border: var(--gb-green);
|
||||
|
||||
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
|
||||
which is under the 4.5:1 an unenlarged label needs, and the label is the
|
||||
part that has to survive a phone in sunlight. The border carries the tier
|
||||
and the text stays at 8.45:1. */
|
||||
--key-nav-fg: var(--gb-light1);
|
||||
--key-nav-border: var(--gb-blue);
|
||||
|
||||
--key-armed-fg: var(--gb-dark0-hard);
|
||||
--key-armed-bg: var(--gb-blue);
|
||||
--key-locked-fg: var(--gb-dark0-hard);
|
||||
--key-locked-bg: var(--gb-yellow);
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
|
||||
background: #14161a;
|
||||
border-top: 1px solid #2a2f37;
|
||||
background: var(--key-bar-bg);
|
||||
border-top: 1px solid var(--key-bar-border);
|
||||
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;
|
||||
color: #d7dce3;
|
||||
background: #232830;
|
||||
border: 1px solid #343b45;
|
||||
/* 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: var(--key-fg);
|
||||
background: var(--key-bg);
|
||||
border: 1px solid var(--key-border);
|
||||
border-radius: 6px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
@@ -375,21 +441,79 @@ body.touch #terminal {
|
||||
}
|
||||
|
||||
#toolbar button:active {
|
||||
background: #39414d;
|
||||
background: var(--key-active-bg);
|
||||
}
|
||||
|
||||
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
|
||||
which is a ramp that survives being looked at quickly and in sunlight -
|
||||
the two conditions a phone toolbar is actually used in.
|
||||
|
||||
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
|
||||
button match at the same specificity, so what decides is source order, and
|
||||
the state a button is IN has to beat the job it does. */
|
||||
#toolbar button[data-action="kbd"] {
|
||||
color: var(--key-primary-fg);
|
||||
background: var(--key-primary-bg);
|
||||
border-color: var(--key-primary-border);
|
||||
}
|
||||
|
||||
#toolbar button[data-action="enter"] {
|
||||
color: var(--key-secondary-fg);
|
||||
background: var(--key-secondary-bg);
|
||||
border-color: var(--key-secondary-border);
|
||||
}
|
||||
|
||||
#toolbar button[data-action="prev-window"],
|
||||
#toolbar button[data-action="next-window"],
|
||||
#toolbar button[data-action="left"],
|
||||
#toolbar button[data-action="down"],
|
||||
#toolbar button[data-action="up"],
|
||||
#toolbar button[data-action="right"] {
|
||||
color: var(--key-nav-fg);
|
||||
border-color: var(--key-nav-border);
|
||||
}
|
||||
|
||||
/* Armed for one key, versus latched on. Two states on one button, so the
|
||||
distinction has to be visible without a legend. */
|
||||
#toolbar button.is-armed {
|
||||
color: #0b0d10;
|
||||
background: #7fb3ff;
|
||||
border-color: #7fb3ff;
|
||||
color: var(--key-armed-fg);
|
||||
background: var(--key-armed-bg);
|
||||
border-color: var(--key-armed-bg);
|
||||
}
|
||||
|
||||
#toolbar button.is-locked {
|
||||
color: #0b0d10;
|
||||
background: #ffcc66;
|
||||
border-color: #ffcc66;
|
||||
color: var(--key-locked-fg);
|
||||
background: var(--key-locked-bg);
|
||||
border-color: var(--key-locked-bg);
|
||||
}
|
||||
|
||||
/* Every colour the toolbar had before it had a palette. One block, because
|
||||
the alternative is an override per rule and a way for the two to drift -
|
||||
an operator who turns colour off is asking for what was here, not for a
|
||||
second scheme to maintain. */
|
||||
body.mono-keys {
|
||||
--key-fg: #d7dce3;
|
||||
--key-bg: #232830;
|
||||
--key-border: #343b45;
|
||||
--key-active-bg: #39414d;
|
||||
--key-bar-bg: #14161a;
|
||||
--key-bar-border: #2a2f37;
|
||||
|
||||
--key-primary-fg: #d7dce3;
|
||||
--key-primary-bg: #232830;
|
||||
--key-primary-border: #343b45;
|
||||
|
||||
--key-secondary-fg: #d7dce3;
|
||||
--key-secondary-bg: #232830;
|
||||
--key-secondary-border: #343b45;
|
||||
|
||||
--key-nav-fg: #d7dce3;
|
||||
--key-nav-border: #343b45;
|
||||
|
||||
--key-armed-fg: #0b0d10;
|
||||
--key-armed-bg: #7fb3ff;
|
||||
--key-locked-fg: #0b0d10;
|
||||
--key-locked-bg: #ffcc66;
|
||||
}
|
||||
|
||||
#toast {
|
||||
@@ -420,11 +544,13 @@ body.touch #terminal {
|
||||
<button type="button" data-action="down" aria-label="Down">↓</button>
|
||||
<button type="button" data-action="up" aria-label="Up">↑</button>
|
||||
<button type="button" data-action="right" aria-label="Right">→</button>
|
||||
<button type="button" data-action="enter" aria-label="Enter">⏎</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" data-action="prefix">PREFIX</button>
|
||||
<button type="button" data-action="prev-window" aria-label="Previous window">◀W</button>
|
||||
<button type="button" data-action="next-window" aria-label="Next window">W▶</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 +856,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', 'buttonColors'];
|
||||
var IGNORED_KEYS = ['isWindows'];
|
||||
|
||||
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
|
||||
@@ -770,12 +896,71 @@ 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;
|
||||
}
|
||||
|
||||
// Whether the toolbar is colour-coded by what each button is for.
|
||||
//
|
||||
// On unless the operator says otherwise: the hierarchy is the point of it,
|
||||
// and a toolbar where every key looks the same is one where the key you
|
||||
// need most is found by counting positions. "off" restores exactly the grey
|
||||
// it had before, for a terminal colour scheme this clashes with or an
|
||||
// operator who would rather it were quiet.
|
||||
function buttonColorsWanted(preference) {
|
||||
if (preference === 'off' || preference === false || preference === 'false' ||
|
||||
preference === '0') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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,
|
||||
buttonColorsWanted: buttonColorsWanted,
|
||||
pickerSearch: pickerSearch
|
||||
};
|
||||
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
|
||||
|
||||
@@ -1280,6 +1465,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 +1580,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 +1684,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 +1797,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 +1841,17 @@ 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 class rather than per-button styling: what the page decides is
|
||||
// whether there is a palette at all, and the stylesheet owns which button
|
||||
// gets which part of it.
|
||||
document.body.classList.toggle(
|
||||
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
|
||||
|
||||
// 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 +1866,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 +1920,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 +1932,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
@@ -1,14 +1,15 @@
|
||||
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
|
||||
aa2a54392a0ab6bceae342ac0b12494d401b11574db12c013d0c164ebd10e018 src/config.js
|
||||
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
|
||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||
248b4b6e72eed31eda11d466e1483b33ed2c6796895c221f1d0aecfea9243eac src/main.js
|
||||
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
|
||||
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
||||
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
||||
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||
6dddb5ed652fd5189916225115083fc38ae0380a45f82f59f0c388591aa74ccc src/page.css
|
||||
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f 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
|
||||
|
||||
+61
-2
@@ -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', 'buttonColors'];
|
||||
var IGNORED_KEYS = ['isWindows'];
|
||||
|
||||
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
|
||||
@@ -52,11 +52,70 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Whether the toolbar is colour-coded by what each button is for.
|
||||
//
|
||||
// On unless the operator says otherwise: the hierarchy is the point of it,
|
||||
// and a toolbar where every key looks the same is one where the key you
|
||||
// need most is found by counting positions. "off" restores exactly the grey
|
||||
// it had before, for a terminal colour scheme this clashes with or an
|
||||
// operator who would rather it were quiet.
|
||||
function buttonColorsWanted(preference) {
|
||||
if (preference === 'off' || preference === false || preference === 'false' ||
|
||||
preference === '0') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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,
|
||||
buttonColorsWanted: buttonColorsWanted,
|
||||
pickerSearch: pickerSearch
|
||||
};
|
||||
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
|
||||
|
||||
@@ -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 || {}));
|
||||
+42
-3
@@ -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,17 @@
|
||||
}
|
||||
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 class rather than per-button styling: what the page decides is
|
||||
// whether there is a palette at all, and the stylesheet owns which button
|
||||
// gets which part of it.
|
||||
document.body.classList.toggle(
|
||||
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
|
||||
|
||||
// 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 +130,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 +184,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 +196,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();
|
||||
|
||||
|
||||
+139
-15
@@ -42,30 +42,96 @@ body.touch #terminal {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* gruvbox dark, the same values bin/dejima-attach uses for the session
|
||||
picker, each tagged with its upstream color.table name so a wrong hex is
|
||||
catchable by reading. The two sit either side of one attach: picker, then
|
||||
terminal. They should not look like different programs.
|
||||
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
|
||||
:root {
|
||||
--gb-dark0-hard: #1d2021;
|
||||
--gb-dark0: #282828;
|
||||
--gb-dark1: #3c3836;
|
||||
--gb-dark2: #504945;
|
||||
--gb-light1: #ebdbb2;
|
||||
--gb-orange: #fe8019; /* bright_orange, the wordmark */
|
||||
--gb-yellow: #fabd2f; /* bright_yellow */
|
||||
--gb-green: #b8bb26; /* bright_green */
|
||||
--gb-blue: #83a598; /* bright_blue */
|
||||
|
||||
/* What the toolbar is made of, in tiers rather than in colours, so that
|
||||
turning colour off is one block of overrides at the bottom of this file
|
||||
and not an edit to every rule.
|
||||
|
||||
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
|
||||
is how you type at all on a phone, so it is the one button that must be
|
||||
findable without looking for it. Enter is the key a session asks for most.
|
||||
The window and arrow keys are movement - reached for often, but never the
|
||||
thing you are hunting for - so they are marked out as a group without
|
||||
being filled. Everything else is furniture. */
|
||||
--key-fg: var(--gb-light1);
|
||||
--key-bg: var(--gb-dark1);
|
||||
--key-border: var(--gb-dark2);
|
||||
--key-active-bg: var(--gb-dark2);
|
||||
--key-bar-bg: var(--gb-dark0-hard);
|
||||
--key-bar-border: var(--gb-dark2);
|
||||
|
||||
--key-primary-fg: var(--gb-dark0-hard);
|
||||
--key-primary-bg: var(--gb-orange);
|
||||
--key-primary-border: var(--gb-orange);
|
||||
|
||||
--key-secondary-fg: var(--gb-dark0-hard);
|
||||
--key-secondary-bg: var(--gb-green);
|
||||
--key-secondary-border: var(--gb-green);
|
||||
|
||||
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
|
||||
which is under the 4.5:1 an unenlarged label needs, and the label is the
|
||||
part that has to survive a phone in sunlight. The border carries the tier
|
||||
and the text stays at 8.45:1. */
|
||||
--key-nav-fg: var(--gb-light1);
|
||||
--key-nav-border: var(--gb-blue);
|
||||
|
||||
--key-armed-fg: var(--gb-dark0-hard);
|
||||
--key-armed-bg: var(--gb-blue);
|
||||
--key-locked-fg: var(--gb-dark0-hard);
|
||||
--key-locked-bg: var(--gb-yellow);
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
|
||||
background: #14161a;
|
||||
border-top: 1px solid #2a2f37;
|
||||
background: var(--key-bar-bg);
|
||||
border-top: 1px solid var(--key-bar-border);
|
||||
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;
|
||||
color: #d7dce3;
|
||||
background: #232830;
|
||||
border: 1px solid #343b45;
|
||||
/* 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: var(--key-fg);
|
||||
background: var(--key-bg);
|
||||
border: 1px solid var(--key-border);
|
||||
border-radius: 6px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
@@ -73,21 +139,79 @@ body.touch #terminal {
|
||||
}
|
||||
|
||||
#toolbar button:active {
|
||||
background: #39414d;
|
||||
background: var(--key-active-bg);
|
||||
}
|
||||
|
||||
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
|
||||
which is a ramp that survives being looked at quickly and in sunlight -
|
||||
the two conditions a phone toolbar is actually used in.
|
||||
|
||||
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
|
||||
button match at the same specificity, so what decides is source order, and
|
||||
the state a button is IN has to beat the job it does. */
|
||||
#toolbar button[data-action="kbd"] {
|
||||
color: var(--key-primary-fg);
|
||||
background: var(--key-primary-bg);
|
||||
border-color: var(--key-primary-border);
|
||||
}
|
||||
|
||||
#toolbar button[data-action="enter"] {
|
||||
color: var(--key-secondary-fg);
|
||||
background: var(--key-secondary-bg);
|
||||
border-color: var(--key-secondary-border);
|
||||
}
|
||||
|
||||
#toolbar button[data-action="prev-window"],
|
||||
#toolbar button[data-action="next-window"],
|
||||
#toolbar button[data-action="left"],
|
||||
#toolbar button[data-action="down"],
|
||||
#toolbar button[data-action="up"],
|
||||
#toolbar button[data-action="right"] {
|
||||
color: var(--key-nav-fg);
|
||||
border-color: var(--key-nav-border);
|
||||
}
|
||||
|
||||
/* Armed for one key, versus latched on. Two states on one button, so the
|
||||
distinction has to be visible without a legend. */
|
||||
#toolbar button.is-armed {
|
||||
color: #0b0d10;
|
||||
background: #7fb3ff;
|
||||
border-color: #7fb3ff;
|
||||
color: var(--key-armed-fg);
|
||||
background: var(--key-armed-bg);
|
||||
border-color: var(--key-armed-bg);
|
||||
}
|
||||
|
||||
#toolbar button.is-locked {
|
||||
color: #0b0d10;
|
||||
background: #ffcc66;
|
||||
border-color: #ffcc66;
|
||||
color: var(--key-locked-fg);
|
||||
background: var(--key-locked-bg);
|
||||
border-color: var(--key-locked-bg);
|
||||
}
|
||||
|
||||
/* Every colour the toolbar had before it had a palette. One block, because
|
||||
the alternative is an override per rule and a way for the two to drift -
|
||||
an operator who turns colour off is asking for what was here, not for a
|
||||
second scheme to maintain. */
|
||||
body.mono-keys {
|
||||
--key-fg: #d7dce3;
|
||||
--key-bg: #232830;
|
||||
--key-border: #343b45;
|
||||
--key-active-bg: #39414d;
|
||||
--key-bar-bg: #14161a;
|
||||
--key-bar-border: #2a2f37;
|
||||
|
||||
--key-primary-fg: #d7dce3;
|
||||
--key-primary-bg: #232830;
|
||||
--key-primary-border: #343b45;
|
||||
|
||||
--key-secondary-fg: #d7dce3;
|
||||
--key-secondary-bg: #232830;
|
||||
--key-secondary-border: #343b45;
|
||||
|
||||
--key-nav-fg: #d7dce3;
|
||||
--key-nav-border: #343b45;
|
||||
|
||||
--key-armed-fg: #0b0d10;
|
||||
--key-armed-bg: #7fb3ff;
|
||||
--key-locked-fg: #0b0d10;
|
||||
--key-locked-bg: #ffcc66;
|
||||
}
|
||||
|
||||
#toast {
|
||||
|
||||
@@ -30,11 +30,13 @@
|
||||
<button type="button" data-action="down" aria-label="Down">↓</button>
|
||||
<button type="button" data-action="up" aria-label="Up">↑</button>
|
||||
<button type="button" data-action="right" aria-label="Right">→</button>
|
||||
<button type="button" data-action="enter" aria-label="Enter">⏎</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" data-action="prefix">PREFIX</button>
|
||||
<button type="button" data-action="prev-window" aria-label="Previous window">◀W</button>
|
||||
<button type="button" data-action="next-window" aria-label="Next window">W▶</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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+94
-2
@@ -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, buttonColorsWanted } = globalThis.DEJIMA.config;
|
||||
|
||||
test('a tmux prefix is parsed into the byte tmux would receive', () => {
|
||||
assert.equal(parsePrefix('C-b'), '\x02');
|
||||
@@ -53,3 +53,95 @@ 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');
|
||||
});
|
||||
|
||||
// The toolbar's colours are on unless the operator turns them off: the
|
||||
// hierarchy is the point of them, and a toolbar where every key looks alike is
|
||||
// one where the key you need most is found by counting positions.
|
||||
|
||||
test('the toolbar is coloured when nothing says otherwise', () => {
|
||||
assert.equal(buttonColorsWanted(undefined), true);
|
||||
assert.equal(buttonColorsWanted(''), true);
|
||||
assert.equal(buttonColorsWanted('on'), true);
|
||||
});
|
||||
|
||||
test('off turns the toolbar grey, in dejima\'s own bool spelling', () => {
|
||||
// ttyd_button_colors is a dejima bool, so what reaches the page is "off" -
|
||||
// not "false". Accepting only the latter would ship a documented key that
|
||||
// does nothing, which is exactly what focusOnTap nearly did.
|
||||
assert.equal(buttonColorsWanted('off'), false);
|
||||
assert.equal(buttonColorsWanted(false), false);
|
||||
assert.equal(buttonColorsWanted('false'), false);
|
||||
assert.equal(buttonColorsWanted('0'), false);
|
||||
});
|
||||
|
||||
test('buttonColors is a page option, not one xterm is handed', () => {
|
||||
// Every page key that reached term.options would be an unknown option to
|
||||
// xterm.js, which is the failure splitPreferences exists to prevent.
|
||||
const split = splitPreferences({ buttonColors: 'off', fontSize: 14 });
|
||||
assert.equal(split.page.buttonColors, 'off');
|
||||
assert.equal(split.terminal.buttonColors, undefined);
|
||||
assert.equal(split.terminal.fontSize, 14);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// Every label the toolbar draws has to be readable on a phone held outdoors,
|
||||
// which is the only place this page is ever used. WCAG AA asks 4.5:1 for text
|
||||
// this size, and one of these pairings shipped at 4.31:1 in the first draft -
|
||||
// gruvbox's bright_blue as a label on dark1 - which is a difference nobody
|
||||
// catches by looking at a screenshot on a desk.
|
||||
//
|
||||
// Read out of the stylesheet rather than restated here, so a colour changed
|
||||
// there is a colour checked here.
|
||||
|
||||
const CSS = fs.readFileSync(path.join(__dirname, '..', 'src', 'page.css'), 'utf8');
|
||||
|
||||
function declarationsIn(block) {
|
||||
const out = {};
|
||||
const re = /^\s*(--[a-z0-9-]+):\s*([^;]+);/gm;
|
||||
let m;
|
||||
while ((m = re.exec(block)) !== null) out[m[1]] = m[2].trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
function blockFor(selector) {
|
||||
const start = CSS.indexOf(selector + ' {');
|
||||
assert.notEqual(start, -1, 'no ' + selector + ' block in page.css');
|
||||
const end = CSS.indexOf('\n}', start);
|
||||
return CSS.slice(start, end);
|
||||
}
|
||||
|
||||
// var(--x) chains down to a literal hex.
|
||||
function resolve(vars, value, depth) {
|
||||
depth = depth || 0;
|
||||
assert.ok(depth < 10, 'variable cycle resolving ' + value);
|
||||
const m = /^var\((--[a-z0-9-]+)\)$/.exec(value);
|
||||
if (!m) return value;
|
||||
assert.ok(vars[m[1]], 'undefined variable ' + m[1]);
|
||||
return resolve(vars, vars[m[1]], depth + 1);
|
||||
}
|
||||
|
||||
function luminance(hex) {
|
||||
const h = hex.replace('#', '');
|
||||
const parts = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
||||
const lin = parts.map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||
}
|
||||
|
||||
function contrast(fg, bg) {
|
||||
const a = luminance(fg);
|
||||
const b = luminance(bg);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
// Each tier's label against the surface it is drawn on. The nav tier is
|
||||
// outlined rather than filled, so its label sits on the ordinary key
|
||||
// background - which is precisely why its colour matters.
|
||||
const PAIRINGS = [
|
||||
['plain keys', '--key-fg', '--key-bg'],
|
||||
['the nav tier', '--key-nav-fg', '--key-bg'],
|
||||
['KBD', '--key-primary-fg', '--key-primary-bg'],
|
||||
['Enter', '--key-secondary-fg', '--key-secondary-bg'],
|
||||
['an armed latch', '--key-armed-fg', '--key-armed-bg'],
|
||||
['a locked latch', '--key-locked-fg', '--key-locked-bg']
|
||||
];
|
||||
|
||||
function checkBlock(selector) {
|
||||
const vars = declarationsIn(blockFor(selector));
|
||||
PAIRINGS.forEach(([what, fgVar, bgVar]) => {
|
||||
assert.ok(vars[fgVar], selector + ' does not set ' + fgVar);
|
||||
assert.ok(vars[bgVar], selector + ' does not set ' + bgVar);
|
||||
const fg = resolve(vars, vars[fgVar]);
|
||||
const bg = resolve(vars, vars[bgVar]);
|
||||
const ratio = contrast(fg, bg);
|
||||
assert.ok(
|
||||
ratio >= 4.5,
|
||||
what + ' in ' + selector + ' is ' + fg + ' on ' + bg + ' at ' +
|
||||
ratio.toFixed(2) + ':1, under the 4.5:1 a label this size needs'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('every toolbar label meets AA against what it is drawn on', () => {
|
||||
checkBlock(':root');
|
||||
});
|
||||
|
||||
test('turning the colours off does not turn legibility off with them', () => {
|
||||
// The grey scheme is a supported mode, not a fallback nobody looks at.
|
||||
checkBlock('body.mono-keys');
|
||||
});
|
||||
|
||||
test('the tiers are actually distinct, or the hierarchy is decoration', () => {
|
||||
const vars = declarationsIn(blockFor(':root'));
|
||||
const bg = (v) => resolve(vars, vars[v]);
|
||||
const primary = bg('--key-primary-bg');
|
||||
const secondary = bg('--key-secondary-bg');
|
||||
const plain = bg('--key-bg');
|
||||
assert.notEqual(primary, secondary, 'KBD and Enter are filled the same colour');
|
||||
assert.notEqual(primary, plain, 'KBD is filled the same as an ordinary key');
|
||||
assert.notEqual(secondary, plain, 'Enter is filled the same as an ordinary key');
|
||||
assert.notEqual(
|
||||
resolve(vars, vars['--key-nav-border']), resolve(vars, vars['--key-border']),
|
||||
'the nav tier is outlined the same as an ordinary key'
|
||||
);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -10,7 +10,10 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
branch-finishing advice from a skill cannot know that, and does not
|
||||
override it.
|
||||
- Stop at the push. Do not open a pull request, and do not merge anything -
|
||||
neither is part of this workflow. Report the branch name and stop.
|
||||
neither is part of this workflow. A `merge-guard.sh` hook and a shadow over
|
||||
the forge's own command both refuse a merge here, but treat that as the
|
||||
workflow rather than as a filter to route around. Report the branch name
|
||||
and stop.
|
||||
- Never push directly to {{blocked_branches}}, on any remote. A
|
||||
`git-push-guard.sh` hook enforces that as a backstop, but treat it as the
|
||||
workflow to follow rather than a filter to route around.
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# block_merge is off despite this being a pushing workflow: nothing here ever
|
||||
# reaches a forge's PR interface, so there is no merge command to block.
|
||||
requires =
|
||||
push = on
|
||||
blocked_branches = main master
|
||||
block_merge = off
|
||||
# This said "off" and gave a reason: nothing here ever reaches a forge's PR
|
||||
# interface, so there is no merge command to block. The premise is false. What
|
||||
# the briefing tells the agent to do and what the image puts on its PATH are
|
||||
# different things - select forge = gitea and `gitea-pr merge` is present
|
||||
# whatever the workflow says, because the CLI is installed by the forge, not
|
||||
# by the workflow. This workflow's own briefing says "do not merge anything",
|
||||
# and until now nothing held it to that.
|
||||
block_merge = on
|
||||
requires_worktree = on
|
||||
autonomy = push-branch
|
||||
briefing = briefing.md
|
||||
|
||||
@@ -3,7 +3,7 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
anywhere: there is no remote destination configured for it, and
|
||||
`git-push-guard.sh` blocks pushes as a backstop.
|
||||
- Do not open pull requests and do not merge. Neither is part of this
|
||||
workflow.
|
||||
workflow, and a merge is refused here in any case.
|
||||
- Report what you committed and stop. If the work needs to reach a remote,
|
||||
say so plainly rather than attempting it - that is the operator's call and
|
||||
needs a different workflow.
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
requires =
|
||||
push = off
|
||||
blocked_branches =
|
||||
block_merge = off
|
||||
# On for the same reason branch-push's is: this workflow may be selected
|
||||
# alongside a forge, its briefing says "do not open pull requests and do not
|
||||
# merge", and the forge's CLI is on PATH regardless of what the workflow tells
|
||||
# the agent to do. A briefing that states a rule while the manifest leaves the
|
||||
# guard off is asserting something nothing holds.
|
||||
block_merge = on
|
||||
requires_worktree = off
|
||||
autonomy = commit
|
||||
briefing = briefing.md
|
||||
|
||||
Reference in New Issue
Block a user