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