Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd3022dd3 | ||
|
|
9a612f7b97 | ||
|
|
ad03d02f4a | ||
|
|
f638c40b76 | ||
|
|
ed57f646a3 | ||
|
|
90a264c63c | ||
|
|
53abc0ae21 | ||
|
|
52a7a21f8a | ||
|
|
549e5eb351 | ||
|
|
4e093f0c4a | ||
|
|
d1f8a54883 | ||
|
|
b71cd7617d | ||
|
|
f43ffdcf6a | ||
|
|
c03800e435 | ||
|
|
e73c6df114 | ||
|
|
3185d9fb5c | ||
|
|
b5ac1b3b46 | ||
|
|
ea3e9c09e8 | ||
|
|
d082e6a75c | ||
|
|
77b2d02866 | ||
|
|
0c2ceba35b | ||
|
|
4d1bb99180 | ||
|
|
4163ec12cd | ||
|
|
028fd8fb7f | ||
|
|
69713202ad | ||
|
|
decf60fa9e | ||
|
|
5c0f4b80c3 | ||
|
|
1587a154fd | ||
|
|
17602234de | ||
|
|
7585c4e8b6 | ||
|
|
a5b35c3750 | ||
|
|
4bdf3a7f14 | ||
|
|
6f9b93517e | ||
|
|
6bb5d0ef3c | ||
|
|
7b82328541 | ||
|
|
4c63db4891 | ||
|
|
8368ffb67a | ||
|
|
edc55d258f | ||
|
|
a41bae1b2f | ||
|
|
88de8b8670 | ||
|
|
baff452a22 | ||
|
|
d5551a5231 | ||
|
|
aa78ff896f | ||
|
|
588b4cac51 | ||
|
|
dd7fd109a7 | ||
|
|
844ff1c312 | ||
|
|
a5d7782610 | ||
|
|
829537c17a | ||
|
|
46329034e1 | ||
|
|
d973ba3cd7 | ||
|
|
edbb0a4913 | ||
|
|
919e723de8 | ||
|
|
be1d4521ec | ||
|
|
37c86e844d | ||
|
|
831221d930 | ||
|
|
f100b0b915 | ||
|
|
c94447f97e | ||
|
|
aa3e7090a1 | ||
|
|
2f7663b03f | ||
|
|
4da489879f | ||
|
|
569d40513a | ||
|
|
ac4b09843b | ||
|
|
a918047f93 | ||
|
|
68aedd157a | ||
|
|
f84e47c7c7 | ||
|
|
e8de5edbf0 | ||
|
|
deabec04d4 | ||
|
|
7c1e95ab97 | ||
|
|
5a4a72ffb8 | ||
|
|
77f41fb60e | ||
|
|
ddedcf0c15 | ||
|
|
c8968a3c80 | ||
|
|
937b697d07 | ||
|
|
a7a4f0b39d | ||
|
|
e3eb54b97a | ||
|
|
0a671a5faa | ||
|
|
9998fe2679 | ||
|
|
3b14a4ad17 | ||
|
|
7b76de039c | ||
|
|
7070ca6f67 | ||
|
|
49205f50d4 | ||
|
|
43973f3c2f | ||
|
|
246921ace2 | ||
|
|
3bc9143918 | ||
|
|
71593180ad | ||
|
|
f55cc70e62 | ||
|
|
37483236fb | ||
|
|
a9dafeca41 | ||
|
|
f371c4d173 | ||
|
|
4c7967fbc9 | ||
|
|
00f1497cec | ||
|
|
423f7103c8 | ||
|
|
dc9d311b32 | ||
|
|
c7cb7e1e15 | ||
|
|
f2c1d79341 | ||
|
|
74a1c8c0d3 | ||
|
|
98ca03e3ee | ||
|
|
0bd28d6b38 | ||
|
|
eb16b39bf5 | ||
|
|
0f440b8817 | ||
|
|
135d06345a | ||
|
|
791f3bfcff | ||
|
|
bc418366a9 | ||
|
|
bb3c2a16d6 | ||
|
|
336f0e0b3c | ||
|
|
d3cc1b7d2e | ||
|
|
a0d6eb69ff | ||
|
|
fa4cd3cf76 | ||
|
|
fbe7c3a214 | ||
|
|
96b3912667 | ||
|
|
831b346b70 | ||
|
|
3e5c94743f | ||
|
|
964d27d385 | ||
|
|
5b27628fab | ||
|
|
d5dd7357f3 | ||
|
|
e638a5db23 |
@@ -37,6 +37,10 @@ set before opening a PR, not between edits.
|
|||||||
| `bin/dejima-provider-lib.sh`, `forges/`, `workflows/`, `harnesses/` | `test/providers.bats` |
|
| `bin/dejima-provider-lib.sh`, `forges/`, `workflows/`, `harnesses/` | `test/providers.bats` |
|
||||||
| `bin/dejima-attach`, `-ttyd`, `-notify-watch`, `web/`, `systemd/` | `test/remote.bats` |
|
| `bin/dejima-attach`, `-ttyd`, `-notify-watch`, `web/`, `systemd/` | `test/remote.bats` |
|
||||||
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
|
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
|
||||||
|
| `bin/dejima-update-check` | `test/config.bats` |
|
||||||
|
| `bin/dejima-tui-lib.sh` | `test/remote.bats` and `test/project.bats` (both tools use it) |
|
||||||
|
| `bin/dejima-tty-suspend` | `test/static.bats` |
|
||||||
|
| `tools/` | `test/providers.bats` |
|
||||||
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
|
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
|
||||||
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
|
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
|
||||||
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
|
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
|
||||||
|
|||||||
+440
-39
@@ -30,26 +30,34 @@ Usage: dejima [options] [workspace] [-- args...]
|
|||||||
-- args... everything after is passed to the harness command untouched
|
-- args... everything after is passed to the harness command untouched
|
||||||
|
|
||||||
Options (each overrides the config file for one launch only):
|
Options (each overrides the config file for one launch only):
|
||||||
--forge <name> which forge integration to use for this launch
|
--harness <name> which harness to run
|
||||||
|
--forge <name> which forge integration to use
|
||||||
--workflow <name> what the agent is told to do with its work
|
--workflow <name> what the agent is told to do with its work
|
||||||
--cmd <command> run this instead of the harness's own command
|
--cmd <command> run this instead of the harness's own command
|
||||||
--ro, --extra-readonly <path> also mount this host path read-only
|
--identity <name> which [identity.<name>] supplies the credentials
|
||||||
--rw, --extra-read-write <path> also mount this host path read-write
|
-i, --include <tool> add an optional tool to this launch (repeatable);
|
||||||
--allow-github-push open GitHub's push path (port 22) for this launch
|
see docs/tools.md for what dejima ships
|
||||||
|
--model <id> the selected harness's model, where it has one;
|
||||||
|
--base-url <url> its endpoint, and how much context it may use.
|
||||||
|
--context-window <n> Refused for a harness with no such setting
|
||||||
|
--ro <path> mount read-only; --rw read-write. Repeatable. Long
|
||||||
|
forms --extra-readonly and --extra-read-write. Both
|
||||||
|
refuse a path overlapping dejima's own stores
|
||||||
|
--allow-github-push open GitHub's push path (port 22)
|
||||||
|
--strict-audit refuse to start over unacknowledged CRITICAL findings
|
||||||
|
--no-log ship nothing to the log endpoint this launch
|
||||||
|
--no-conversation keep this session's transcript out of the log
|
||||||
|
--no-epitaph keep nothing if this session compacts
|
||||||
--no-firewall disable the egress firewall entirely (debugging only)
|
--no-firewall disable the egress firewall entirely (debugging only)
|
||||||
--print-config print the resolved configuration and exit
|
--print-config print the resolved configuration and exit
|
||||||
--print-briefing print the session briefing the agent will get, and exit
|
--print-briefing print the briefing the agent will get, and exit
|
||||||
-h, --help this message
|
-h, --help this message
|
||||||
|
|
||||||
--ro and --rw may be given more than once. Both refuse a path that overlaps
|
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
|
||||||
dejima's own approval, audit or epitaph stores.
|
config that says off is a decision, not a default to override in passing.
|
||||||
|
|
||||||
Configuration lives in ~/.config/dejima/config and [core] harness must be set
|
Config lives in ~/.config/dejima/config and [core] harness must be set before
|
||||||
before a launch will start. --print-config shows what is actually in effect,
|
a launch starts. --print-config prints every key as it resolves.
|
||||||
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
|
DEJIMA_HELP
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
esac
|
esac
|
||||||
@@ -111,10 +119,14 @@ fi
|
|||||||
FLAG_OVERRIDES=()
|
FLAG_OVERRIDES=()
|
||||||
_set_override() {
|
_set_override() {
|
||||||
local key="$1" value="$2" type entry
|
local key="$1" value="$2" type entry
|
||||||
|
# $3 is the flag to blame, for the flags whose spelling is not their key's:
|
||||||
|
# --context-window sets context_window, and reporting the key back would
|
||||||
|
# name an option the operator cannot type.
|
||||||
|
local flag="${3:---${1##*.}}"
|
||||||
local -a kept=()
|
local -a kept=()
|
||||||
type="$(config_type "$key")" || { echo "unknown config key: $key" >&2; exit 1; }
|
type="$(config_type "$key")" || { echo "unknown config key: $key" >&2; exit 1; }
|
||||||
if ! _config_valid_value "$type" "$value"; then
|
if ! _config_valid_value "$type" "$value"; then
|
||||||
echo "invalid value for --${key##*.}: $value" >&2
|
echo "invalid value for $flag: $value" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# A repeated flag takes the last value, same as a repeated key in the
|
# A repeated flag takes the last value, same as a repeated key in the
|
||||||
@@ -190,9 +202,26 @@ _mount_label() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A flag naming one of the SELECTED harness's keys, deferred because which
|
||||||
|
# harness is selected is not known until every flag has been read - --harness
|
||||||
|
# may come after --model. Recorded here, resolved below the loop.
|
||||||
|
#
|
||||||
|
# Deferred rather than resolved eagerly so the error can name the harness:
|
||||||
|
# "claude-code has no model setting" is actionable where "unknown config key"
|
||||||
|
# is not.
|
||||||
|
HARNESS_KEY_FLAGS=()
|
||||||
|
_defer_harness_key() {
|
||||||
|
HARNESS_KEY_FLAGS+=("$1=$2=$3")
|
||||||
|
}
|
||||||
|
|
||||||
PRINT_CONFIG=0
|
PRINT_CONFIG=0
|
||||||
PRINT_BRIEFING=0
|
PRINT_BRIEFING=0
|
||||||
ALLOW_GITHUB_PUSH_FLAG=0
|
ALLOW_GITHUB_PUSH_FLAG=0
|
||||||
|
# Additive, like --ro and --rw and unlike every other flag here: an optional
|
||||||
|
# tool asked for on the command line is one MORE than the config and the
|
||||||
|
# project already agreed on, not a replacement for them. Repeatable.
|
||||||
|
TOOL_FLAGS=()
|
||||||
|
NO_LOG=0
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--) shift; break ;;
|
--) shift; break ;;
|
||||||
@@ -200,8 +229,27 @@ while [ $# -gt 0 ]; do
|
|||||||
--print-briefing) PRINT_BRIEFING=1; shift ;;
|
--print-briefing) PRINT_BRIEFING=1; shift ;;
|
||||||
--no-firewall) _set_override core.firewall off; shift ;;
|
--no-firewall) _set_override core.firewall off; shift ;;
|
||||||
--allow-github-push) ALLOW_GITHUB_PUSH_FLAG=1; shift ;;
|
--allow-github-push) ALLOW_GITHUB_PUSH_FLAG=1; shift ;;
|
||||||
|
# Narrowing flags. Each has no counterpart that turns the setting back
|
||||||
|
# on: a config saying off is a decision, and a flag that could undo it
|
||||||
|
# in passing would make the file's answer the weaker one.
|
||||||
|
--strict-audit) _set_override audit.strict on; shift ;;
|
||||||
|
--no-epitaph) _set_override epitaph.enabled off; shift ;;
|
||||||
|
--no-conversation) _set_override logging.conversation off; shift ;;
|
||||||
|
--no-log) _set_override logging.loki_url ""
|
||||||
|
_set_override logging.conversation off
|
||||||
|
NO_LOG=1; shift ;;
|
||||||
|
--model) _need_value --model "${2:-}"
|
||||||
|
_defer_harness_key --model model "$2"; shift 2 ;;
|
||||||
|
--base-url) _need_value --base-url "${2:-}"
|
||||||
|
_defer_harness_key --base-url base_url "$2"; shift 2 ;;
|
||||||
|
--context-window) _need_value --context-window "${2:-}"
|
||||||
|
_defer_harness_key --context-window context_window "$2"; shift 2 ;;
|
||||||
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
|
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
|
||||||
--harness) _need_value --harness "${2:-}"; _set_override core.harness "$2"; shift 2 ;;
|
--harness) _need_value --harness "${2:-}"; _set_override core.harness "$2"; shift 2 ;;
|
||||||
|
--identity) _need_value --identity "${2:-}"; _set_override core.identity "$2"; shift 2 ;;
|
||||||
|
--include|-i)
|
||||||
|
_need_value --include "${2:-}"
|
||||||
|
TOOL_FLAGS+=("$2"); shift 2 ;;
|
||||||
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
|
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
|
||||||
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
|
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
|
||||||
--extra-readonly|--ro)
|
--extra-readonly|--ro)
|
||||||
@@ -219,15 +267,52 @@ done
|
|||||||
# Defined here rather than in the library because an override is a property
|
# Defined here rather than in the library because an override is a property
|
||||||
# of one invocation of this launcher, not of the config file.
|
# of one invocation of this launcher, not of the config file.
|
||||||
config_get() {
|
config_get() {
|
||||||
local key="$1.$2" entry
|
local key="$1.$2" entry value
|
||||||
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
|
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
|
||||||
[ "${entry%%=*}" = "$key" ] || continue
|
[ "${entry%%=*}" = "$key" ] || continue
|
||||||
printf '%s' "${entry#*=}"
|
printf '%s' "${entry#*=}"
|
||||||
return 0
|
return 0
|
||||||
done
|
done
|
||||||
|
# The selected identity, for the handful of keys an identity owns: the
|
||||||
|
# credentials and the ssh key a PERSON brings, which is the half a shared
|
||||||
|
# project file cannot carry. Below a flag, because a flag is this launch
|
||||||
|
# and an identity is this project; above the file, because the file is the
|
||||||
|
# machine's default and naming an identity is how you say "not that one".
|
||||||
|
#
|
||||||
|
# Every consumer goes through config_get, including forge_env_exports in
|
||||||
|
# the library, so this one wrapper is the whole insertion point.
|
||||||
|
if value="$(identity_get "$1" "$2")"; then
|
||||||
|
printf '%s' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
_config_get_from_file "$1" "$2"
|
_config_get_from_file "$1" "$2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The deferred harness-key flags, now that --harness has been read and
|
||||||
|
# config_get honours it. A key the selected harness does not declare is
|
||||||
|
# refused by name rather than ignored: a --model that silently did nothing
|
||||||
|
# because the harness has no such setting is the failure worth avoiding, and
|
||||||
|
# it is the whole reason these are not resolved inside the loop above.
|
||||||
|
if [ "${#HARNESS_KEY_FLAGS[@]}" -gt 0 ]; then
|
||||||
|
_flag_harness="$(config_get core harness)"
|
||||||
|
for _entry in "${HARNESS_KEY_FLAGS[@]}"; do
|
||||||
|
_flag="${_entry%%=*}"
|
||||||
|
_rest="${_entry#*=}"
|
||||||
|
_key="${_rest%%=*}"
|
||||||
|
_value="${_rest#*=}"
|
||||||
|
if [ -z "$_flag_harness" ]; then
|
||||||
|
echo "$_flag needs a harness: set [core] harness or pass --harness." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! config_type "harness.$_flag_harness.$_key" >/dev/null 2>&1; then
|
||||||
|
echo "the $_flag_harness harness has no '$_key' setting, so $_flag does not apply to it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
_set_override "harness.$_flag_harness.$_key" "$_value" "$_flag"
|
||||||
|
done
|
||||||
|
unset _flag_harness _entry _flag _rest _key _value
|
||||||
|
fi
|
||||||
|
|
||||||
# Retired environment variables. Named, never honoured: silently ignoring an
|
# Retired environment variables. Named, never honoured: silently ignoring an
|
||||||
# exported LOKI_URL means logs stop shipping with nothing saying why, and
|
# exported LOKI_URL means logs stop shipping with nothing saying why, and
|
||||||
# silently ignoring SANDBOX_ANTHROPIC_TRANSPORT means a session quietly gets
|
# silently ignoring SANDBOX_ANTHROPIC_TRANSPORT means a session quietly gets
|
||||||
@@ -356,6 +441,14 @@ fi
|
|||||||
# testable without booting Docker.
|
# testable without booting Docker.
|
||||||
if [ "$PRINT_CONFIG" = 1 ]; then
|
if [ "$PRINT_CONFIG" = 1 ]; then
|
||||||
config_validate || exit 1
|
config_validate || exit 1
|
||||||
|
# Before printing, not only before launching. Every value below resolves
|
||||||
|
# THROUGH the identity, so printing them under a name that resolves to
|
||||||
|
# nothing would show this machine's own credentials as though they were
|
||||||
|
# the identity's - the exact confusion the refusal exists to prevent, in
|
||||||
|
# the one command an operator runs to check what they configured.
|
||||||
|
# The forge is read here rather than taken from DEJIMA_FORGE_NAME, which
|
||||||
|
# is resolved further down, past this early exit.
|
||||||
|
identity_validate "$(config_get core forge)" || exit 1
|
||||||
while IFS= read -r _key; do
|
while IFS= read -r _key; do
|
||||||
_section="${_key%.*}"
|
_section="${_key%.*}"
|
||||||
_leaf="${_key##*.}"
|
_leaf="${_key##*.}"
|
||||||
@@ -457,6 +550,44 @@ case " $_corpus_approved " in
|
|||||||
esac
|
esac
|
||||||
unset _corpus_approved
|
unset _corpus_approved
|
||||||
|
|
||||||
|
# The optional tools this launch has: [core] tools, plus whatever the project
|
||||||
|
# declared AND the operator approved, plus --include. A union rather than a
|
||||||
|
# precedence chain, because each of the three answers a different question -
|
||||||
|
# what this machine always wants, what this project needs, what this run is
|
||||||
|
# for - and none of them is a correction of another.
|
||||||
|
#
|
||||||
|
# The project's half comes from the APPROVAL STORE and never from the
|
||||||
|
# declaration in the workspace, for the reason the corpus block above gives:
|
||||||
|
# that file is session-writable, and a session that could add a tool would be
|
||||||
|
# choosing what the next session's image contains. This file may not name that
|
||||||
|
# directory at all, and a static test holds it to that - which is why the path
|
||||||
|
# is not spelled out here.
|
||||||
|
#
|
||||||
|
# Every name is checked against a manifest that exists before it reaches the
|
||||||
|
# image build. approved_entries already refuses an entry its validator does
|
||||||
|
# not accept, so the only unchecked source is the flag - which is the
|
||||||
|
# operator's own typing, and gets the same answer.
|
||||||
|
DEJIMA_TOOLS=""
|
||||||
|
_tools_wanted="$(config_get core tools | tr ':' ' ')"
|
||||||
|
_tools_wanted="$_tools_wanted $(approved_entries "$(config_root)" tools | tr '\n' ' ')"
|
||||||
|
if [ -n "$PROJECT_STORE" ]; then
|
||||||
|
_tools_wanted="$_tools_wanted $(approved_entries "$PROJECT_STORE" tools | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
_tools_wanted="$_tools_wanted ${TOOL_FLAGS[*]-}"
|
||||||
|
for _tool in $_tools_wanted; do
|
||||||
|
[ -n "$_tool" ] || continue
|
||||||
|
case " $DEJIMA_TOOLS " in *" $_tool "*) continue ;; esac
|
||||||
|
if ! valid_tool_name "$_tool"; then
|
||||||
|
echo "no optional tool named '$_tool'." >&2
|
||||||
|
echo "Available: $(provider_list tool | tr '\n' ' ')" >&2
|
||||||
|
echo "See docs/tools.md." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
tool_validate "$_tool" || exit 1
|
||||||
|
DEJIMA_TOOLS="${DEJIMA_TOOLS:+$DEJIMA_TOOLS }$_tool"
|
||||||
|
done
|
||||||
|
unset _tools_wanted _tool
|
||||||
|
|
||||||
|
|
||||||
AUDIT_DIR="$(config_get audit dir)"
|
AUDIT_DIR="$(config_get audit dir)"
|
||||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||||
@@ -671,6 +802,41 @@ if [ -n "$_disclose_spec" ]; then
|
|||||||
IFS="$_old_ifs"
|
IFS="$_old_ifs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# How this forge's CLI can be aimed somewhere other than the repository the
|
||||||
|
# session was launched against, and what that repository is (#187).
|
||||||
|
#
|
||||||
|
# A CLI that resolves the repo from origin and refuses another host cannot be
|
||||||
|
# aimed anywhere and declares nothing here. One that takes the repository as
|
||||||
|
# an option makes acting on an unrelated repository the same length of command
|
||||||
|
# as acting on this one. Which is which is the forge's to declare; core only
|
||||||
|
# resolves the repository the declaration is compared against, and
|
||||||
|
# docker/forge-guard.sh does the comparing.
|
||||||
|
#
|
||||||
|
# The last two path components of origin's URL, which is the same
|
||||||
|
# "<owner>/<name>" both forges use and covers ssh, scp-style and https
|
||||||
|
# remotes without parsing any of them as a URL. No origin, or a workspace
|
||||||
|
# that is not a checkout, leaves this empty - and the guard then refuses any
|
||||||
|
# --repo or GH_REPO outright rather than waving it through. A workspace with
|
||||||
|
# no origin has nothing such a flag could legitimately name, so "cannot
|
||||||
|
# confirm" is not a reason to allow it.
|
||||||
|
DEJIMA_FORGE_REPO_FLAGS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_flags)"
|
||||||
|
DEJIMA_FORGE_REPO_ENV="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_env)"
|
||||||
|
DEJIMA_FORGE_REPO=""
|
||||||
|
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ]; then
|
||||||
|
_origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
|
||||||
|
if [ -n "$_origin_url" ]; then
|
||||||
|
_origin_url="${_origin_url%/}"
|
||||||
|
_origin_url="${_origin_url%.git}"
|
||||||
|
_origin_name="${_origin_url##*/}"
|
||||||
|
_origin_rest="${_origin_url%/*}"
|
||||||
|
_origin_owner="${_origin_rest##*[:/]}"
|
||||||
|
if [ -n "$_origin_owner" ] && [ -n "$_origin_name" ] &&
|
||||||
|
[ "$_origin_owner" != "$_origin_url" ]; then
|
||||||
|
DEJIMA_FORGE_REPO="$_origin_owner/$_origin_name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# WORKSPACE_DIR is resolved above, ahead of --print-briefing, since
|
# WORKSPACE_DIR is resolved above, ahead of --print-briefing, since
|
||||||
# briefing_compose needs DEJIMA_RW_MOUNTS (which needs WORKSPACE_DIR for its
|
# briefing_compose needs DEJIMA_RW_MOUNTS (which needs WORKSPACE_DIR for its
|
||||||
# own overlap guard) before that point.
|
# own overlap guard) before that point.
|
||||||
@@ -878,6 +1044,7 @@ SANDBOX_DERIVED_CONTEXT=""
|
|||||||
SANDBOX_CONVERSATION_SHIP_PID=""
|
SANDBOX_CONVERSATION_SHIP_PID=""
|
||||||
SANDBOX_TTY_SUSPEND_PID=""
|
SANDBOX_TTY_SUSPEND_PID=""
|
||||||
SANDBOX_TTY_MODES=""
|
SANDBOX_TTY_MODES=""
|
||||||
|
SANDBOX_CLIENT_PID=""
|
||||||
|
|
||||||
# Names each host-side task in cleanup() as it starts. Between the container
|
# Names each host-side task in cleanup() as it starts. Between the container
|
||||||
# exiting and the operator's shell prompt returning, dejima audits every
|
# exiting and the operator's shell prompt returning, dejima audits every
|
||||||
@@ -924,13 +1091,15 @@ cleanup() {
|
|||||||
# rather than dying with the batch. Reaped for the same reason the proxy
|
# 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
|
# is: an unreaped child reparents to PID 1 and stays a zombie wherever
|
||||||
# PID 1 does not reap.
|
# PID 1 does not reap.
|
||||||
# The terminal first, and its settings before its watcher: the container
|
# The terminal first, and its settings before their watcher: the
|
||||||
# run restores what it saved when it attached, but a run that was killed
|
# container run restores what it saved when it attached, but a run that
|
||||||
# rather than exited restores nothing, and what it would leave behind is a
|
# was killed rather than exited restores nothing, and what that leaves
|
||||||
# terminal where Ctrl-C is undefined. Cheap, and only does anything when
|
# behind is a terminal with no interrupt character.
|
||||||
# there was a terminal to save.
|
|
||||||
if [ -n "${SANDBOX_TTY_MODES:-}" ]; then
|
if [ -n "${SANDBOX_TTY_MODES:-}" ]; then
|
||||||
stty "$SANDBOX_TTY_MODES" < /dev/tty 2>/dev/null || true
|
stty "$SANDBOX_TTY_MODES" < /dev/tty 2>/dev/null || true
|
||||||
|
# And the cursor, for the same reason the modes are restored here: a
|
||||||
|
# run that was killed rather than exited put neither back.
|
||||||
|
printf '\033[?25h' > /dev/tty 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
|
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
|
||||||
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
|
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
|
||||||
@@ -984,7 +1153,20 @@ cleanup() {
|
|||||||
# Ahead of the audit rather than after it, so the audit keeps the last
|
# Ahead of the audit rather than after it, so the audit keeps the last
|
||||||
# word (see below): this prints a line of its own whenever it keeps
|
# word (see below): this prints a line of its own whenever it keeps
|
||||||
# something, and that line was landing under the verdict.
|
# something, and that line was landing under the verdict.
|
||||||
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
# Gated here as well as inside the tool, because a flag override lives in
|
||||||
|
# this process and the tool re-reads the config for itself - it is also
|
||||||
|
# run by hand, where an environment this launcher happened to have would
|
||||||
|
# be the wrong authority. Same reason the shipper is gated at its start.
|
||||||
|
#
|
||||||
|
# DEJIMA_EPITAPH_NO_SHIP carries the one part of --no-log that this
|
||||||
|
# process cannot decide for the tool: whether to push what it kept. It
|
||||||
|
# only ever suppresses, so a stale export makes epitaph quieter and never
|
||||||
|
# louder, which is what makes it safe to honour from the environment at
|
||||||
|
# all.
|
||||||
|
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
|
||||||
|
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
|
||||||
|
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
||||||
|
fi
|
||||||
# Last writer, structurally: the operator reads bottom-up from their
|
# Last writer, structurally: the operator reads bottom-up from their
|
||||||
# shell prompt, so anything printed after the verdict pushes it out of
|
# shell prompt, so anything printed after the verdict pushes it out of
|
||||||
# view. The marker-directory warning above is the one line a session can
|
# view. The marker-directory warning above is the one line a session can
|
||||||
@@ -1019,6 +1201,23 @@ if [ -f "$HOME/.gitconfig" ]; then
|
|||||||
else
|
else
|
||||||
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
|
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
|
||||||
fi
|
fi
|
||||||
|
# The selected identity's git name, appended to the COPY. Appended rather than
|
||||||
|
# rewritten because git takes the last value for a key, so this needs no
|
||||||
|
# knowledge of what the operator's own file already says - and because the
|
||||||
|
# copy is the only thing being edited, the real ~/.gitconfig is untouched
|
||||||
|
# whatever happens here.
|
||||||
|
#
|
||||||
|
# `git config -f` rather than a printf of "[user]\n\tname = ...": the value is
|
||||||
|
# the operator's own text, and git quotes and escapes its own file format
|
||||||
|
# correctly where a printf would produce something git reads differently the
|
||||||
|
# moment a name contains a quote or a backslash.
|
||||||
|
for _git_field in name email; do
|
||||||
|
_git_value="$(identity_git_field "user_$_git_field")" || _git_value=""
|
||||||
|
[ -n "$_git_value" ] || continue
|
||||||
|
git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" "user.$_git_field" "$_git_value" \
|
||||||
|
|| { echo "could not set user.$_git_field from [identity] - is git installed?" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
unset _git_field _git_value
|
||||||
|
|
||||||
export WORKSPACE_DIR
|
export WORKSPACE_DIR
|
||||||
# The image's user is built to match this uid/gid/username exactly (see
|
# The image's user is built to match this uid/gid/username exactly (see
|
||||||
@@ -1062,8 +1261,8 @@ export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/#
|
|||||||
|
|
||||||
# core.command permits a leading "-" (it is validated as a generic identifier,
|
# core.command permits a leading "-" (it is validated as a generic identifier,
|
||||||
# shared with other keys that have no such concern) but entrypoint.sh expands
|
# shared with other keys that have no such concern) but entrypoint.sh expands
|
||||||
# it unquoted into `gosu "$SANDBOX_USER" ${SANDBOX_CMD:-claude} "$@"` - a
|
# it unquoted into `gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"` - a leading dash
|
||||||
# leading dash there would be parsed as an option to gosu, not as the command.
|
# there would be parsed as an option to gosu, not as the command.
|
||||||
# Refuse here, at the point this value is wired in, rather than relax the
|
# Refuse here, at the point this value is wired in, rather than relax the
|
||||||
# schema type for every other identifier-typed key.
|
# schema type for every other identifier-typed key.
|
||||||
SANDBOX_CMD="$(config_get core command)"
|
SANDBOX_CMD="$(config_get core command)"
|
||||||
@@ -1169,8 +1368,26 @@ LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL
|
|||||||
extra_mounts=()
|
extra_mounts=()
|
||||||
|
|
||||||
# The forge's own env vars, generated from its manifest's env_export list
|
# The forge's own env vars, generated from its manifest's env_export list
|
||||||
# rather than hardcoded - see forge_env_exports. -e here: passed straight
|
# rather than hardcoded - see forge_env_exports.
|
||||||
# through to `docker compose run`, which takes -e alongside -v.
|
#
|
||||||
|
# `-e NAME`, never `-e NAME=value`, for the ones that carry a secret - which
|
||||||
|
# is every `@` reference, since those resolve to a credential FILE'S CONTENTS
|
||||||
|
# rather than its path. A value in the argument list lands in
|
||||||
|
# /proc/<pid>/cmdline, which is world-readable: `ps` prints the token to any
|
||||||
|
# process on the host, and from there into whatever captures ps output. That
|
||||||
|
# is not a hypothetical - it is how #226 was found, in a paste an operator
|
||||||
|
# made while debugging something unrelated.
|
||||||
|
#
|
||||||
|
# The bare form takes the value from THIS process's environment instead,
|
||||||
|
# where it sits in /proc/<pid>/environ - mode 0400, owner only.
|
||||||
|
#
|
||||||
|
# This repository already made the argument one layer down: a forge's own CLI
|
||||||
|
# feeds curl its auth header on stdin rather than as an argument, and says why
|
||||||
|
# in almost these words. The reasoning was right and it had not reached here.
|
||||||
|
#
|
||||||
|
# Docker still shows the resolved value to anyone who can `docker inspect`
|
||||||
|
# the container, which the bare form does not change and cannot. What it
|
||||||
|
# changes is the audience: daemon access rather than any process at all.
|
||||||
_forge_env_lines=""
|
_forge_env_lines=""
|
||||||
if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
|
if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
|
||||||
# forge_env_exports has already explained why on stderr. It can fail
|
# forge_env_exports has already explained why on stderr. It can fail
|
||||||
@@ -1180,8 +1397,15 @@ if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
|
|||||||
fi
|
fi
|
||||||
while IFS= read -r _forge_env; do
|
while IFS= read -r _forge_env; do
|
||||||
[ -n "$_forge_env" ] || continue
|
[ -n "$_forge_env" ] || continue
|
||||||
extra_mounts+=(-e "$_forge_env")
|
# forge_env_exports emits nothing at all for a value that resolved empty,
|
||||||
|
# deliberately - see its own comment on why an empty -e for a variable
|
||||||
|
# compose already declares is not harmless. So every name reaching here
|
||||||
|
# has a value, and exporting it is what makes the bare -e below carry one.
|
||||||
|
_forge_env_name="${_forge_env%%=*}"
|
||||||
|
export "${_forge_env_name}=${_forge_env#*=}"
|
||||||
|
extra_mounts+=(-e "$_forge_env_name")
|
||||||
done <<< "$_forge_env_lines"
|
done <<< "$_forge_env_lines"
|
||||||
|
unset _forge_env_name
|
||||||
|
|
||||||
# The resolved combination, for the briefing and the hooks that read it
|
# The resolved combination, for the briefing and the hooks that read it
|
||||||
# (git-push-guard.sh's host allowlist and no-direct-push branches,
|
# (git-push-guard.sh's host allowlist and no-direct-push branches,
|
||||||
@@ -1195,6 +1419,9 @@ extra_mounts+=(-e "DEJIMA_PUSH_HOSTS=$DEJIMA_PUSH_HOSTS")
|
|||||||
extra_mounts+=(-e "DEJIMA_BLOCKED_BRANCHES=$DEJIMA_BLOCKED_BRANCHES")
|
extra_mounts+=(-e "DEJIMA_BLOCKED_BRANCHES=$DEJIMA_BLOCKED_BRANCHES")
|
||||||
extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
|
extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
|
||||||
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
|
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
|
||||||
|
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
|
||||||
|
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
|
||||||
|
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
|
||||||
|
|
||||||
# The harness's own configuration, mounted at the mode it declared, and -
|
# The harness's own configuration, mounted at the mode it declared, and -
|
||||||
# for the directories it marked scannable - the symlink targets that have to
|
# for the directories it marked scannable - the symlink targets that have to
|
||||||
@@ -1639,6 +1866,59 @@ fi
|
|||||||
# or one after a Dockerfile change, builds the image and takes minutes rather
|
# or one after a Dockerfile change, builds the image and takes minutes rather
|
||||||
# than seconds - the line names that, since it is the case where someone is
|
# than seconds - the line names that, since it is the case where someone is
|
||||||
# most likely to give up and interrupt.
|
# most likely to give up and interrupt.
|
||||||
|
# What this launch is, before the long silent part. The startup output is
|
||||||
|
# otherwise a list of checks with no point at which it says what it is, and
|
||||||
|
# the facts below are the ones that differ between one launch and the next -
|
||||||
|
# the ones an operator otherwise has to remember or go and read (#211).
|
||||||
|
#
|
||||||
|
# Deliberately not a config dump. The value is seeing at a glance that this
|
||||||
|
# launch is not configured like the last one, so what is here is the selection
|
||||||
|
# plus the switches whose state changes what a session can do.
|
||||||
|
#
|
||||||
|
# In bin/dejima's own palette rather than the pickers': dejima-color-lib.sh
|
||||||
|
# and dejima-tui-lib.sh both define C_OK and C_WARN, with different values, so
|
||||||
|
# sourcing the second here would silently recolour every warning this script
|
||||||
|
# already prints. Unifying the two palettes is worth doing and is not this.
|
||||||
|
startup_banner() {
|
||||||
|
[ "$(config_get_bool ui banner)" = "1" ] || return 0
|
||||||
|
# Nothing at all when stderr is not a terminal: a redirected launch is a
|
||||||
|
# log, and this is decoration in it.
|
||||||
|
[ -t 2 ] || return 0
|
||||||
|
|
||||||
|
local branch model domains firewall remote epitaph
|
||||||
|
branch="$(git -C "$WORKSPACE_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null)" || branch=""
|
||||||
|
model="$(harness_config_get model 2>/dev/null)" || model=""
|
||||||
|
# The count, not the list: the list is long and the number is what changes.
|
||||||
|
domains="$(printf '%s' "${ALLOWED_DOMAINS:-}" | wc -w)"
|
||||||
|
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||||
|
firewall="${C_OK}on${C_RESET} ($domains domains)"
|
||||||
|
else
|
||||||
|
# The one switch worth a colour of its own: everything else here is a
|
||||||
|
# choice, and this one is a boundary that is not in force.
|
||||||
|
firewall="${C_WARN}off${C_RESET}"
|
||||||
|
fi
|
||||||
|
remote="$(remote_enabled && echo on || echo off)"
|
||||||
|
epitaph="$([ "$(config_get_bool epitaph enabled)" = 1 ] && echo on || echo off)"
|
||||||
|
|
||||||
|
# Labels dim, values not. The labels are how you find the value you came
|
||||||
|
# for; the values are what differs between one launch and the next.
|
||||||
|
# C_BADGE, the same name the pickers set the mark in. Two surfaces
|
||||||
|
# spelling the wordmark two different colours is the drift a shared
|
||||||
|
# palette exists to stop.
|
||||||
|
printf '\n %sdejima%s %s%s%s' \
|
||||||
|
"$C_BADGE" "$C_RESET" "$C_KEY" "$(basename -- "$WORKSPACE_DIR")" "$C_RESET" >&2
|
||||||
|
[ -z "$branch" ] || printf ' %s%s%s %s' "$C_DIM" "$GLYPH_SEP" "$C_RESET" "$branch" >&2
|
||||||
|
printf '\n %sharness%s %s %sforge%s %s %sworkflow%s %s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$DEJIMA_HARNESS_NAME${model:+ ($model)}" \
|
||||||
|
"$C_DIM" "$C_RESET" "$DEJIMA_FORGE_NAME" \
|
||||||
|
"$C_DIM" "$C_RESET" "$DEJIMA_WORKFLOW_NAME" >&2
|
||||||
|
printf ' %sfirewall%s %s %sremote%s %s %sepitaph%s %s\n\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$firewall" \
|
||||||
|
"$C_DIM" "$C_RESET" "$remote" \
|
||||||
|
"$C_DIM" "$C_RESET" "$epitaph" >&2
|
||||||
|
}
|
||||||
|
startup_banner
|
||||||
|
|
||||||
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
||||||
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
|
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
|
||||||
"$C_DIM" "$C_RESET" >&2
|
"$C_DIM" "$C_RESET" >&2
|
||||||
@@ -1663,6 +1943,12 @@ if ! _unwired="$(harness_guards_wired)"; then
|
|||||||
printf ' Run %s./install.sh%s to register them.\n' "$C_KEY" "$C_RESET" >&2
|
printf ' Run %s./install.sh%s to register them.\n' "$C_KEY" "$C_RESET" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Before the image build, which is the long part: an operator who wants the
|
||||||
|
# newer dejima would rather know now than after waiting for a build of the one
|
||||||
|
# they have. Advisory and best-effort - see bin/dejima-update-check for why it
|
||||||
|
# never reaches the remote through this checkout's own git config.
|
||||||
|
"$SCRIPT_DIR/dejima-update-check" || true
|
||||||
|
|
||||||
# Snapshot host-persistence surfaces so the post-run audit can report
|
# Snapshot host-persistence surfaces so the post-run audit can report
|
||||||
# anything that changed while this session ran (advisory - see bin/dejima-audit).
|
# anything that changed while this session ran (advisory - see bin/dejima-audit).
|
||||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||||
@@ -1672,8 +1958,9 @@ fi
|
|||||||
# below will report any change to it.
|
# below will report any change to it.
|
||||||
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
|
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
|
||||||
echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
|
echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
|
||||||
echo "computing base (bin/, hooks/, docker/, install.sh) is writable this" >&2
|
echo "computing base (bin/, hooks/, docker/, test/, install.sh, and the" >&2
|
||||||
echo "session; the exit audit will report any changes to review." >&2
|
echo "provider and tool manifests) is writable this session; the exit audit" >&2
|
||||||
|
echo "will report any changes to review." >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Compose builds the shared base image under its own tag, explicitly - with
|
# Compose builds the shared base image under its own tag, explicitly - with
|
||||||
@@ -1729,6 +2016,7 @@ SANDBOX_HARNESS_IMAGE_FILES="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image
|
|||||||
|
|
||||||
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
||||||
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ] \
|
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ] \
|
||||||
|
|| [ -n "$DEJIMA_TOOLS" ] \
|
||||||
|| [ -s "$(config_root)/dependencies" ] \
|
|| [ -s "$(config_root)/dependencies" ] \
|
||||||
|| { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
|
|| { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
|
||||||
SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
|
SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
|
||||||
@@ -1757,6 +2045,34 @@ if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
|||||||
SANDBOX_IMAGE="$tag"
|
SANDBOX_IMAGE="$tag"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# One layer per tool, above the harness and below the dependencies. Above the
|
||||||
|
# harness because a tool is meant to be on PATH for it; below the operator's
|
||||||
|
# own dependencies because those are the last word on what the image holds.
|
||||||
|
#
|
||||||
|
# Tagged by name and version-of-spec so two projects wanting the same tool
|
||||||
|
# share a layer, and a manifest that changes what it installs gets a new one
|
||||||
|
# rather than a stale cached hit.
|
||||||
|
for _tool in $DEJIMA_TOOLS; do
|
||||||
|
_tool_install="$(manifest_get tool "$_tool" install)"
|
||||||
|
_tool_env="$(manifest_get tool "$_tool" image_env)"
|
||||||
|
_tool_layout="$(manifest_get tool "$_tool" install_layout)"
|
||||||
|
tag="dejima:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
|
||||||
|
DEJIMA_TARBALL_STRIP="$_tool_layout" \
|
||||||
|
generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
|
||||||
|
> "$SANDBOX_DERIVED_DOCKERFILE"
|
||||||
|
echo "Building $tag ($_tool: $(manifest_get tool "$_tool" description))..."
|
||||||
|
if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
|
||||||
|
# No fallback, for the reason the harness layer gives: a session told
|
||||||
|
# it has a tool and given an image without one fails inside the
|
||||||
|
# container, where it reads as dejima being broken.
|
||||||
|
echo "Failed to build $tag." >&2
|
||||||
|
echo "$_tool installs from: $_tool_install" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SANDBOX_IMAGE="$tag"
|
||||||
|
done
|
||||||
|
unset _tool _tool_install _tool_env _tool_layout
|
||||||
|
|
||||||
if [ -s "$(config_root)/dependencies" ]; then
|
if [ -s "$(config_root)/dependencies" ]; then
|
||||||
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
|
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
|
||||||
fi
|
fi
|
||||||
@@ -1854,23 +2170,108 @@ compose_status=0
|
|||||||
#
|
#
|
||||||
# `|| true` and a backgrounded start: shipping is a bonus on top of the
|
# `|| true` and a backgrounded start: shipping is a bonus on top of the
|
||||||
# session, so a shipper that cannot start must not stop the session running.
|
# session, so a shipper that cannot start must not stop the session running.
|
||||||
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
|
#
|
||||||
SANDBOX_CONVERSATION_SHIP_PID=$!
|
# Gated here as well as inside the shipper, which checks the same two keys for
|
||||||
|
# itself: --no-conversation and --no-log are overrides held in this process,
|
||||||
|
# and the shipper re-reads the config rather than inheriting them.
|
||||||
|
if [ "$(config_get_bool logging conversation)" = "1" ] &&
|
||||||
|
[ -n "$(config_get logging loki_url)" ]; then
|
||||||
|
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
|
||||||
|
SANDBOX_CONVERSATION_SHIP_PID=$!
|
||||||
|
fi
|
||||||
|
|
||||||
# Ctrl-Z, which without this is not a signal at all: the container run puts
|
# Ctrl-Z (#179). Two things have to be true at once, and the second is the
|
||||||
# the terminal in raw mode, so 0x1a is delivered into the container, where a
|
# one that took four rounds of measurement to find.
|
||||||
# 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
|
# The keystroke has to become a signal at all. Under a container run the
|
||||||
# that the terminal is only altered while a session is on it, and backgrounded
|
# terminal is raw, ISIG is off, and Ctrl-Z is the byte 0x1a delivered into the
|
||||||
# into this same process group so the kernel stops and continues it with
|
# container - where a harness that implements suspend stops itself, with no
|
||||||
# everything else.
|
# job-control shell in there to continue it. dejima-tty-suspend takes that one
|
||||||
|
# character back for the host.
|
||||||
|
#
|
||||||
|
# And the container client must never receive SIGTSTP. Measured, holding
|
||||||
|
# everything else equal: the client survives SIGSTOP and does not survive
|
||||||
|
# SIGTSTP. A catchable stop signal interrupts the read it is blocked in, its
|
||||||
|
# input forwarding never recovers, and the session comes back alive but deaf -
|
||||||
|
# resumed, with nothing typed reaching the harness. That is what shipped once
|
||||||
|
# and had to be taken out again.
|
||||||
|
#
|
||||||
|
# So the client is started with SIGTSTP ignored, which it inherits and keeps
|
||||||
|
# across exec, and the launcher converts the keystroke into an explicit
|
||||||
|
# SIGSTOP for it before stopping itself. The job still stops completely, so
|
||||||
|
# the prompt comes back; the client is stopped by a signal that interrupts
|
||||||
|
# nothing.
|
||||||
|
#
|
||||||
|
# < /dev/tty because a background command is given /dev/null for stdin, and a
|
||||||
|
# container client with no terminal reads EOF and exits at once.
|
||||||
SANDBOX_TTY_MODES="$(stty -g < /dev/tty 2>/dev/null)" || SANDBOX_TTY_MODES=""
|
SANDBOX_TTY_MODES="$(stty -g < /dev/tty 2>/dev/null)" || SANDBOX_TTY_MODES=""
|
||||||
if [ -n "$SANDBOX_TTY_MODES" ]; then
|
if [ -n "$SANDBOX_TTY_MODES" ]; then
|
||||||
"$SCRIPT_DIR/dejima-tty-suspend" &
|
"$SCRIPT_DIR/dejima-tty-suspend" &
|
||||||
SANDBOX_TTY_SUSPEND_PID=$!
|
SANDBOX_TTY_SUSPEND_PID=$!
|
||||||
fi
|
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
|
trap '' TSTP
|
||||||
|
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" < /dev/tty &
|
||||||
|
SANDBOX_CLIENT_PID=$!
|
||||||
|
trap - TSTP
|
||||||
|
|
||||||
|
# The pid $! names is the docker CLI; `docker compose run` then runs the
|
||||||
|
# compose plugin as a child of it, and the child is what holds the
|
||||||
|
# terminal. Stopping only the pid leaves that child in raw mode with the
|
||||||
|
# shell also on the terminal - a cursorless prompt that resumes to
|
||||||
|
# nothing, which is what the first version of this fix produced.
|
||||||
|
#
|
||||||
|
# Deepest first, so nothing is left running long enough to fork again;
|
||||||
|
# shallowest first on the way back, so a parent is ready before its child
|
||||||
|
# resumes.
|
||||||
|
_stop_tree() {
|
||||||
|
local p="$1" kid
|
||||||
|
for kid in $(pgrep -P "$p" 2>/dev/null); do _stop_tree "$kid"; done
|
||||||
|
kill -STOP "$p" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
_cont_tree() {
|
||||||
|
local p="$1" kid
|
||||||
|
kill -CONT "$p" 2>/dev/null || true
|
||||||
|
for kid in $(pgrep -P "$p" 2>/dev/null); do _cont_tree "$kid"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
_on_tstp() {
|
||||||
|
_stop_tree "$SANDBOX_CLIENT_PID"
|
||||||
|
# Show the cursor again. A full-screen harness hides it (DECTCEM,
|
||||||
|
# \033[?25l) and puts it back when it exits - but a suspend is not an
|
||||||
|
# exit, so the shell prompt inherits an invisible cursor and the
|
||||||
|
# terminal looks like a connection that died. The harness hides it
|
||||||
|
# again on resume, when the SIGWINCH below makes it repaint.
|
||||||
|
printf '\033[?25h' > /dev/tty 2>/dev/null || true
|
||||||
|
# Default disposition to actually stop, then put the handler back for
|
||||||
|
# the next time. Everything after the re-raise runs on resume.
|
||||||
|
trap - TSTP
|
||||||
|
kill -TSTP $$
|
||||||
|
trap _on_tstp TSTP
|
||||||
|
_cont_tree "$SANDBOX_CLIENT_PID"
|
||||||
|
}
|
||||||
|
trap _on_tstp TSTP
|
||||||
|
|
||||||
|
# Looped because `wait` returns early when a trap runs, which is exactly
|
||||||
|
# what happens on every suspend.
|
||||||
|
while kill -0 "$SANDBOX_CLIENT_PID" 2>/dev/null; do
|
||||||
|
if wait "$SANDBOX_CLIENT_PID"; then
|
||||||
|
compose_status=0
|
||||||
|
else
|
||||||
|
compose_status=$?
|
||||||
|
fi
|
||||||
|
# 128+n means a signal interrupted the wait rather than the client
|
||||||
|
# exiting; anything else is the client's own status.
|
||||||
|
case "$compose_status" in
|
||||||
|
146|148) continue ;;
|
||||||
|
esac
|
||||||
|
break
|
||||||
|
done
|
||||||
|
trap - TSTP
|
||||||
|
else
|
||||||
|
# No terminal - a CI job, or output redirected. Nothing to suspend, and
|
||||||
|
# /dev/tty would not open.
|
||||||
|
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
|
||||||
|
fi
|
||||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
|
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
|
||||||
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
|
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
|
||||||
echo "the session may have been failing. Check for proxy errors in the output above." >&2
|
echo "the session may have been failing. Check for proxy errors in the output above." >&2
|
||||||
|
|||||||
+126
-8
@@ -29,9 +29,14 @@ Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
|||||||
|
|
||||||
Scope, which decides who the grant is for and how long it lasts:
|
Scope, which decides who the grant is for and how long it lasts:
|
||||||
(none) the running container, right now, until it exits. Finds it
|
(none) the running container, right now, until it exits. Finds it
|
||||||
if exactly one is running; refuses if none or several.
|
if exactly one is running. With several, it offers them to
|
||||||
|
pick from on a terminal, and lists them and stops anywhere
|
||||||
|
else - a prompt is no use to a script.
|
||||||
--container <id> the same, for a named container. Use this when a session
|
--container <id> the same, for a named container. Use this when a session
|
||||||
told you its own id rather than letting anything guess.
|
told you its own id rather than letting anything guess.
|
||||||
|
With several running, they are listed by project and by
|
||||||
|
the number the dejima-attach picker gives each one. On a
|
||||||
|
terminal it offers them to pick from instead.
|
||||||
--project [path] every future session in one project. Also writes the domain
|
--project [path] every future session in one project. Also writes the domain
|
||||||
to that project's .dejima/allowed-domains, so it is shared
|
to that project's .dejima/allowed-domains, so it is shared
|
||||||
with everyone working on it. Path defaults to $PWD.
|
with everyone working on it. Path defaults to $PWD.
|
||||||
@@ -61,6 +66,11 @@ source "$SCRIPT_DIR/dejima-project-lib.sh"
|
|||||||
# reason init-firewall.sh and dns-proxy.sh share this file.
|
# reason init-firewall.sh and dns-proxy.sh share this file.
|
||||||
# shellcheck source=../docker/fixed-domains.sh
|
# shellcheck source=../docker/fixed-domains.sh
|
||||||
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||||
|
# The palette, heading and prompt dejima-attach uses. Picking one session out
|
||||||
|
# of several is the same job here, and an operator holding one terminal should
|
||||||
|
# not meet two different answers to it.
|
||||||
|
# shellcheck source=dejima-tui-lib.sh
|
||||||
|
source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
||||||
@@ -129,25 +139,133 @@ fi
|
|||||||
# This is the only thing that decides. Deliberately not an environment
|
# This is the only thing that decides. Deliberately not an environment
|
||||||
# variable as well: a firewall CLI with an environment-triggered no-op path
|
# variable as well: a firewall CLI with an environment-triggered no-op path
|
||||||
# is one an env var can silently disarm.
|
# is one an env var can silently disarm.
|
||||||
|
# A container list an operator can act on without asking the agent anything.
|
||||||
|
#
|
||||||
|
# The name alone is a run id, which is exactly what someone with three
|
||||||
|
# sessions open does not know. What they do know is which project each one is
|
||||||
|
# working on, and - where remote access is on - the number the dejima-attach
|
||||||
|
# picker gives it, because that is the list they are already reading.
|
||||||
|
#
|
||||||
|
# The project comes from the container's own working directory rather than
|
||||||
|
# from the session registry, so it is there for every session and not only a
|
||||||
|
# remote-enabled one. The picker number comes from `dejima-attach --list`,
|
||||||
|
# whose rows the picker numbers 1..N in the order they are printed; absent
|
||||||
|
# where remote access is off, which is a column missing rather than a failure.
|
||||||
|
#
|
||||||
|
# Reads only. Nothing here decides which container is acted on - that is the
|
||||||
|
# caller's --container, or the single running one.
|
||||||
|
# Fills CT_IDS, CT_NAMES, CT_PROJECTS and CT_ATTACH, in the order docker
|
||||||
|
# listed them, and measures the widest project and name for the renderers
|
||||||
|
# below. Padding to the widest value rather than to a guess is not cosmetic
|
||||||
|
# here: "dejima-sandbox-run-" plus twelve hex digits is 31 characters, so a
|
||||||
|
# fixed 28 ran every name straight into the project beside it and there was
|
||||||
|
# no telling where one ended.
|
||||||
|
CT_IDS=(); CT_NAMES=(); CT_PROJECTS=(); CT_ATTACH=()
|
||||||
|
CT_W_NAME=0; CT_W_PROJECT=0; CT_HAS_ATTACH=0
|
||||||
|
collect_containers() {
|
||||||
|
local listing="$1" name id project row n=0 idx
|
||||||
|
local -A attach_index=()
|
||||||
|
while IFS=$'\t' read -r _ _ _ _ _ row; do
|
||||||
|
[ -n "$row" ] || continue
|
||||||
|
n=$((n + 1))
|
||||||
|
attach_index["$row"]="$n"
|
||||||
|
done < <(dejima-attach --list 2>/dev/null || true)
|
||||||
|
while IFS=$'\t' read -r name id; do
|
||||||
|
[ -n "$name" ] || continue
|
||||||
|
project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
|
||||||
|
[ -n "$project" ] && project="$(basename "$project")"
|
||||||
|
project="${project:-?}"
|
||||||
|
idx="${attach_index[${id:0:12}]:-}"
|
||||||
|
CT_IDS+=("$id"); CT_NAMES+=("$name"); CT_PROJECTS+=("$project")
|
||||||
|
CT_ATTACH+=("$idx")
|
||||||
|
[ -z "$idx" ] || CT_HAS_ATTACH=1
|
||||||
|
[ "${#name}" -le "$CT_W_NAME" ] || CT_W_NAME="${#name}"
|
||||||
|
[ "${#project}" -le "$CT_W_PROJECT" ] || CT_W_PROJECT="${#project}"
|
||||||
|
done <<< "$listing"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The non-interactive form: a list to read, with the flag that acts on a row.
|
||||||
|
# This is what a script gets, and what an agent is shown when it asks the
|
||||||
|
# operator to run one of these.
|
||||||
|
describe_containers() {
|
||||||
|
local i
|
||||||
|
for i in "${!CT_IDS[@]}"; do
|
||||||
|
printf ' %-*s %-*s %s\n' \
|
||||||
|
"$CT_W_PROJECT" "${CT_PROJECTS[$i]}" \
|
||||||
|
"$CT_W_NAME" "${CT_NAMES[$i]}" \
|
||||||
|
"${CT_ATTACH[$i]:+(dejima-attach ${CT_ATTACH[$i]})}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# The interactive form. Same rows, numbered, under dejima-attach's heading -
|
||||||
|
# an operator with several sessions open is choosing between the same things
|
||||||
|
# in both tools, and picking by project is the only way they can.
|
||||||
|
#
|
||||||
|
# Headed, where dejima-attach's rows are not: there the coloured state word
|
||||||
|
# says what each column is, and vertical space on a phone is the scarce
|
||||||
|
# thing. Here the columns are two names and a number, which say nothing about
|
||||||
|
# themselves.
|
||||||
|
pick_container() {
|
||||||
|
local i
|
||||||
|
tui_banner 'running sessions'
|
||||||
|
# The attach column is absent, not blank, when no session has a number -
|
||||||
|
# remote access off is the ordinary case, and a labelled empty column
|
||||||
|
# reads as something that failed.
|
||||||
|
if [ "$CT_HAS_ATTACH" -eq 1 ]; then
|
||||||
|
printf ' %s%-*s %-*s %s%s\n' "$C_SUBTLE" \
|
||||||
|
"$CT_W_PROJECT" "project" "$CT_W_NAME" "container" "attach" "$C_OFF"
|
||||||
|
else
|
||||||
|
printf ' %s%-*s %s%s\n' "$C_SUBTLE" \
|
||||||
|
"$CT_W_PROJECT" "project" "container" "$C_OFF"
|
||||||
|
fi
|
||||||
|
for i in "${!CT_IDS[@]}"; do
|
||||||
|
printf ' %s%2d%s %s%-*s%s %s%-*s%s %s%s%s\n' \
|
||||||
|
"$C_WARN" "$((i + 1))" "$C_OFF" \
|
||||||
|
"$C_BOLD$C_TEXT" "$CT_W_PROJECT" "${CT_PROJECTS[$i]}" "$C_OFF" \
|
||||||
|
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
|
||||||
|
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
|
||||||
|
done
|
||||||
|
tui_pick "${#CT_IDS[@]}" "allow $domain in"
|
||||||
|
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
|
||||||
|
}
|
||||||
|
|
||||||
session_wanted=1
|
session_wanted=1
|
||||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||||
[ -n "$container" ] || session_wanted=0
|
[ -n "$container" ] || session_wanted=0
|
||||||
fi
|
fi
|
||||||
if [ "$session_wanted" -eq 1 ]; then
|
if [ "$session_wanted" -eq 1 ]; then
|
||||||
if [ -z "$container" ]; then
|
if [ -z "$container" ]; then
|
||||||
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" --filter "status=running" --format '{{.Names}}')
|
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" \
|
||||||
|
--filter "status=running" --format '{{.Names}} {{.ID}}')
|
||||||
count=$(echo "$containers" | grep -c . || true)
|
count=$(echo "$containers" | grep -c . || true)
|
||||||
if [ "$count" -eq 0 ]; then
|
if [ "$count" -eq 0 ]; then
|
||||||
echo "No running dejima container found." >&2
|
echo "No running dejima container found." >&2
|
||||||
exit 1
|
exit 1
|
||||||
elif [ "$count" -gt 1 ]; then
|
elif [ "$count" -gt 1 ]; then
|
||||||
echo "Multiple running dejima containers found; specify one with --container:" >&2
|
collect_containers "$containers"
|
||||||
echo "$containers" >&2
|
# A person at a terminal gets to choose; anything else gets the
|
||||||
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
|
# list and the flag. Both ends are checked because the prompt is
|
||||||
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
|
# useless without somewhere to draw it and something to read the
|
||||||
exit 1
|
# answer from, and half of either is how a script ends up hung on
|
||||||
|
# a read nobody can answer.
|
||||||
|
if [ -t 0 ] && [ -t 1 ]; then
|
||||||
|
pick_container
|
||||||
|
else
|
||||||
|
echo "Multiple running dejima containers found; specify one with --container:" >&2
|
||||||
|
echo "" >&2
|
||||||
|
describe_containers >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
|
||||||
|
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
container="$containers"
|
# Only when nothing above chose one. This line used to be reachable
|
||||||
|
# only in the single-container case, because the multi-container
|
||||||
|
# branch exited - now that the picker returns from it, an
|
||||||
|
# unconditional assignment here would silently discard the choice and
|
||||||
|
# act on whichever session docker happened to list first.
|
||||||
|
[ -n "$container" ] || container="${containers%%$'\t'*}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||||
|
|||||||
+12
-76
@@ -57,55 +57,20 @@ SESSIONS_DIR="$STATE_DIR/sessions"
|
|||||||
|
|
||||||
# gruvbox dark, every value upstream's and tagged with its color.table row:
|
# gruvbox dark, every value upstream's and tagged with its color.table row:
|
||||||
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
|
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
|
||||||
# The names are carried so a wrong hex is catchable by reading.
|
# The palette, the heading and the prompt come from the shared library: this
|
||||||
#
|
# and dejima-allow both ask an operator to pick a session, and two copies of
|
||||||
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
|
# that would drift apart on the terminal an operator is actually holding.
|
||||||
# 38;2 to its nearest colour rather than printing the escape, so the fallback
|
# shellcheck source=dejima-tui-lib.sh
|
||||||
# already exists one layer down.
|
source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||||
#
|
|
||||||
# Empty unless stdout is a colour-capable terminal, which keeps --list a
|
|
||||||
# machine interface (ttyd feeds run ids back in) and is also how the bats
|
|
||||||
# suite reads this script.
|
|
||||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
|
|
||||||
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
|
|
||||||
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
|
|
||||||
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
|
|
||||||
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
|
|
||||||
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
|
|
||||||
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
|
|
||||||
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
|
|
||||||
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
|
|
||||||
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
|
|
||||||
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
|
|
||||||
GLYPH_BAR='▏'; GLYPH_DOT='●'
|
|
||||||
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
|
|
||||||
# would shred a 3-byte box-drawing character, and every alternative costs
|
|
||||||
# a loop or a subshell to draw a fixed line. Both are the width of the
|
|
||||||
# heading they sit under ("dejima | live sessions"), and are next to it so
|
|
||||||
# they stay that way.
|
|
||||||
RULE_LINE='──────────────────────'
|
|
||||||
else
|
|
||||||
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
|
|
||||||
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
|
|
||||||
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
|
|
||||||
# because no-colour implies no-UTF-8, but because the two terminals this
|
|
||||||
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
|
|
||||||
# are exactly the ones that mangle box drawing, and one flag is easier to
|
|
||||||
# reason about than two.
|
|
||||||
GLYPH_BAR='|'; GLYPH_DOT='*'
|
|
||||||
RULE_LINE='----------------------'
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Narrow deliberately. The phone is this script's main front door, and the
|
# Narrow deliberately. The phone is this script's main front door, and the
|
||||||
# rule underlines the heading instead of spanning the table because the
|
# rule underlines the heading instead of spanning the table because the
|
||||||
# table's width follows the longest project name - a rule that chased it would
|
# table's width follows the longest project name - a rule that chased it would
|
||||||
# wrap on a phone exactly when the rows did.
|
# wrap on a phone exactly when the rows did.
|
||||||
banner() {
|
# Cleared first: this is a front door, and it is navigated to rather than
|
||||||
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
|
# scrolled to. dejima-allow's picker shares the banner and deliberately not
|
||||||
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
|
# this - see tui_clear.
|
||||||
"$C_MUTED" "$C_OFF"
|
banner() { tui_clear; tui_banner 'live sessions'; }
|
||||||
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Coloured by meaning, not by name: attachable is the one an operator wants to
|
# Coloured by meaning, not by name: attachable is the one an operator wants to
|
||||||
# find, and stale/no-tmux are the two that will refuse if picked.
|
# find, and stale/no-tmux are the two that will refuse if picked.
|
||||||
@@ -263,7 +228,7 @@ list_sessions() {
|
|||||||
# ever race it. Removing it under a live session is not a leak
|
# ever race it. Removing it under a live session is not a leak
|
||||||
# being tidied but a session being broken - Docker recreates the
|
# being tidied but a session being broken - Docker recreates the
|
||||||
# bind source root-owned, hooks/dejima-notify.sh then fails on
|
# bind source root-owned, hooks/dejima-notify.sh then fails on
|
||||||
# every Notification and Stop for the rest of the run, and nothing
|
# every notification for the rest of the run, and nothing
|
||||||
# rewrites the registry entry to make the session visible again.
|
# rewrites the registry entry to make the session visible again.
|
||||||
continue
|
continue
|
||||||
elif [ "$rc" -ne 0 ]; then
|
elif [ "$rc" -ne 0 ]; then
|
||||||
@@ -438,37 +403,8 @@ picker() {
|
|||||||
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||||
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
||||||
done
|
done
|
||||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits
|
tui_pick "${#ids[@]}" 'attach to'
|
||||||
# far better than arrow keys, and this is the phone's entry point.
|
choice="$TUI_CHOICE"
|
||||||
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
|
|
||||||
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
|
|
||||||
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
|
|
||||||
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
|
|
||||||
# returns nonzero immediately, and exiting 1 with nothing printed reads as
|
|
||||||
# a broken front door. attach() reasons about the same case explicitly.
|
|
||||||
read -r choice || exit 0
|
|
||||||
case "$choice" in
|
|
||||||
q|Q|'') exit 0 ;;
|
|
||||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
# Normalized to base 10 once, here, because the range check and the
|
|
||||||
# subscript below do not read a number the same way: `[` is POSIX test and
|
|
||||||
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
|
|
||||||
# "010" - which a phone keypad makes easy to type - passes the check as 10
|
|
||||||
# and indexes as 8, and the caller is attached to a DIFFERENT session than
|
|
||||||
# the one they picked, with nothing printed to say so.
|
|
||||||
#
|
|
||||||
# It fixes the two ugly shapes as well. "09" is a value here rather than
|
|
||||||
# the "invalid octal constant" arithmetic error it is to $(( )); and a
|
|
||||||
# number past int64 wraps to one the range check can answer for, where
|
|
||||||
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
|
|
||||||
# expression expected" - which makes the `if` false, so "Out of range."
|
|
||||||
# is never printed and the subscript below dies on an unbound element.
|
|
||||||
choice="$((10#$choice))"
|
|
||||||
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
|
|
||||||
echo "Out of range." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
attach "${ids[$((choice - 1))]}"
|
attach "${ids[$((choice - 1))]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-12
@@ -23,6 +23,7 @@ Usage: dejima-audit <snapshot|compare|show|ack> [workspace] [state-file]
|
|||||||
show [--full] index of reports waiting to be read; --full prints one
|
show [--full] index of reports waiting to be read; --full prints one
|
||||||
ack walk a report finding by finding and record a decision
|
ack walk a report finding by finding and record a decision
|
||||||
([o] at the prompt opens the file in $VISUAL/$EDITOR)
|
([o] at the prompt opens the file in $VISUAL/$EDITOR)
|
||||||
|
--print-paths the resolved store paths, one per line, for scripts
|
||||||
|
|
||||||
workspace defaults to the enclosing git repository, else $PWD.
|
workspace defaults to the enclosing git repository, else $PWD.
|
||||||
|
|
||||||
@@ -791,17 +792,9 @@ id_severity() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Every finding interpolates workspace-controlled text (paths, config values)
|
# sanitize() and sanitize_body() come from dejima-color-lib.sh - every finding
|
||||||
# into a terminal. A planted value containing C0 escapes can scroll, clear or
|
# here interpolates workspace-controlled text (paths, config values) into a
|
||||||
# overwrite the report; C1 controls and Unicode bidi overrides/isolates
|
# terminal, and so does every epitaph, so the rule has one definition.
|
||||||
# (U+202A-U+202E, U+2066-U+2069) can visually reverse or reorder the
|
|
||||||
# displayed text without touching the bytes an operator would grep for - so
|
|
||||||
# strip all of these at the display boundary, after all parsing and
|
|
||||||
# TAB-splitting is done.
|
|
||||||
sanitize() {
|
|
||||||
printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' \
|
|
||||||
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
|
|
||||||
}
|
|
||||||
|
|
||||||
# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
|
# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
|
||||||
# baseline id from the report text and the next `compare` matches it against
|
# baseline id from the report text and the next `compare` matches it against
|
||||||
@@ -973,7 +966,11 @@ tcb_snapshot() {
|
|||||||
# here. Both decide what the next launch permits, which is what the
|
# here. Both decide what the next launch permits, which is what the
|
||||||
# TCB list is for. A visible edit stays a normal reviewable diff - the
|
# TCB list is for. A visible edit stays a normal reviewable diff - the
|
||||||
# split above only reports what git cannot see.
|
# split above only reports what git cannot see.
|
||||||
for dir in bin hooks docker test forges workflows harnesses; do
|
# tools/ for the same reason as the provider directories, and it was
|
||||||
|
# missed when that layer landed: a tool.conf names a tarball URL and
|
||||||
|
# the sha256 checked against it, and the host's image build installs
|
||||||
|
# what it points at as root.
|
||||||
|
for dir in bin hooks docker test forges workflows harnesses tools; do
|
||||||
if [ -d "$ws/$dir" ]; then
|
if [ -d "$ws/$dir" ]; then
|
||||||
while IFS= read -r -d '' f; do
|
while IFS= read -r -d '' f; do
|
||||||
stage_entry "tcb:${f#"$ws"/}" "$f"
|
stage_entry "tcb:${f#"$ws"/}" "$f"
|
||||||
|
|||||||
+87
-9
@@ -17,25 +17,103 @@
|
|||||||
#
|
#
|
||||||
# The variables are always DEFINED, empty when colour is off, so callers
|
# The variables are always DEFINED, empty when colour is off, so callers
|
||||||
# interpolate them unconditionally and no call site needs an if.
|
# interpolate them unconditionally and no call site needs an if.
|
||||||
|
# Session-authored text on its way to the operator's terminal. A planted value
|
||||||
|
# containing C0 escapes can scroll, clear or overwrite what is being read; C1
|
||||||
|
# controls and Unicode bidi overrides/isolates (U+202A-U+202E, U+2066-U+2069)
|
||||||
|
# reverse or reorder the displayed text without touching the bytes an operator
|
||||||
|
# would grep for. Stripped at the display boundary, after all parsing and
|
||||||
|
# TAB-splitting is done.
|
||||||
|
#
|
||||||
|
# Two forms, because the rule differs. A field is one line, so every C0 byte
|
||||||
|
# goes - including LF, which is what keeps a planted newline from forging a
|
||||||
|
# second row. A body is many lines, and applying the field rule to one would
|
||||||
|
# fold a whole epitaph onto a single line, so there LF and TAB survive and
|
||||||
|
# nothing else does. CR goes either way: it is the overwrite-this-line trick.
|
||||||
|
_sanitize_c1_bidi() {
|
||||||
|
LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitize() {
|
||||||
|
printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' | _sanitize_c1_bidi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads the text on stdin rather than taking it as an argument: the callers
|
||||||
|
# are whole files, and a body large enough to be worth keeping is not worth
|
||||||
|
# passing through argv.
|
||||||
|
sanitize_body() {
|
||||||
|
LC_ALL=C tr -d '\000-\010\013-\037\177' | _sanitize_c1_bidi
|
||||||
|
}
|
||||||
|
|
||||||
|
# One palette, and it is the terminal's own.
|
||||||
|
#
|
||||||
|
# gruvbox is dejima's brand, and the values below ARE gruvbox on a gruvbox
|
||||||
|
# terminal - every colour here is an ANSI slot that a gruvbox theme fills with
|
||||||
|
# the exact hex the marks use (bright green #b8bb26, bright yellow #fabd2f,
|
||||||
|
# bright red #fb4934, bright blue #83a598, bright black #928374). What it does
|
||||||
|
# not do is hardcode them.
|
||||||
|
#
|
||||||
|
# It used to, in dejima-tui-lib.sh, and that broke on any terminal whose
|
||||||
|
# background dejima does not control. Measured against a light background:
|
||||||
|
# light1 #ebdbb2 - the colour the session picker drew project names in - came
|
||||||
|
# out at 1.21:1, bright yellow at 1.49:1, bright green at 1.82:1. Nothing in
|
||||||
|
# the palette cleared 3:1 on both a dark and a light background, because a
|
||||||
|
# palette drawn for #282828 cannot.
|
||||||
|
#
|
||||||
|
# An indexed colour cannot have that problem: the theme's author chose it
|
||||||
|
# against their own background. So dejima gets its brand on the terminals set
|
||||||
|
# up for it, and legibility on the rest, and those are the same decision.
|
||||||
|
#
|
||||||
|
# The one place dejima keeps exact hexes is web/src/page.css, where it sets
|
||||||
|
# the background itself and so owns both sides of the contrast.
|
||||||
|
#
|
||||||
|
# Empty unless the fd is a colour-capable terminal, which keeps machine
|
||||||
|
# interfaces (dejima-attach --list, which ttyd feeds run ids back from)
|
||||||
|
# parseable and is how the bats suite reads these scripts.
|
||||||
color_init() {
|
color_init() {
|
||||||
local fd="${1:-1}"
|
local fd="${1:-1}"
|
||||||
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
|
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
|
||||||
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
|
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
|
||||||
|
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
|
||||||
|
C_RULE=""; C_ERR=""; C_LINK=""
|
||||||
|
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
|
||||||
|
# because no-colour implies no-UTF-8, but because the two terminals this
|
||||||
|
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
|
||||||
|
# are exactly the ones that mangle box drawing, and one flag is easier to
|
||||||
|
# reason about than two.
|
||||||
|
GLYPH_SEP='|'; GLYPH_BAR='|'; GLYPH_DOT='*'; GLYPH_RULE='-'
|
||||||
[ -t "$fd" ] || return 0
|
[ -t "$fd" ] || return 0
|
||||||
[ -z "${NO_COLOR+x}" ] || return 0
|
[ -z "${NO_COLOR+x}" ] || return 0
|
||||||
case "${TERM:-}" in ''|dumb) return 0 ;; esac
|
case "${TERM:-}" in ''|dumb) return 0 ;; esac
|
||||||
|
|
||||||
C_RESET=$'\033[0m'
|
C_RESET=$'\033[0m'
|
||||||
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
|
C_OFF="$C_RESET" # the pickers' name for it
|
||||||
C_WARN=$'\033[33m' # yellow
|
C_BOLD=$'\033[1m'
|
||||||
C_INFO=$'\033[36m' # cyan
|
C_KEY="$C_BOLD" # bold - the keystroke an operator has to press
|
||||||
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
|
# Default foreground, deliberately empty. This was light1 #ebdbb2, which
|
||||||
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
|
# is gruvbox's own foreground - so on a gruvbox terminal it is what the
|
||||||
C_DIM=$'\033[2m' # dim - the audit's own framing around content
|
# terminal already draws with, and on a light one it was invisible.
|
||||||
C_ADD=$'\033[32m' # green - a diff's added lines
|
C_TEXT=""
|
||||||
C_DEL=$'\033[31m' # red - a diff's removed lines
|
C_DIM=$'\033[2m'
|
||||||
C_OK=$'\033[32m' # green
|
C_SUBTLE="$C_DIM" # dark4 receded; dim recedes against any background
|
||||||
|
C_RULE="$C_DIM" # dark2, ditto
|
||||||
|
C_MUTED=$'\033[90m' # bright black - gruvbox gray_245 #928374
|
||||||
|
C_OK=$'\033[92m' # bright green - gruvbox #b8bb26
|
||||||
|
C_ADD="$C_OK"
|
||||||
|
C_WARN=$'\033[93m' # bright yellow - gruvbox #fabd2f
|
||||||
|
C_ERR=$'\033[91m' # bright red - gruvbox #fb4934
|
||||||
|
C_DEL="$C_ERR"
|
||||||
|
C_CRIT=$'\033[1;91m' # bold bright red - runs unsandboxed host code
|
||||||
|
C_INFO=$'\033[96m' # bright cyan - gruvbox aqua #8ec07c
|
||||||
|
C_LINK=$'\033[94m' # bright blue - gruvbox #83a598
|
||||||
|
C_PATH=$'\033[1;96m' # bold - the file or key a finding is about
|
||||||
|
# No ANSI slot is orange, and gruvbox's #fe8019 is the wordmark's colour.
|
||||||
|
# Bold bright yellow is the nearest thing every terminal has, and it is
|
||||||
|
# the accent rather than the brand exactly.
|
||||||
|
C_BADGE=$'\033[1;93m'
|
||||||
|
GLYPH_SEP='▏'; GLYPH_BAR='▏'; GLYPH_DOT='●'; GLYPH_RULE='─'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# The colour for a tier name, so the mapping lives in one place rather than
|
# The colour for a tier name, so the mapping lives in one place rather than
|
||||||
# at each site that prints one.
|
# at each site that prints one.
|
||||||
tier_color() {
|
tier_color() {
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ core.harness provider:harness
|
|||||||
core.forge provider:forge none
|
core.forge provider:forge none
|
||||||
core.workflow provider:workflow local-only
|
core.workflow provider:workflow local-only
|
||||||
core.command ?identifier
|
core.command ?identifier
|
||||||
|
core.identity ?identifier
|
||||||
|
core.tools ?list:identifier
|
||||||
core.firewall bool on
|
core.firewall bool on
|
||||||
|
ui.banner bool on
|
||||||
|
core.update_check bool on
|
||||||
core.share_dir path ~/.local/share/dejima/shared
|
core.share_dir path ~/.local/share/dejima/shared
|
||||||
core.ssh_key ?path
|
core.ssh_key ?path
|
||||||
core.known_hosts path ~/.ssh/known_hosts
|
core.known_hosts path ~/.ssh/known_hosts
|
||||||
@@ -69,6 +73,7 @@ remote-access.ttyd_port int:1:65535 7681
|
|||||||
remote-access.ttyd_font_size ?int:8:72
|
remote-access.ttyd_font_size ?int:8:72
|
||||||
remote-access.ttyd_tmux_prefix ?chord
|
remote-access.ttyd_tmux_prefix ?chord
|
||||||
remote-access.ttyd_focus_on_tap ?bool
|
remote-access.ttyd_focus_on_tap ?bool
|
||||||
|
remote-access.ttyd_button_colors ?bool
|
||||||
remote-access.debounce int:0:86400 60
|
remote-access.debounce int:0:86400 60
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -248,8 +253,23 @@ valid_tarball_spec() {
|
|||||||
# A declared domain has to survive being written into a dnsmasq
|
# A declared domain has to survive being written into a dnsmasq
|
||||||
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
|
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
|
||||||
# there is one definition and no pair of them to drift apart.
|
# there is one definition and no pair of them to drift apart.
|
||||||
|
# A hostname, label by label. The matcher is a trust boundary: what it accepts
|
||||||
|
# becomes a dnsmasq `server=` line and an ipset entry, so it should accept only
|
||||||
|
# what it means to rather than only what it can see a use for.
|
||||||
|
#
|
||||||
|
# The previous form allowed an EMPTY label - `a..b.com` matched - because its
|
||||||
|
# repeated group could consume a dot having matched no characters before it.
|
||||||
|
# Harmless in practice, since nothing that shape resolves, and fixed on the
|
||||||
|
# principle rather than on a known abuse: the published bypass in this space
|
||||||
|
# came from a matcher accepting a string the resolver read differently.
|
||||||
|
#
|
||||||
|
# Per label: 1-63 characters, starting and ending alphanumeric, hyphens in
|
||||||
|
# between. That also newly refuses a label with a leading or trailing hyphen,
|
||||||
|
# which is not a hostname either. A single label with no dot stays legal - an
|
||||||
|
# internal name resolved by the host's own resolver is a real case.
|
||||||
valid_domain() {
|
valid_domain() {
|
||||||
[[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]
|
local label='[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
|
||||||
|
[[ "$1" =~ ^$label(\.$label)*$ ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extension point for types this layer cannot judge on its own. A stub here,
|
# Extension point for types this layer cannot judge on its own. A stub here,
|
||||||
@@ -351,6 +371,20 @@ _config_valid_value() {
|
|||||||
identifier)
|
identifier)
|
||||||
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
|
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
|
||||||
;;
|
;;
|
||||||
|
text)
|
||||||
|
# A sentence rather than a token - a person's name, an email
|
||||||
|
# address, a one-line description. An identifier has no room for a
|
||||||
|
# space and a path refuses a colon, and all of those are legal
|
||||||
|
# here. Refuses only what cannot survive the trip: a newline splits
|
||||||
|
# the value where every consumer expects one line, and a control
|
||||||
|
# character reaching a terminal is the escape-sequence hazard
|
||||||
|
# docs/security.md names. Everything else is the operator's
|
||||||
|
# business, the same reading `path` takes of a space.
|
||||||
|
case "$value" in
|
||||||
|
*[$'\n\r\t']*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[[ ! "$value" =~ [[:cntrl:]] ]]
|
||||||
|
;;
|
||||||
chord)
|
chord)
|
||||||
# A tmux control chord, as the remote terminal page's PREFIX,
|
# A tmux control chord, as the remote terminal page's PREFIX,
|
||||||
# window and ZOOM buttons send. Narrower than tmux's own prefix
|
# window and ZOOM buttons send. Narrower than tmux's own prefix
|
||||||
@@ -385,7 +419,7 @@ _config_extra_section_known() { return 1; }
|
|||||||
|
|
||||||
_config_section_known() {
|
_config_section_known() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
|
core|github|mounts|credentials|logging|audit|remote-access|epitaph|ui) return 0 ;;
|
||||||
*) _config_extra_section_known "$1" ;;
|
*) _config_extra_section_known "$1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
+323
-22
@@ -21,7 +21,20 @@
|
|||||||
# file it came from. This preserves what was recorded, it does not attest to
|
# file it came from. This preserves what was recorded, it does not attest to
|
||||||
# it.
|
# it.
|
||||||
#
|
#
|
||||||
# Usage: dejima-epitaph save <workspace>
|
# Usage: dejima-epitaph save <workspace>
|
||||||
|
# dejima-epitaph list [--all] [workspace]
|
||||||
|
# dejima-epitaph search [--all] <pattern> [workspace]
|
||||||
|
# dejima-epitaph show [n | name] [workspace]
|
||||||
|
#
|
||||||
|
# `save` is what bin/dejima's exit trap runs. The other three are the
|
||||||
|
# operator's, and they only ever read from the host: nothing here gives a
|
||||||
|
# container a path to the store, and nothing here hands an epitaph back to a
|
||||||
|
# session. That second one is the load-bearing half. A session can write its
|
||||||
|
# own transcript, so it can author what its own epitaph says - delivering
|
||||||
|
# that to a later session automatically would plant instructions in its
|
||||||
|
# premises, which is the channel the never-mounted store exists to close
|
||||||
|
# (#104). Printing to the operator's terminal keeps a person in the middle,
|
||||||
|
# and that is the whole design rather than a missing feature.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
# --help before anything else runs: no libraries sourced, no config read. The
|
# --help before anything else runs: no libraries sourced, no config read. The
|
||||||
@@ -32,24 +45,28 @@ case "${1:-}" in
|
|||||||
cat <<'DEJIMA_EPITAPH_HELP'
|
cat <<'DEJIMA_EPITAPH_HELP'
|
||||||
dejima-epitaph - keep what a session worked out before its context was compacted
|
dejima-epitaph - keep what a session worked out before its context was compacted
|
||||||
|
|
||||||
Usage: dejima-epitaph save <workspace>
|
Usage: dejima-epitaph save <workspace>
|
||||||
|
dejima-epitaph list [--all] [workspace]
|
||||||
|
dejima-epitaph search [--all] <pattern> [workspace]
|
||||||
|
dejima-epitaph show [n | name] [workspace]
|
||||||
|
|
||||||
save <workspace> extract any compaction summaries from that workspace's
|
save extract any compaction summaries from that workspace's transcripts
|
||||||
transcripts and keep them
|
and keep them. Run by bin/dejima when a session exits, not by hand
|
||||||
|
list what is kept, oldest first, and whether the session behind each is
|
||||||
Run by bin/dejima when a session exits, not usually by hand.
|
still resumable - where it is, resuming beats reading a summary
|
||||||
|
search case-insensitive regex over the bodies, reporting each hit's
|
||||||
|
position in the listing list prints
|
||||||
|
show open one in $VISUAL or $EDITOR, or print it where there is no
|
||||||
|
terminal to open an editor on
|
||||||
|
--all widen list and search to every project rather than this one
|
||||||
|
|
||||||
A harness that compacts a long session writes a summary of what it is about to
|
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
|
forget, and it goes away with the container. This keeps it, host-side, in a
|
||||||
session's reasoning, and it goes away with the container. This keeps it.
|
store nothing mounts - so one session cannot read what an earlier one concluded
|
||||||
|
or edit what this one is about to record.
|
||||||
|
|
||||||
What a compaction looks like in a transcript is the harness's business, so the
|
Reading works with [epitaph] enabled off: turning the feature off is when
|
||||||
selected harness's plugin does the extracting; everything else here is dejima's:
|
reviewing and clearing the store is most likely.
|
||||||
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,
|
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.
|
which is not a failure - it means the transcript already holds it all.
|
||||||
@@ -65,13 +82,293 @@ source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
|||||||
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
||||||
# shellcheck source=dejima-harness-lib.sh
|
# shellcheck source=dejima-harness-lib.sh
|
||||||
source "$(dirname "$(readlink -f "$0")")/dejima-harness-lib.sh"
|
source "$(dirname "$(readlink -f "$0")")/dejima-harness-lib.sh"
|
||||||
|
# shellcheck source=dejima-color-lib.sh
|
||||||
|
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||||
config_validate || exit 1
|
config_validate || exit 1
|
||||||
|
color_init 1
|
||||||
|
|
||||||
cmd="${1:-}"; ws_arg="${2:-}"
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
usage: dejima-epitaph save <workspace>
|
||||||
|
dejima-epitaph list [--all] [workspace]
|
||||||
|
dejima-epitaph search [--all] <pattern> [workspace]
|
||||||
|
dejima-epitaph show [n | name] [workspace]
|
||||||
|
|
||||||
|
n is a position in the listing `list` and `search` print, so it means what
|
||||||
|
those just showed - including under --all, which numbers every project's
|
||||||
|
epitaphs in one sequence.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-}"
|
||||||
|
shift 2>/dev/null || true
|
||||||
|
# --all anywhere, so it reads naturally either side of a search pattern.
|
||||||
|
ALL=0; args=()
|
||||||
|
for a in ${@+"$@"}; do
|
||||||
|
case "$a" in
|
||||||
|
--all) ALL=1 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) args+=("$a") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
set -- ${args[@]+"${args[@]}"}
|
||||||
|
|
||||||
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
|
|
||||||
STORE="$(config_get epitaph dir)"
|
STORE="$(config_get epitaph dir)"
|
||||||
|
|
||||||
|
# `enabled` gates writing, not reading. Turning epitaph off must not strand
|
||||||
|
# what it already kept behind a config key: an operator who switches it off
|
||||||
|
# is the one most likely to want to look at, and get rid of, the existing
|
||||||
|
# store.
|
||||||
|
case "$cmd" in
|
||||||
|
save) [ "$(config_get epitaph enabled)" = "on" ] || exit 0 ;;
|
||||||
|
list|search|show) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Every epitaph belonging to $1, oldest first. The sort key is the basename,
|
||||||
|
# not the path: names begin with the timestamp, so this is chronological
|
||||||
|
# across a workspace's several slugs rather than grouped by whichever slug
|
||||||
|
# directory each file happens to sit in.
|
||||||
|
_epitaphs_in() {
|
||||||
|
local f
|
||||||
|
[ -d "$1" ] || return 0
|
||||||
|
for f in "$1"/*.md; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
printf '%s\t%s\n' "$(basename "$f")" "$f"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
epitaph_files() {
|
||||||
|
local ws="$1" slug d
|
||||||
|
{
|
||||||
|
if [ "$ALL" = 1 ]; then
|
||||||
|
for d in "$STORE"/*/; do _epitaphs_in "${d%/}"; done
|
||||||
|
else
|
||||||
|
while IFS= read -r slug; do
|
||||||
|
[ -n "$slug" ] || continue
|
||||||
|
_epitaphs_in "$STORE/$slug"
|
||||||
|
done < <(workspace_slugs_sorted "$ws")
|
||||||
|
fi
|
||||||
|
} | sort | cut -f2-
|
||||||
|
}
|
||||||
|
|
||||||
|
# The workspace to read from: the one given, else this checkout. Tries the
|
||||||
|
# worktree top and then the main checkout that worktree belongs to, taking
|
||||||
|
# the first that actually has epitaphs - the same shape as dejima-audit's
|
||||||
|
# resolve_ws, and it matters more here. A worktree only has epitaphs of its
|
||||||
|
# own if dejima was launched against the worktree itself; run from one after
|
||||||
|
# working in the main checkout, the answer is the main checkout.
|
||||||
|
resolve_ws() {
|
||||||
|
local given="$1" top common main c
|
||||||
|
if [ -n "$given" ]; then
|
||||||
|
( cd "$given" 2>/dev/null && pwd ) || printf '%s' "$given"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
top="$(git rev-parse --show-toplevel 2>/dev/null)" || top=""
|
||||||
|
common="$(git rev-parse --git-common-dir 2>/dev/null)" || common=""
|
||||||
|
main=""
|
||||||
|
if [ -n "$common" ]; then
|
||||||
|
common="$(readlink -f "$common" 2>/dev/null)" || common=""
|
||||||
|
[ -n "$common" ] && main="$(dirname "$common")"
|
||||||
|
fi
|
||||||
|
for c in "$top" "$main" "$PWD"; do
|
||||||
|
[ -n "$c" ] || continue
|
||||||
|
[ -n "$(epitaph_files "$c")" ] || continue
|
||||||
|
printf '%s' "$c"
|
||||||
|
return
|
||||||
|
done
|
||||||
|
printf '%s' "$PWD"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
list)
|
||||||
|
ws="$(resolve_ws "${1:-}")"
|
||||||
|
mapfile -t FILES < <(epitaph_files "$ws")
|
||||||
|
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||||
|
if [ "$ALL" = 1 ]; then echo "dejima epitaph: nothing kept yet." >&2
|
||||||
|
else echo "dejima epitaph: nothing kept for $ws." >&2; fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$ALL" = 1 ]; then printf '%s epitaph(s), all projects\n\n' "${#FILES[@]}"
|
||||||
|
else printf '%s epitaph(s) for %s%s%s\n\n' "${#FILES[@]}" "$C_PATH" "$ws" "$C_RESET"; fi
|
||||||
|
# One python pass rather than one per file, and python rather than three
|
||||||
|
# greps and a stat because this reads the header of every epitaph in the
|
||||||
|
# store under --all.
|
||||||
|
#
|
||||||
|
# Deliberately no topic column. Every Claude Code summary opens on the
|
||||||
|
# same boilerplate, the "Primary Request" section that follows is written
|
||||||
|
# four different ways across the summaries here, and what it opens on is
|
||||||
|
# usually scene-setting rather than the request. Nothing extractable
|
||||||
|
# tells the reader which session this was, and a column repeating the
|
||||||
|
# same sentence down eight rows reads as a broken tool while leaving
|
||||||
|
# discoverability exactly where it was. `search` is the answer to "which
|
||||||
|
# one was it"; this is the answer to "what is here".
|
||||||
|
#
|
||||||
|
# Which sessions still have a transcript is the harness's answer, not
|
||||||
|
# something to work out from a filename: the id is in the name for one
|
||||||
|
# harness and is the directory for the next. Under --all the workspace is
|
||||||
|
# not passed, because a store key is a slug and a slug does not resolve
|
||||||
|
# back to a path - the plugin answers for every key it holds instead. A
|
||||||
|
# harness that does not implement the hook returns nothing, and every row
|
||||||
|
# then says so rather than claiming the transcript is gone.
|
||||||
|
harness_plugin_load "$(config_get core harness)"
|
||||||
|
live=""
|
||||||
|
if [ "$ALL" = 1 ]; then live="$(harness_hook harness_live_sessions)"
|
||||||
|
else live="$(harness_hook harness_live_sessions "$ws")"; fi
|
||||||
|
C_KEY="$C_KEY" C_DIM="$C_DIM" C_OK="$C_OK" C_RESET="$C_RESET" \
|
||||||
|
DEJIMA_LIVE_SESSIONS="$live" DEJIMA_HAS_LIVE="$(declare -F harness_live_sessions >/dev/null && echo 1)" \
|
||||||
|
python3 - "$ALL" ${FILES[@]+"${FILES[@]}"} <<'PY'
|
||||||
|
import os, re, sys
|
||||||
|
|
||||||
|
show_slug = sys.argv[1] == "1"
|
||||||
|
# "<key>\t<session-id>" per live transcript. The distinction that matters is
|
||||||
|
# an empty set from a harness that answered versus no answer at all: the
|
||||||
|
# first means aged out, the second means unknown.
|
||||||
|
known = os.environ.get("DEJIMA_HAS_LIVE") == "1"
|
||||||
|
live = set(os.environ.get("DEJIMA_LIVE_SESSIONS", "").splitlines())
|
||||||
|
KEY, DIM, OK, RESET = (os.environ.get(k, "") for k in ("C_KEY", "C_DIM", "C_OK", "C_RESET"))
|
||||||
|
|
||||||
|
# The index is padded before it is coloured: a width in the format string
|
||||||
|
# counts the escape sequence's bytes and knocks every row out of line the
|
||||||
|
# moment colour is on.
|
||||||
|
ROW = "%s %-16s %-6s %6s %5s %s"
|
||||||
|
print(DIM + ROW % ("%4s" % "#", "when", "how", "ctx", "size", "transcript") + RESET)
|
||||||
|
|
||||||
|
# The bash side's sanitize(), stated again because this renderer parses the
|
||||||
|
# fields itself and there is nothing to hand it a cleaned string. The classes
|
||||||
|
# are pinned equal to that one by a test rather than by this comment.
|
||||||
|
_BAD = re.compile(
|
||||||
|
"[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]")
|
||||||
|
|
||||||
|
def clean(s):
|
||||||
|
return _BAD.sub("", s)
|
||||||
|
|
||||||
|
for i, path in enumerate(sys.argv[2:], 1):
|
||||||
|
try:
|
||||||
|
with open(path, errors="replace") as fh:
|
||||||
|
head = [next(fh, "") for _ in range(12)]
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
h = {}
|
||||||
|
for l in head:
|
||||||
|
m = re.match(r"- (session|at|trigger|tokens): (.*)", l)
|
||||||
|
if m:
|
||||||
|
h[m.group(1)] = m.group(2).strip()
|
||||||
|
sid = h.get("session", "")
|
||||||
|
# How much context the session had accumulated by the time it compacted:
|
||||||
|
# the one field here that separates a long session from a short one, and
|
||||||
|
# it is recorded rather than guessed at.
|
||||||
|
m = re.match(r"(\d+) before", h.get("tokens", ""))
|
||||||
|
ctx = "%dk" % (int(m.group(1)) / 1000) if m else "?"
|
||||||
|
# The store key is the directory this epitaph sits in, which is dejima's
|
||||||
|
# own arrangement; pairing it with the session id is what makes the answer
|
||||||
|
# unambiguous under --all, where two projects can hold the same id.
|
||||||
|
if not known:
|
||||||
|
state = DIM + "?" + RESET
|
||||||
|
elif "%s\t%s" % (os.path.basename(os.path.dirname(path)), sid) in live:
|
||||||
|
state = OK + "resumable" + RESET
|
||||||
|
else:
|
||||||
|
state = DIM + "aged out" + RESET
|
||||||
|
row = ROW % (KEY + "%4d" % i + RESET,
|
||||||
|
clean(h.get("at", ""))[:16].replace("T", " ") or "?",
|
||||||
|
clean(h.get("trigger", "?"))[:6], ctx,
|
||||||
|
"%dK" % ((os.path.getsize(path) + 1023) // 1024), state)
|
||||||
|
if show_slug:
|
||||||
|
row += " " + DIM + clean(os.path.basename(os.path.dirname(path)))[:30] + RESET
|
||||||
|
print(row)
|
||||||
|
PY
|
||||||
|
printf '\n %sdejima-epitaph show <n>%s opens one; %sdejima-epitaph search <pattern>%s finds one.\n' \
|
||||||
|
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
|
||||||
|
# `resumable` means the transcript this was distilled from is still on
|
||||||
|
# disk, so `claude --resume <session>` gives the real session rather than
|
||||||
|
# a summary of it. That is the better recovery whenever it is available;
|
||||||
|
# the epitaph is what remains once the harness has aged the transcript out.
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
search)
|
||||||
|
pattern="${1:-}"; [ -n "$pattern" ] || usage
|
||||||
|
ws="$(resolve_ws "${2:-}")"
|
||||||
|
mapfile -t FILES < <(epitaph_files "$ws")
|
||||||
|
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
|
||||||
|
# Case-insensitive extended regex, so a bare word works and a pattern is
|
||||||
|
# available without a second flag to remember.
|
||||||
|
gcolor=never; [ -n "$C_RESET" ] && gcolor=always
|
||||||
|
i=0; hits=0
|
||||||
|
for f in ${FILES[@]+"${FILES[@]}"}; do
|
||||||
|
i=$((i + 1))
|
||||||
|
grep -qiE -- "$pattern" "$f" 2>/dev/null || continue
|
||||||
|
hits=$((hits + 1))
|
||||||
|
# The index is this file's position in the SAME listing `list`
|
||||||
|
# prints, so `show <n>` after a search means what the search showed.
|
||||||
|
# The name is built from the transcript's own timestamp and uuid
|
||||||
|
# fields, so it is session-authored too - not just the body under it.
|
||||||
|
printf '\n%s%4d%s %s%s%s\n' "$C_KEY" "$i" "$C_RESET" \
|
||||||
|
"$C_DIM" "$(sanitize "$(basename "$f")")" "$C_RESET"
|
||||||
|
# Sanitized BEFORE grep, not after: grep adds its own colour when the
|
||||||
|
# output is a terminal, and cleaning afterwards would strip the
|
||||||
|
# highlight along with the planted bytes.
|
||||||
|
sanitize_body < "$f" \
|
||||||
|
| grep -inE --color=$gcolor -- "$pattern" 2>/dev/null \
|
||||||
|
| head -3 | sed 's/^/ /' | cut -c1-100
|
||||||
|
done
|
||||||
|
if [ "$hits" -eq 0 ]; then
|
||||||
|
printf 'dejima epitaph: no epitaph matches %s (searched %s).\n' "$pattern" "$i" >&2
|
||||||
|
else
|
||||||
|
printf '\n%s of %s epitaph(s) match. %sdejima-epitaph show <n>%s opens one.\n' \
|
||||||
|
"$hits" "$i" "$C_KEY" "$C_RESET"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
show)
|
||||||
|
sel="${1:-}"; ws_arg="${2:-}"
|
||||||
|
# A lone argument that names a directory is the workspace, not a
|
||||||
|
# selector: `dejima-epitaph show ~/workspace/thing` is the obvious thing
|
||||||
|
# to type and would otherwise be read as a name to match.
|
||||||
|
if [ -n "$sel" ] && [ -z "$ws_arg" ] && [ -d "$sel" ]; then ws_arg="$sel"; sel=""; fi
|
||||||
|
ws="$(resolve_ws "$ws_arg")"
|
||||||
|
mapfile -t FILES < <(epitaph_files "$ws")
|
||||||
|
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
|
||||||
|
file=""
|
||||||
|
case "$sel" in
|
||||||
|
"") file="${FILES[-1]}" ;;
|
||||||
|
*[!0-9]*)
|
||||||
|
for f in ${FILES[@]+"${FILES[@]}"}; do
|
||||||
|
case "$(basename "$f")" in "$sel"*) file="$f"; break ;; esac
|
||||||
|
done ;;
|
||||||
|
*)
|
||||||
|
[ "$sel" -ge 1 ] 2>/dev/null && [ "$sel" -le "${#FILES[@]}" ] \
|
||||||
|
&& file="${FILES[$((sel - 1))]}" ;;
|
||||||
|
esac
|
||||||
|
[ -n "$file" ] || { echo "dejima epitaph: no epitaph '$sel' in ${#FILES[@]} kept for $ws." >&2; exit 1; }
|
||||||
|
# Piped or redirected, print it: `show | grep` must not try to open an
|
||||||
|
# editor, and neither must a test.
|
||||||
|
ed="${VISUAL:-${EDITOR:-}}"
|
||||||
|
if [ -z "$ed" ] || [ ! -t 1 ] || [ ! -t 0 ]; then
|
||||||
|
# The body is what a session wrote, and printing it IS this tool's
|
||||||
|
# purpose - so it is the one place the operator's terminal meets those
|
||||||
|
# bytes directly. sanitize_body rather than sanitize: the field rule
|
||||||
|
# takes LF too, and an epitaph is not one line.
|
||||||
|
sanitize_body < "$file"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Through `sh -c` so $EDITOR may carry flags (`code -w`, `emacsclient
|
||||||
|
# -nw`), with the path passed as an argument rather than interpolated so
|
||||||
|
# a space in it stays one word.
|
||||||
|
sh -c "$ed \"\$1\"" sh "$file"
|
||||||
|
# An editor writes through a temp file and renames it into place, which
|
||||||
|
# leaves the result at the umask's mode rather than the 0600 `save`
|
||||||
|
# wrote. The 0700 directory means nothing else could read it either way;
|
||||||
|
# the mode is restated because it is what this store promises, not
|
||||||
|
# because the directory needs the help.
|
||||||
|
chmod 600 "$file" 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Everything below is `save`. Reading the store is dejima's own business and
|
||||||
|
# needs the plugin only to label a row (see `list` above, which tolerates its
|
||||||
|
# absence); extracting an epitaph is entirely the plugin's, so from here a
|
||||||
|
# missing one is fatal rather than a blank column.
|
||||||
harness_plugin_load "$(config_get core harness)"
|
harness_plugin_load "$(config_get core harness)"
|
||||||
|
|
||||||
# With no harness selected nothing knows what a compaction looks like, so
|
# With no harness selected nothing knows what a compaction looks like, so
|
||||||
@@ -87,15 +384,12 @@ if [ -z "$(config_get core harness)" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ws_arg="${1:-}"
|
||||||
|
|
||||||
ws="$ws_arg"
|
ws="$ws_arg"
|
||||||
[ -n "$ws" ] || ws="$(pwd)"
|
[ -n "$ws" ] || ws="$(pwd)"
|
||||||
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
|
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
|
||||||
|
|
||||||
case "$cmd" in
|
|
||||||
save) ;;
|
|
||||||
*) echo "usage: dejima-epitaph save <workspace>" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# One file per summary, named for the moment it was written, so a directory
|
# One file per summary, named for the moment it was written, so a directory
|
||||||
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
|
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
|
||||||
# on every exit, and a session compacted three times has three epitaphs, two
|
# on every exit, and a session compacted three times has three epitaphs, two
|
||||||
@@ -161,6 +455,13 @@ echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
|
|||||||
# operators who already centralise session logs. Host-side rather than
|
# operators who already centralise session logs. Host-side rather than
|
||||||
# through the container's log-shipper, so the same "never in the session's
|
# through the container's log-shipper, so the same "never in the session's
|
||||||
# reach" property holds for the shipping path too.
|
# reach" property holds for the shipping path too.
|
||||||
|
#
|
||||||
|
# DEJIMA_EPITAPH_NO_SHIP is bin/dejima's --no-log reaching the one decision it
|
||||||
|
# cannot make on this tool's behalf. Honoured from the environment, which
|
||||||
|
# nothing else here is, because it can only ever suppress: a stale export
|
||||||
|
# keeps an epitaph off the wire and never puts one on it. The epitaph is still
|
||||||
|
# written either way - the store is the durable copy, and this is the bonus.
|
||||||
|
[ "${DEJIMA_EPITAPH_NO_SHIP:-0}" != 1 ] || exit 0
|
||||||
[ "$(config_get epitaph loki)" = "on" ] || exit 0
|
[ "$(config_get epitaph loki)" = "on" ] || exit 0
|
||||||
loki="$(config_get logging loki_url)"
|
loki="$(config_get logging loki_url)"
|
||||||
[ -n "$loki" ] || exit 0
|
[ -n "$loki" ] || exit 0
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
# The hooks core will call. A plugin defines the ones its harness needs and
|
# 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
|
# 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.
|
# the static test refuses it rather than letting it look wired up.
|
||||||
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files harness_seed"
|
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files harness_live_sessions harness_seed"
|
||||||
|
|
||||||
# What an operator loses when a harness cannot host a given guard. The set
|
# 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
|
# itself is DEJIMA_KNOWN_GUARDS in dejima-provider-lib.sh, next to the
|
||||||
|
|||||||
+28
-2
@@ -29,6 +29,7 @@ A project declares its requirements in files inside its own checkout:
|
|||||||
.dejima/dependencies packages its image should carry
|
.dejima/dependencies packages its image should carry
|
||||||
.dejima/allowed-domains hosts its sessions may reach
|
.dejima/allowed-domains hosts its sessions may reach
|
||||||
.dejima/corpus rules about how its history is recorded
|
.dejima/corpus rules about how its history is recorded
|
||||||
|
.dejima/tools optional tools its sessions should have
|
||||||
|
|
||||||
Those are declarations, not grants. They live in the workspace, which a session
|
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
|
can write, so nothing they contain takes effect until it is approved here and
|
||||||
@@ -164,12 +165,15 @@ case "$action" in
|
|||||||
report allowed-domains valid_domain
|
report allowed-domains valid_domain
|
||||||
echo "corpus:"
|
echo "corpus:"
|
||||||
report corpus valid_corpus_rule
|
report corpus valid_corpus_rule
|
||||||
|
echo "tools:"
|
||||||
|
report tools valid_tool_name
|
||||||
;;
|
;;
|
||||||
approve)
|
approve)
|
||||||
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
|
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
|
||||||
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
|
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
|
||||||
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
|
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
|
||||||
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; } | sed -n 's/^rejected //p')
|
pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
|
||||||
|
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
|
||||||
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
|
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
|
||||||
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
|
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
|
||||||
if [ -n "$trunc_deps" ]; then
|
if [ -n "$trunc_deps" ]; then
|
||||||
@@ -183,7 +187,8 @@ case "$action" in
|
|||||||
sed 's/^/ /' <<< "$rejected" >&2
|
sed 's/^/ /' <<< "$rejected" >&2
|
||||||
echo >&2
|
echo >&2
|
||||||
fi
|
fi
|
||||||
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ]; then
|
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ] \
|
||||||
|
&& [ -z "$pending_tools" ]; then
|
||||||
echo "Nothing pending approval for $project."
|
echo "Nothing pending approval for $project."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -191,6 +196,12 @@ case "$action" in
|
|||||||
[ -n "$pending_deps" ] && printf ' dependency: %s\n' $pending_deps
|
[ -n "$pending_deps" ] && printf ' dependency: %s\n' $pending_deps
|
||||||
[ -n "$pending_doms" ] && printf ' domain: %s\n' $pending_doms
|
[ -n "$pending_doms" ] && printf ' domain: %s\n' $pending_doms
|
||||||
[ -n "$pending_corpus" ] && printf ' corpus: %s\n' $pending_corpus
|
[ -n "$pending_corpus" ] && printf ' corpus: %s\n' $pending_corpus
|
||||||
|
# Named as what it is rather than as a package: approving one adds a
|
||||||
|
# program to the image every session on this project then has on
|
||||||
|
# PATH. dejima hardens what it ships (docs/tools.md), which is what
|
||||||
|
# makes this a shorter conversation than a dependency - but it is
|
||||||
|
# still a change to what the sandbox contains.
|
||||||
|
[ -n "$pending_tools" ] && printf ' tool: %s (installed into the image)\n' $pending_tools
|
||||||
echo
|
echo
|
||||||
printf 'Approve all of the above? [y/N] '
|
printf 'Approve all of the above? [y/N] '
|
||||||
read -r reply || reply=n
|
read -r reply || reply=n
|
||||||
@@ -233,6 +244,15 @@ case "$action" in
|
|||||||
# RUNNING container's firewall or image; a corpus rule changes what the
|
# 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
|
# next session is told and nothing about a live one, so there is
|
||||||
# nothing to patch and no CLI to route through.
|
# nothing to patch and no CLI to route through.
|
||||||
|
tool_failed=""
|
||||||
|
tool_approved=0
|
||||||
|
for tool in $pending_tools; do
|
||||||
|
if approve_entry "$(ensure_project_store "$project")" tools "$tool"; then
|
||||||
|
tool_approved=1
|
||||||
|
else
|
||||||
|
tool_failed="$tool_failed $tool"
|
||||||
|
fi
|
||||||
|
done
|
||||||
corpus_failed=""
|
corpus_failed=""
|
||||||
corpus_approved=0
|
corpus_approved=0
|
||||||
for rule in $pending_corpus; do
|
for rule in $pending_corpus; do
|
||||||
@@ -249,6 +269,12 @@ case "$action" in
|
|||||||
if [ "$corpus_approved" = 1 ]; then
|
if [ "$corpus_approved" = 1 ]; then
|
||||||
echo "Approved corpus rules for $project. They take effect at the next launch."
|
echo "Approved corpus rules for $project. They take effect at the next launch."
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$tool_failed" ]; then
|
||||||
|
echo "NOT approved for $project (could not record):$tool_failed" >&2
|
||||||
|
fi
|
||||||
|
if [ "$tool_approved" = 1 ]; then
|
||||||
|
echo "Approved tools for $project. The image rebuilds on the next launch."
|
||||||
|
fi
|
||||||
if [ -n "$dep_failed" ]; then
|
if [ -n "$dep_failed" ]; then
|
||||||
echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
|
echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
|
||||||
echo "Fix the cause and re-run; nothing was recorded for those." >&2
|
echo "Fix the cause and re-run; nothing was recorded for those." >&2
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ _approved_validator() {
|
|||||||
allowed-domains) printf 'valid_domain' ;;
|
allowed-domains) printf 'valid_domain' ;;
|
||||||
dependencies) printf 'valid_dep_spec' ;;
|
dependencies) printf 'valid_dep_spec' ;;
|
||||||
corpus) printf 'valid_corpus_rule' ;;
|
corpus) printf 'valid_corpus_rule' ;;
|
||||||
|
tools) printf 'valid_tool_name' ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -249,6 +250,18 @@ _approved_validator() {
|
|||||||
# not. There is one today.
|
# not. There is one today.
|
||||||
DEJIMA_CORPUS_RULES="commit-per-step"
|
DEJIMA_CORPUS_RULES="commit-per-step"
|
||||||
|
|
||||||
|
# An optional tool a project asks for. A name dejima ships a manifest for and
|
||||||
|
# nothing else: an entry naming a tool that does not exist would approve
|
||||||
|
# cleanly, sit in the store and install nothing, which reads as the tool being
|
||||||
|
# in force when it is not - the same reasoning as the corpus rules above.
|
||||||
|
#
|
||||||
|
# Refusing at declaration time also means the approval prompt cannot offer an
|
||||||
|
# operator a name that would silently do nothing.
|
||||||
|
valid_tool_name() {
|
||||||
|
valid_provider_name "$1" || return 1
|
||||||
|
provider_dir tool "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
valid_corpus_rule() {
|
valid_corpus_rule() {
|
||||||
case " $DEJIMA_CORPUS_RULES " in
|
case " $DEJIMA_CORPUS_RULES " in
|
||||||
*" $1 "*) return 0 ;;
|
*" $1 "*) return 0 ;;
|
||||||
@@ -423,8 +436,22 @@ unapprove_entry() {
|
|||||||
#
|
#
|
||||||
# -xaf rather than -xzf: compression is detected from the archive, so xz and
|
# -xaf rather than -xzf: compression is detected from the archive, so xz and
|
||||||
# zstd releases work without the spec having to say which it is.
|
# zstd releases work without the spec having to say which it is.
|
||||||
|
# DEJIMA_TARBALL_STRIP picks between the two archive shapes. "prefix" is the
|
||||||
|
# default and the one every dependency uses: everything under a single
|
||||||
|
# directory, stripped so bin/ lands in the same place whatever the release
|
||||||
|
# called its top level. "binary" is an archive that IS the executable, with no
|
||||||
|
# enclosing directory at all - stripping a component there strips the binary
|
||||||
|
# and extracts nothing, silently, leaving a layer that built fine and contains
|
||||||
|
# no tool.
|
||||||
|
#
|
||||||
|
# A parameter rather than a second emitter, because the digest-before-unpack
|
||||||
|
# below is the part that must not exist twice.
|
||||||
_emit_tarball_layers() {
|
_emit_tarball_layers() {
|
||||||
local spec rest name sha url
|
local spec rest name sha url dest strip
|
||||||
|
case "${DEJIMA_TARBALL_STRIP:-prefix}" in
|
||||||
|
binary) strip="" ;;
|
||||||
|
*) strip="--strip-components=1 " ;;
|
||||||
|
esac
|
||||||
for spec in "$@"; do
|
for spec in "$@"; do
|
||||||
[ -n "$spec" ] || continue
|
[ -n "$spec" ] || continue
|
||||||
rest="${spec#tarball:}"
|
rest="${spec#tarball:}"
|
||||||
@@ -436,7 +463,12 @@ _emit_tarball_layers() {
|
|||||||
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
|
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
|
||||||
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
|
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
|
||||||
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
|
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
|
||||||
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
|
# A bare binary goes straight into the bin/ a prefix archive would
|
||||||
|
# have brought with it, so the PATH line below is the same either way.
|
||||||
|
dest="/opt/dejima/$name"
|
||||||
|
[ -n "$strip" ] || dest="$dest/bin"
|
||||||
|
printf ' mkdir -p %s && \\\n' "$dest"
|
||||||
|
printf ' tar -C %s %s-xaf /tmp/%s.tar && \\\n' "$dest" "$strip" "$name"
|
||||||
printf ' rm /tmp/%s.tar\n' "$name"
|
printf ' rm /tmp/%s.tar\n' "$name"
|
||||||
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
|
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
|
||||||
done
|
done
|
||||||
@@ -629,6 +661,14 @@ remote_ttyd_font_size() {
|
|||||||
# useful default and is deliberately NOT a bool default of off: the page
|
# 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
|
# decides per client from the pointer type, which a host-wide setting cannot
|
||||||
# see. Only a value the operator actually wrote is passed on.
|
# see. Only a value the operator actually wrote is passed on.
|
||||||
|
# Whether the terminal page colour-codes its toolbar. Unset means on, which
|
||||||
|
# the page decides for itself - so, like the tap setting above, only a value
|
||||||
|
# the operator actually wrote is passed on.
|
||||||
|
remote_ttyd_button_colors() {
|
||||||
|
config_is_set remote-access ttyd_button_colors || return 0
|
||||||
|
config_get remote-access ttyd_button_colors
|
||||||
|
}
|
||||||
|
|
||||||
remote_ttyd_focus_on_tap() {
|
remote_ttyd_focus_on_tap() {
|
||||||
config_is_set remote-access ttyd_focus_on_tap || return 0
|
config_is_set remote-access ttyd_focus_on_tap || return 0
|
||||||
config_get remote-access ttyd_focus_on_tap
|
config_get remote-access ttyd_focus_on_tap
|
||||||
|
|||||||
+427
-4
@@ -44,6 +44,11 @@ _provider_axis_dir() {
|
|||||||
forge) printf 'forges' ;;
|
forge) printf 'forges' ;;
|
||||||
workflow) printf 'workflows' ;;
|
workflow) printf 'workflows' ;;
|
||||||
harness) printf 'harnesses' ;;
|
harness) printf 'harnesses' ;;
|
||||||
|
# Not an axis in the select-one sense the other three are: a launch
|
||||||
|
# enables zero or more tools, and none is the default. It is here
|
||||||
|
# because everything else about it is the same - a manifest that is
|
||||||
|
# data, validated by the same schema machinery, never sourced.
|
||||||
|
tool) printf 'tools' ;;
|
||||||
*) echo "unknown provider axis: $1" >&2; return 1 ;;
|
*) echo "unknown provider axis: $1" >&2; return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,7 @@ _provider_manifest_name() {
|
|||||||
forge) printf 'forge.conf' ;;
|
forge) printf 'forge.conf' ;;
|
||||||
workflow) printf 'workflow.conf' ;;
|
workflow) printf 'workflow.conf' ;;
|
||||||
harness) printf 'harness.conf' ;;
|
harness) printf 'harness.conf' ;;
|
||||||
|
tool) printf 'tool.conf' ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -143,6 +149,9 @@ capabilities ?list:identifier
|
|||||||
pr_command ?command
|
pr_command ?command
|
||||||
issue_command ?command
|
issue_command ?command
|
||||||
merge_block ?command
|
merge_block ?command
|
||||||
|
repo_flags ?list:flag
|
||||||
|
repo_env ?identifier
|
||||||
|
identity_keys ?list:identifier
|
||||||
disclose ?disclosures
|
disclose ?disclosures
|
||||||
keys ?list:keyspec
|
keys ?list:keyspec
|
||||||
requires ?list:identifier
|
requires ?list:identifier
|
||||||
@@ -152,13 +161,29 @@ fixed_hosts ?list:hostname
|
|||||||
hosts_allowlisted_by_core bool off
|
hosts_allowlisted_by_core bool off
|
||||||
install ?list:installspec
|
install ?list:installspec
|
||||||
env_export ?list:export
|
env_export ?list:export
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
# An optional tool. Deliberately small: a tool declares what it is,
|
||||||
|
# what installs it, what its environment needs and what the session
|
||||||
|
# should be told about it - and nothing that could change what dejima
|
||||||
|
# permits. There is no key here for a hook, a mount, a domain or a
|
||||||
|
# guard, because a tool that needed one of those would be a change to
|
||||||
|
# the boundary rather than a program on PATH, and would have to be
|
||||||
|
# argued for rather than declared.
|
||||||
|
tool) cat <<'EOF'
|
||||||
|
description text
|
||||||
|
install depspec
|
||||||
|
install_layout enum:prefix|binary prefix
|
||||||
|
image_env ?list:envpair
|
||||||
|
command identifier
|
||||||
|
instructions ?relpath
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
workflow) cat <<'EOF'
|
workflow) cat <<'EOF'
|
||||||
requires ?list:identifier
|
requires ?list:identifier
|
||||||
push bool on
|
push bool on
|
||||||
blocked_branches ?list:identifier
|
blocked_branches ?list:identifier
|
||||||
block_merge bool off
|
block_merge bool on
|
||||||
requires_worktree bool off
|
requires_worktree bool off
|
||||||
autonomy enum:commit|push-branch|open-pr commit
|
autonomy enum:commit|push-branch|open-pr commit
|
||||||
briefing ?relpath
|
briefing ?relpath
|
||||||
@@ -271,6 +296,13 @@ _provider_valid_value() {
|
|||||||
# and nothing shell-active does.
|
# and nothing shell-active does.
|
||||||
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*( [A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]
|
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*( [A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]
|
||||||
;;
|
;;
|
||||||
|
flag)
|
||||||
|
# A command-line option name, compared against argv by
|
||||||
|
# docker/forge-guard.sh. The leading dash is required: without it
|
||||||
|
# the guard would be comparing a word that could just as well be a
|
||||||
|
# subcommand or a value.
|
||||||
|
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
|
||||||
|
;;
|
||||||
relpath)
|
relpath)
|
||||||
# Joined to a provider directory, so no absolute paths and no
|
# Joined to a provider directory, so no absolute paths and no
|
||||||
# traversal - a manifest may only name a file it ships.
|
# traversal - a manifest may only name a file it ships.
|
||||||
@@ -600,11 +632,54 @@ _config_extra_section_known() {
|
|||||||
case "$section" in
|
case "$section" in
|
||||||
forge.*) axis=forge; name="${section#forge.}" ;;
|
forge.*) axis=forge; name="${section#forge.}" ;;
|
||||||
harness.*) axis=harness; name="${section#harness.}" ;;
|
harness.*) axis=harness; name="${section#harness.}" ;;
|
||||||
|
# An identity is the operator's own name for a set of credentials, so
|
||||||
|
# there is no directory to check it against - only that it is a name
|
||||||
|
# at all. Unlike a provider, an unused one is not a misconfiguration:
|
||||||
|
# having [identity.oss] on a machine that never selects it is the
|
||||||
|
# normal state of a second account.
|
||||||
|
identity.*)
|
||||||
|
name="${section#identity.}"
|
||||||
|
valid_provider_name "$name"
|
||||||
|
return
|
||||||
|
;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
provider_dir "$axis" "$name" >/dev/null 2>&1
|
provider_dir "$axis" "$name" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The keys an [identity.<name>] section may carry, as "<key>\t<type>" lines.
|
||||||
|
#
|
||||||
|
# ssh_key and the git identity are core's, because every forge needs the same
|
||||||
|
# two answers to "who is pushing". Everything else is declared by a forge's
|
||||||
|
# identity_keys and typed by that forge's own keys entry, so the identity
|
||||||
|
# inherits the validation the forge already wrote rather than a second copy of
|
||||||
|
# it that could disagree.
|
||||||
|
#
|
||||||
|
# A key name declared by two forges resolves to one type here, which is why a
|
||||||
|
# static test refuses that collision rather than this function guessing.
|
||||||
|
_identity_key_specs() {
|
||||||
|
printf 'ssh_key ?path
|
||||||
|
'
|
||||||
|
printf 'user_name ?text
|
||||||
|
'
|
||||||
|
printf 'user_email ?text
|
||||||
|
'
|
||||||
|
local forge key spec
|
||||||
|
for forge in $(provider_list forge); do
|
||||||
|
for key in $(manifest_get forge "$forge" identity_keys); do
|
||||||
|
for spec in $(_provider_keys_field forge "$forge"); do
|
||||||
|
[ "${spec%%:*}" = "$key" ] || continue
|
||||||
|
# The forge's own type, with its default deliberately dropped:
|
||||||
|
# a default belongs to the forge's section, and repeating it
|
||||||
|
# here would make an identity that names no token look like one
|
||||||
|
# that named the default path.
|
||||||
|
printf '%s\t?%s\n' "$key" \
|
||||||
|
"$(cut -f2 <<< "$(_provider_keyspec_parse "$spec")" | sed 's/^?//')"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
|
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
|
||||||
# provider's `keys` manifest entry instead of a row hardcoded in core.
|
# provider's `keys` manifest entry instead of a row hardcoded in core.
|
||||||
_config_extra_spec() {
|
_config_extra_spec() {
|
||||||
@@ -612,6 +687,17 @@ _config_extra_spec() {
|
|||||||
case "$want" in
|
case "$want" in
|
||||||
forge.*) axis=forge ;;
|
forge.*) axis=forge ;;
|
||||||
harness.*) axis=harness ;;
|
harness.*) axis=harness ;;
|
||||||
|
identity.*)
|
||||||
|
rest="${want#identity.}"
|
||||||
|
key="${rest#*.}"
|
||||||
|
[ "$key" != "$rest" ] || return 1
|
||||||
|
while IFS=$'\t' read -r pname ptype; do
|
||||||
|
[ "$pname" = "$key" ] || continue
|
||||||
|
printf '%s\t' "$ptype"
|
||||||
|
return 0
|
||||||
|
done < <(_identity_key_specs)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
rest="${want#*.}"
|
rest="${want#*.}"
|
||||||
@@ -632,6 +718,250 @@ _config_extra_spec() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# What the selected identity says about one key, if anything.
|
||||||
|
#
|
||||||
|
# An identity answers for the credentials and the git name a PERSON brings to
|
||||||
|
# a project, which is the half a project cannot declare: .dejima/ is committed
|
||||||
|
# and shared, and a key path is per-user and per-machine. The project (or the
|
||||||
|
# operator) names an identity; the identity supplies the paths.
|
||||||
|
#
|
||||||
|
# Returns 1 for a key no identity owns, which is most of them - the caller
|
||||||
|
# then reads the config file as before. Deliberately narrow: an identity that
|
||||||
|
# could set any key would be a second config file with different rules about
|
||||||
|
# what may be in it.
|
||||||
|
#
|
||||||
|
# core.identity itself is excluded, and not merely by convention: the caller
|
||||||
|
# that consults this is bin/dejima's config_get wrapper, so answering for the
|
||||||
|
# key that names the identity would recurse into itself.
|
||||||
|
# Whether the named identity exists in the config at all - any of its keys
|
||||||
|
# being present is enough, because a section with no keys is not a section
|
||||||
|
# the parser records.
|
||||||
|
#
|
||||||
|
# The check that matters: [core] identity naming a section nobody wrote is a
|
||||||
|
# silent fall-through to the machine's defaults, which is the wrong direction
|
||||||
|
# to be wrong in. Pushing under the wrong key fails loudly; pushing under the
|
||||||
|
# default key when you asked for another one does not fail at all.
|
||||||
|
identity_exists() {
|
||||||
|
local name="$1" key
|
||||||
|
[ -n "$name" ] || return 1
|
||||||
|
valid_provider_name "$name" || return 1
|
||||||
|
while IFS=$'\t' read -r key _; do
|
||||||
|
config_is_set "identity.$name" "$key" && return 0
|
||||||
|
done < <(_identity_key_specs)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The credential keys the selected identity leaves to the section beneath it,
|
||||||
|
# as "<section> <key>" lines, empty when there are none.
|
||||||
|
#
|
||||||
|
# Only the selected forge's, because refusing over a credential belonging to a
|
||||||
|
# forge this launch does not use would be refusing over a value nothing reads.
|
||||||
|
#
|
||||||
|
# "Set" means written in the config file, not resolved from a schema default.
|
||||||
|
# A forge whose credential key carries a default still hands an identity that
|
||||||
|
# names none the machine's own path, and that is not caught here - see
|
||||||
|
# docs/configuration.md, which says so rather than leaving it to be found.
|
||||||
|
identity_borrowed_credentials() {
|
||||||
|
local name="$1" forge="$2" key
|
||||||
|
if ! config_is_set "identity.$name" ssh_key && config_is_set core ssh_key; then
|
||||||
|
printf 'core ssh_key
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
for key in $(manifest_get forge "$forge" identity_keys); do
|
||||||
|
if ! config_is_set "identity.$name" "$key" \
|
||||||
|
&& config_is_set "forge.$forge" "$key"; then
|
||||||
|
printf 'forge.%s %s
|
||||||
|
' "$forge" "$key"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether the selected identity, if there is one, is one a session can act
|
||||||
|
# under without borrowing from anyone.
|
||||||
|
#
|
||||||
|
# Two refusals, one reason. A name with no section resolves to nothing and
|
||||||
|
# falls through to the machine's own credentials; a section that names only
|
||||||
|
# some of them falls through for the rest. Either way a session launched as
|
||||||
|
# this identity acts under another account, and nothing says so. Pushing under
|
||||||
|
# the wrong key fails loudly; pushing under the default key when you asked for
|
||||||
|
# another one does not fail at all, which is the direction worth being careful
|
||||||
|
# about.
|
||||||
|
#
|
||||||
|
# One function rather than a check at each caller: bin/dejima's --print-config
|
||||||
|
# needs the same answer, because every value it prints resolves THROUGH the
|
||||||
|
# identity and printing this machine's credentials under an identity's name is
|
||||||
|
# the confusion the refusal exists to prevent.
|
||||||
|
#
|
||||||
|
# Guarded on the config existing rather than swallowing config_get's stderr:
|
||||||
|
# with no file at all it says so, and a selection validated without one is a
|
||||||
|
# legitimate case this must stay silent for.
|
||||||
|
# The identity in force: [core] identity when it names one, and otherwise the
|
||||||
|
# reserved `default` section when the config has one.
|
||||||
|
#
|
||||||
|
# `default` is what [core] ssh_key was without a name. Giving it one is what
|
||||||
|
# lets a host state its ordinary account the same way it states any other,
|
||||||
|
# with a git name and a forge credential beside the key rather than those
|
||||||
|
# coming from three different places.
|
||||||
|
#
|
||||||
|
# Empty when neither applies, which is the pre-identity config and still a
|
||||||
|
# supported one.
|
||||||
|
# Assigns rather than prints, and every caller uses this form. identity_get
|
||||||
|
# runs on every config_get through bin/dejima's wrapper, and `$(...)` around
|
||||||
|
# an answer this cheap costs a fork per read - which is most of what a launch
|
||||||
|
# spends on config.
|
||||||
|
identity_selected_into() {
|
||||||
|
local __var="$1" __name=""
|
||||||
|
if config_exists; then
|
||||||
|
__name="$(config_get core identity)" || __name=""
|
||||||
|
if [ -z "$__name" ] && _identity_section_present default; then
|
||||||
|
__name=default
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf -v "$__var" '%s' "$__name"
|
||||||
|
}
|
||||||
|
|
||||||
|
identity_selected() {
|
||||||
|
local _n
|
||||||
|
identity_selected_into _n
|
||||||
|
printf '%s' "$_n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether a section has any key in the parsed config, read straight off the
|
||||||
|
# parse rather than through identity_exists.
|
||||||
|
#
|
||||||
|
# identity_exists walks every forge's manifest to decide which keys count, and
|
||||||
|
# bin/dejima's config_get wrapper reaches this on every single read - which
|
||||||
|
# turned a --print-config from three seconds into forty. It does not need the
|
||||||
|
# manifest: the parser refuses an unknown key outright, so anything present
|
||||||
|
# under identity.<name> in a config that validated is already one of the keys
|
||||||
|
# identity_exists would have accepted.
|
||||||
|
_identity_section_present() {
|
||||||
|
local name="$1" k
|
||||||
|
config_exists || return 1
|
||||||
|
config_validate || return 1
|
||||||
|
for k in "${!_CFG_SEEN[@]}"; do
|
||||||
|
case "$k" in "identity.$name".*) return 0 ;; esac
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
identity_validate() {
|
||||||
|
local forge="$1" name="" borrowed section key
|
||||||
|
name="$(identity_selected)"
|
||||||
|
|
||||||
|
# [core] ssh_key is the default identity under its old name, so a config
|
||||||
|
# holding both says the same thing twice and dejima would have to pick.
|
||||||
|
# Refused rather than ranked: which key a session pushed with is not a
|
||||||
|
# thing to work out from precedence rules.
|
||||||
|
if identity_exists default && config_is_set core ssh_key; then
|
||||||
|
echo "[core] ssh_key is set and so is [identity.default], which is the same" >&2
|
||||||
|
echo "thing under two names - the key a session gets when none is asked for." >&2
|
||||||
|
echo "Move it into [identity.default] and remove [core] ssh_key." >&2
|
||||||
|
echo "See docs/configuration.md, \"Identities\"." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deprecated, not removed: it is how every config written before
|
||||||
|
# identities existed says this, and those still work. Warned on every
|
||||||
|
# launch that has it set, not only the ones using it - a config with
|
||||||
|
# [core] ssh_key and a named identity is one migration away from the
|
||||||
|
# refusal above, and finding that out on the launch that breaks is worse
|
||||||
|
# than being told now.
|
||||||
|
#
|
||||||
|
# Every launch rather than once, because there is nowhere to record
|
||||||
|
# "once" that a session could not reach, and a deprecation nobody sees
|
||||||
|
# until removal is not a deprecation.
|
||||||
|
if config_is_set core ssh_key; then
|
||||||
|
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
|
||||||
|
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||||
|
printf ' under its old name.\n' >&2
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
printf ' This launch runs as %s%s%s, so it is not the key being used here.\n' \
|
||||||
|
"${C_KEY:-}" "$name" "${C_RESET:-}" >&2
|
||||||
|
else
|
||||||
|
printf ' Nothing else names an account, so it is the key this launch uses.\n' >&2
|
||||||
|
fi
|
||||||
|
printf ' Move it into an [identity.default] section, which can carry the git\n' >&2
|
||||||
|
printf ' name and the forge credential belonging to that same account:\n\n' >&2
|
||||||
|
# The value in the file, not through config_get: in this process
|
||||||
|
# config_get is bin/dejima's wrapper, so with a named identity in
|
||||||
|
# force it answers with THAT identity's key - and printing it as the
|
||||||
|
# thing to move would name a key the operator is not being asked to
|
||||||
|
# touch.
|
||||||
|
printf ' [identity.default]\n ssh_key = %s\n\n' \
|
||||||
|
"$(_config_get_from_file core ssh_key)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$name" ] || return 0
|
||||||
|
|
||||||
|
if ! identity_exists "$name"; then
|
||||||
|
echo "[core] identity is '$name', and no [identity.$name] section sets" >&2
|
||||||
|
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
|
||||||
|
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
|
||||||
|
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
borrowed="$(identity_borrowed_credentials "$name" "$forge")"
|
||||||
|
[ -n "$borrowed" ] || return 0
|
||||||
|
echo "[identity.$name] leaves a credential to the section beneath it, so a" >&2
|
||||||
|
echo "session launched as '$name' would act under whichever account owns:" >&2
|
||||||
|
while IFS=$'\t' read -r section key; do
|
||||||
|
[ -n "$key" ] || continue
|
||||||
|
echo " [$section] $key" >&2
|
||||||
|
done <<< "$borrowed"
|
||||||
|
echo "An identity is how one session's account is kept separate from another's;" >&2
|
||||||
|
echo "set each of those under [identity.$name], or unset it in the section named." >&2
|
||||||
|
echo "See docs/configuration.md, \"Identities\"." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
identity_get() {
|
||||||
|
local section="$1" key="$2" name value pname
|
||||||
|
[ "$section.$key" != "core.identity" ] || return 1
|
||||||
|
# Which keys an identity could own, before working out which identity is
|
||||||
|
# in force: this runs on every config_get, and all but a handful of keys
|
||||||
|
# are ones no identity can answer for. Resolving the identity first meant
|
||||||
|
# doing that work ~40 times a launch to discard it ~35 of those times.
|
||||||
|
case "$section" in
|
||||||
|
core) [ "$key" = ssh_key ] || return 1 ;;
|
||||||
|
forge.*) ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
identity_selected_into name
|
||||||
|
[ -n "$name" ] || return 1
|
||||||
|
# The manifest lookup last, for the same reason: it forks, and by here
|
||||||
|
# there is an identity that might actually answer.
|
||||||
|
case "$section" in
|
||||||
|
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
|
||||||
|
esac
|
||||||
|
value="$(config_get "identity.$name" "$key")" || return 1
|
||||||
|
[ -n "$value" ] || return 1
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether one forge declared this key as naming an account rather than the
|
||||||
|
# forge itself - a credential a session acts under, not a setting like an API
|
||||||
|
# URL that every session on this host shares.
|
||||||
|
_forge_owns_identity_key() {
|
||||||
|
local forge="$1" key="$2" k
|
||||||
|
for k in $(manifest_get forge "$forge" identity_keys); do
|
||||||
|
[ "$k" = "$key" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The git name and address the identity carries, for the gitconfig copy the
|
||||||
|
# container gets. Not core config keys: the host's own ~/.gitconfig is where
|
||||||
|
# they come from without an identity, and adding a second place to set them
|
||||||
|
# globally would be a new way to get the same answer.
|
||||||
|
identity_git_field() {
|
||||||
|
local field="$1" name
|
||||||
|
name="$(identity_selected)" || return 1
|
||||||
|
[ -n "$name" ] || return 1
|
||||||
|
config_get "identity.$name" "$field"
|
||||||
|
}
|
||||||
|
|
||||||
# One key of the SELECTED harness, rather than of a harness named literally
|
# One key of the SELECTED harness, rather than of a harness named literally
|
||||||
# at the call site.
|
# at the call site.
|
||||||
#
|
#
|
||||||
@@ -707,6 +1037,8 @@ provider_validate_selection() {
|
|||||||
# quietly absent, because an operator who has them everywhere else would
|
# quietly absent, because an operator who has them everywhere else would
|
||||||
# otherwise have to read a manifest to find out they are gone here.
|
# otherwise have to read a manifest to find out they are gone here.
|
||||||
#
|
#
|
||||||
|
identity_validate "$forge" || return 1
|
||||||
|
|
||||||
# A name outside this set is a hard error rather than an unknown guard
|
# A name outside this set is a hard error rather than an unknown guard
|
||||||
# treated as absent: a typo would otherwise silently turn a guard off,
|
# treated as absent: a typo would otherwise silently turn a guard off,
|
||||||
# which is the failure this whole declaration exists to prevent.
|
# which is the failure this whole declaration exists to prevent.
|
||||||
@@ -951,6 +1283,10 @@ What this means:
|
|||||||
briefing.{{writable_mounts}} Everything else is disposable: installed system
|
briefing.{{writable_mounts}} Everything else is disposable: installed system
|
||||||
packages, anything outside the mounted paths, and the container itself go
|
packages, anything outside the mounted paths, and the container itself go
|
||||||
away when this run ends.
|
away when this run ends.
|
||||||
|
- A headless browser is installed: playwright, with chromium, importable
|
||||||
|
from any directory in either module system. If you change something with a
|
||||||
|
user interface, look at it rather than describing what it should look like,
|
||||||
|
and leave the screenshot in ~/shared.
|
||||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||||
registry, {{network_allowlist}}, plus anything
|
registry, {{network_allowlist}}, plus anything
|
||||||
approved via dejima-allow). Requests to anything else will simply
|
approved via dejima-allow). Requests to anything else will simply
|
||||||
@@ -963,9 +1299,9 @@ What this means:
|
|||||||
it appears to. If a task needs one more domain and nothing else, that's
|
it appears to. If a task needs one more domain and nothing else, that's
|
||||||
a quick fix, not a reason to stop. Tell the user to run, from a terminal
|
a quick fix, not a reason to stop. Tell the user to run, from a terminal
|
||||||
outside the sandbox, whichever of these matches why the domain is needed:
|
outside the sandbox, whichever of these matches why the domain is needed:
|
||||||
bin/dejima-allow --container {{container_id}} <domain> # just this session
|
dejima-allow --container {{container_id}} <domain> # just this session
|
||||||
bin/dejima-allow --project <path> <domain> # this project, always
|
dejima-allow --project <path> <domain> # this project, always
|
||||||
bin/dejima-allow --global <domain> # every project
|
dejima-allow --global <domain> # every project
|
||||||
Pick deliberately rather than asking for all three: a domain this project
|
Pick deliberately rather than asking for all three: a domain this project
|
||||||
needs is --project, not --global - a global grant widens every other
|
needs is --project, not --global - a global grant widens every other
|
||||||
project's firewall too, which is rarely what one project's dependency
|
project's firewall too, which is rarely what one project's dependency
|
||||||
@@ -1333,6 +1669,77 @@ _briefing_substitute() {
|
|||||||
# the resume command) - in that reading order. Every {{key}} is substituted
|
# the resume command) - in that reading order. Every {{key}} is substituted
|
||||||
# except {{session_id}}/{{container_id}}, deferred as @@SESSION_ID@@ and
|
# except {{session_id}}/{{container_id}}, deferred as @@SESSION_ID@@ and
|
||||||
# @@CONTAINER_ID@@ for the hook that mounts this at session start.
|
# @@CONTAINER_ID@@ for the hook that mounts this at session start.
|
||||||
|
# A tool's manifest, plus the one rule its schema cannot express.
|
||||||
|
#
|
||||||
|
# image_env becomes a Dockerfile ENV, which every process in the container
|
||||||
|
# inherits - not just the tool that declared it. A harness may set a bare name
|
||||||
|
# there because a harness IS the container's main process; a tool is one
|
||||||
|
# program among many and may not.
|
||||||
|
#
|
||||||
|
# This is not hypothetical. rtk shipped with NO_COLOR=1, reasoning that its
|
||||||
|
# output lands in a model's context where an escape sequence is noise. The
|
||||||
|
# reasoning was fine, the scope was wrong, and a launch with that tool enabled
|
||||||
|
# lost colour in the harness, git, ls and everything else. The rule that would
|
||||||
|
# have caught it is that a tool's variables must be namespaced to the tool.
|
||||||
|
#
|
||||||
|
# Uppercased, with anything that is not alphanumeric folded to an underscore,
|
||||||
|
# so a tool named `my-tool` claims `MY_TOOL_*`.
|
||||||
|
tool_validate() {
|
||||||
|
local name="$1" prefix pair varname
|
||||||
|
manifest_validate tool "$name" || return 1
|
||||||
|
prefix="$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]' | tr -c '[:alnum:]' '_')_"
|
||||||
|
for pair in $(manifest_get tool "$name" image_env); do
|
||||||
|
varname="${pair%%=*}"
|
||||||
|
case "$varname" in
|
||||||
|
"$prefix"*) continue ;;
|
||||||
|
esac
|
||||||
|
echo "tool '$name' declares image_env $varname, which is not its own." >&2
|
||||||
|
echo "A tool's image_env reaches every process in the container, so a bare" >&2
|
||||||
|
echo "name like NO_COLOR or DO_NOT_TRACK changes the harness and everything" >&2
|
||||||
|
echo "else too. Name it ${prefix}something, or do not set it." >&2
|
||||||
|
return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# That the authorship marker is added for the session, so it does not add one.
|
||||||
|
#
|
||||||
|
# Every issue and pull request on a project worked on this way opens with that
|
||||||
|
# line, so an agent reading the existing ones concludes it is the house style
|
||||||
|
# and writes it into the body - and the shadow then added a second. Each
|
||||||
|
# doubled artifact taught the next agent to do the same. docker/forge-guard.sh
|
||||||
|
# refuses to add a second now, which holds whatever the session does; this is
|
||||||
|
# the other half, which stops it being written in the first place.
|
||||||
|
_briefing_core_disclosure() {
|
||||||
|
printf '%s' "Bodies you write for the forge - pull requests, issues, comments - are
|
||||||
|
marked as agent-written before they are sent. Do not write that line
|
||||||
|
yourself: it is added for you, and one is what the artifact should carry.
|
||||||
|
The existing issues on a project all open with it for this reason, not
|
||||||
|
because it was typed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# What the session is told about the optional tools this launch enabled.
|
||||||
|
#
|
||||||
|
# The heading is core's and the body is each tool's own instructions.md, so a
|
||||||
|
# tool describes itself once and every harness gets the same words. Nothing
|
||||||
|
# here can change what the session may do - a tool's manifest has no key for
|
||||||
|
# that - which is what makes rendering a project-selectable fragment into the
|
||||||
|
# briefing safe at all.
|
||||||
|
_briefing_core_tools() {
|
||||||
|
local tools="$1" tool frag dir out=""
|
||||||
|
out="Extra tools this session has, beyond the usual shell:"
|
||||||
|
for tool in $tools; do
|
||||||
|
dir="$(provider_dir tool "$tool")" || continue
|
||||||
|
frag="$(manifest_get tool "$tool" instructions)" || frag=""
|
||||||
|
out+=$'\n\n'
|
||||||
|
out+="- **$tool** - $(manifest_get tool "$tool" description)"
|
||||||
|
if [ -n "$frag" ] && [ -f "$dir/$frag" ]; then
|
||||||
|
out+=$'\n\n'
|
||||||
|
out+="$(sed 's/^/ /' "$dir/$frag")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
briefing_compose() {
|
briefing_compose() {
|
||||||
local forge="$1" workflow="$2" harness="$3"
|
local forge="$1" workflow="$2" harness="$3"
|
||||||
local caps autonomy workflow_frag harness_frag text
|
local caps autonomy workflow_frag harness_frag text
|
||||||
@@ -1362,6 +1769,22 @@ briefing_compose() {
|
|||||||
text+="$(_briefing_core_corpus)"
|
text+="$(_briefing_core_corpus)"
|
||||||
text+=$'\n\n'
|
text+=$'\n\n'
|
||||||
fi
|
fi
|
||||||
|
# Optional tools, from DEJIMA_TOOLS. A tool the session is not told about
|
||||||
|
# is one it will not use, so the manifest's instructions fragment is the
|
||||||
|
# whole of the wiring for a harness: dejima renders it rather than letting
|
||||||
|
# the tool's own installer patch a config, which is the difference
|
||||||
|
# docs/tools.md draws between a hardened tool and a bare install.
|
||||||
|
if [ -n "${DEJIMA_TOOLS:-}" ]; then
|
||||||
|
text+="$(_briefing_core_tools "${DEJIMA_TOOLS}")"
|
||||||
|
text+=$'\n\n'
|
||||||
|
fi
|
||||||
|
# Only when something actually discloses. A session told about a marker
|
||||||
|
# nothing adds would be told a falsehood, and would then reasonably write
|
||||||
|
# one itself - which is the failure this paragraph exists to stop.
|
||||||
|
if [ -n "${DEJIMA_DISCLOSE:-}" ]; then
|
||||||
|
text+="$(_briefing_core_disclosure)"
|
||||||
|
text+=$'\n\n'
|
||||||
|
fi
|
||||||
# The harness fragment closes the briefing: it supplies both the resume
|
# The harness fragment closes the briefing: it supplies both the resume
|
||||||
# command closing_a has just promised and the sentence about where the
|
# command closing_a has just promised and the sentence about where the
|
||||||
# session history it resumes from actually lives. Both are the harness's
|
# session history it resumes from actually lives. Both are the harness's
|
||||||
|
|||||||
+64
-5
@@ -23,10 +23,28 @@
|
|||||||
# when it takes the terminal at the prompt, and puts those same modes back on
|
# 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 -
|
# `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
|
# 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
|
# trap re-applies the modes the container run was using.
|
||||||
# with SIGWINCH: the container client answers a resize by resizing the
|
#
|
||||||
# container's TTY, which is what makes a full-screen harness repaint over
|
# It then has to make the harness redraw, over whatever the shell printed while
|
||||||
# whatever the shell printed while the session was away.
|
# the session was away. That used to be `kill -WINCH`, on the reasoning that a
|
||||||
|
# resize is what makes a full-screen program repaint. The reasoning was right
|
||||||
|
# and the signal was not enough: a program that answers SIGWINCH by re-reading
|
||||||
|
# the size, seeing the same numbers and doing nothing gets a poke that costs it
|
||||||
|
# nothing to ignore.
|
||||||
|
#
|
||||||
|
# Measured against the real programs rather than a stand-in, on a pty, counting
|
||||||
|
# the bytes each wrote in a fixed window after each kind of poke:
|
||||||
|
#
|
||||||
|
# idle same-size SIGWINCH size changed and back
|
||||||
|
# Claude Code 0 0 3337
|
||||||
|
# Cline 0 0 17810
|
||||||
|
# nvim (control) 0 3356 6724
|
||||||
|
#
|
||||||
|
# Both shipped harnesses ignore the signal outright. So the size is changed
|
||||||
|
# and changed straight back, which is a genuine resize twice over: TIOCSWINSZ
|
||||||
|
# raises SIGWINCH on the foreground process group by itself, the container
|
||||||
|
# client answers a resize by resizing the container's TTY, and the harness
|
||||||
|
# inside sees dimensions that actually moved.
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
@@ -85,7 +103,48 @@ session_modes="$(stty -g < /dev/tty 2>/dev/null)" || exit 0
|
|||||||
|
|
||||||
on_cont() {
|
on_cont() {
|
||||||
stty "$session_modes" < /dev/tty 2>/dev/null || true
|
stty "$session_modes" < /dev/tty 2>/dev/null || true
|
||||||
# The whole process group, since this script does not know the container
|
|
||||||
|
# One row shorter, held, and back. Only the kernel's idea of the size
|
||||||
|
# changes - the terminal emulator is not told and does not need to be,
|
||||||
|
# because what a program reads is the kernel's value. It ends where it
|
||||||
|
# started, so a failure partway leaves the size wrong rather than the
|
||||||
|
# session unusable, which is why the restore is unconditional.
|
||||||
|
#
|
||||||
|
# The hold is the whole of it, and the first version of this did not have
|
||||||
|
# one. Two ioctls back to back are not a resize a program can see: the
|
||||||
|
# signal arrives, the handler reads the size as it is NOW, and by then it
|
||||||
|
# is back where it started. Measured on a pty, bytes written after the
|
||||||
|
# poke:
|
||||||
|
#
|
||||||
|
# no hold 0.1s 0.2s
|
||||||
|
# Cline 0 0 17810
|
||||||
|
# Claude Code 0 3328 3328
|
||||||
|
#
|
||||||
|
# Cline sets the floor at something between 0.1 and 0.2 seconds. This uses
|
||||||
|
# half a second, which is 2.5x that - headroom for the hop this could not
|
||||||
|
# measure, since a real session's resize has to travel from the host tty
|
||||||
|
# through the container client and the daemon to the container's own tty
|
||||||
|
# before the harness sees it at all.
|
||||||
|
#
|
||||||
|
# The cost is that the terminal is one row short for half a second during
|
||||||
|
# a resume, while the harness is redrawing anyway.
|
||||||
|
local size rows
|
||||||
|
size="$(stty size < /dev/tty 2>/dev/null)" || size=""
|
||||||
|
rows="${size%% *}"
|
||||||
|
case "$rows" in
|
||||||
|
''|*[!0-9]*) rows="" ;;
|
||||||
|
*) [ "$rows" -gt 1 ] || rows="" ;;
|
||||||
|
esac
|
||||||
|
if [ -n "$rows" ]; then
|
||||||
|
stty rows "$((rows - 1))" < /dev/tty 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
stty rows "$rows" < /dev/tty 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kept as well as the resize, and not redundant: nvim is the measured
|
||||||
|
# example of a program that repaints on the bare signal, and a session
|
||||||
|
# running one under `--cmd` should not be worse off than a harness. The
|
||||||
|
# whole process group, since this script does not know the container
|
||||||
# client's pid and SIGWINCH is ignored by default everywhere else in it.
|
# client's pid and SIGWINCH is ignored by default everywhere else in it.
|
||||||
kill -WINCH 0 2>/dev/null || true
|
kill -WINCH 0 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ tmux_prefix="$(remote_ttyd_tmux_prefix)"
|
|||||||
# cannot see. See docs/remote-access.md.
|
# cannot see. See docs/remote-access.md.
|
||||||
focus_on_tap="$(remote_ttyd_focus_on_tap)"
|
focus_on_tap="$(remote_ttyd_focus_on_tap)"
|
||||||
|
|
||||||
|
# Whether the toolbar is colour-coded. Passed on only when set, for the same
|
||||||
|
# reason as the line above: the page has a default and it is the right one.
|
||||||
|
button_colors="$(remote_ttyd_button_colors)"
|
||||||
|
|
||||||
# Checked rather than left to exec, whose failure is bash's "ttyd: not found"
|
# 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
|
# on a line number - true, and useless to an operator who needs to know that
|
||||||
# the missing piece is a package.
|
# the missing piece is a package.
|
||||||
@@ -171,10 +175,38 @@ prefix_opt=()
|
|||||||
[ -z "$tmux_prefix" ] || prefix_opt=(-t "tmuxPrefix=$tmux_prefix")
|
[ -z "$tmux_prefix" ] || prefix_opt=(-t "tmuxPrefix=$tmux_prefix")
|
||||||
focus_opt=()
|
focus_opt=()
|
||||||
[ -z "$focus_on_tap" ] || focus_opt=(-t "focusOnTap=$focus_on_tap")
|
[ -z "$focus_on_tap" ] || focus_opt=(-t "focusOnTap=$focus_on_tap")
|
||||||
|
colors_opt=()
|
||||||
|
[ -z "$button_colors" ] || colors_opt=(-t "buttonColors=$button_colors")
|
||||||
|
|
||||||
|
# The sixteen ANSI slots xterm.js draws with, which without this are its own
|
||||||
|
# defaults - white on black, a bright red, a magenta. dejima's own output
|
||||||
|
# reaches a terminal in indexed colour precisely so the terminal's theme fills
|
||||||
|
# those slots (see dejima-color-lib.sh), and here dejima IS the terminal: the
|
||||||
|
# browser has no gruvbox to inherit, so the front door showed the stock
|
||||||
|
# palette while the same session over SSH showed the operator's.
|
||||||
|
#
|
||||||
|
# So the exact hexes belong here, by the same rule as web/src/page.css: they
|
||||||
|
# are the two surfaces where dejima owns the background. A static test keeps
|
||||||
|
# the values they share in step.
|
||||||
|
#
|
||||||
|
# Upstream gruvbox dark, by its own names. `background` and `foreground` are
|
||||||
|
# bg0/light1, which is what a gruvbox terminal is set to.
|
||||||
|
theme_opt=(-t 'theme={
|
||||||
|
"background":"#282828","foreground":"#ebdbb2",
|
||||||
|
"cursor":"#ebdbb2","cursorAccent":"#282828",
|
||||||
|
"selectionBackground":"#504945",
|
||||||
|
"black":"#282828","red":"#cc241d","green":"#98971a","yellow":"#d79921",
|
||||||
|
"blue":"#458588","magenta":"#b16286","cyan":"#689d6a","white":"#a89984",
|
||||||
|
"brightBlack":"#928374","brightRed":"#fb4934","brightGreen":"#b8bb26",
|
||||||
|
"brightYellow":"#fabd2f","brightBlue":"#83a598","brightMagenta":"#d3869b",
|
||||||
|
"brightCyan":"#8ec07c","brightWhite":"#ebdbb2"
|
||||||
|
}')
|
||||||
|
|
||||||
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
|
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
|
||||||
--index "$index_html" \
|
--index "$index_html" \
|
||||||
${font_opt[@]+"${font_opt[@]}"} \
|
${font_opt[@]+"${font_opt[@]}"} \
|
||||||
${prefix_opt[@]+"${prefix_opt[@]}"} \
|
${prefix_opt[@]+"${prefix_opt[@]}"} \
|
||||||
${focus_opt[@]+"${focus_opt[@]}"} \
|
${focus_opt[@]+"${focus_opt[@]}"} \
|
||||||
|
${colors_opt[@]+"${colors_opt[@]}"} \
|
||||||
|
"${theme_opt[@]}" \
|
||||||
"$SCRIPT_DIR/dejima-attach"
|
"$SCRIPT_DIR/dejima-attach"
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The small terminal interface dejima-attach and dejima-allow share.
|
||||||
|
#
|
||||||
|
# Two tools ask an operator to pick one running session out of several, and
|
||||||
|
# both had to be readable on whatever terminal the operator happened to be
|
||||||
|
# holding. This is the part that does not depend on which of them is asking:
|
||||||
|
# the palette, the heading, and the prompt.
|
||||||
|
#
|
||||||
|
# What is NOT here is the row. Each tool lists a different thing from a
|
||||||
|
# different source - the registry of remote-enabled sessions on one side,
|
||||||
|
# `docker ps` on the other - so the columns are the tool's own, measured by
|
||||||
|
# the tool against its own data.
|
||||||
|
#
|
||||||
|
# Sourced, never run.
|
||||||
|
|
||||||
|
# The palette is dejima-color-lib.sh's, and there is only one of it. This file
|
||||||
|
# used to carry a second, in hardcoded gruvbox truecolor, which collided with
|
||||||
|
# that one on C_OK and C_WARN and broke on any background dejima does not own -
|
||||||
|
# see the comment there for the contrast measurements that decided it.
|
||||||
|
#
|
||||||
|
# Sourced and initialised here rather than left to the caller, because both
|
||||||
|
# callers draw to stdout and this file's functions assume the names exist.
|
||||||
|
# shellcheck source=dejima-color-lib.sh
|
||||||
|
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/dejima-color-lib.sh"
|
||||||
|
color_init 1
|
||||||
|
|
||||||
|
# The rule under the heading, the width of the heading itself. Built rather
|
||||||
|
# than written out because the heading is now the caller's: two literals kept
|
||||||
|
# next to two headings stay in step only while nobody adds a third.
|
||||||
|
#
|
||||||
|
# A loop rather than `tr`, which is byte-oriented and would shred a 3-byte box
|
||||||
|
# drawing character into three copies of nothing.
|
||||||
|
tui_rule() {
|
||||||
|
local n="$1" i out=""
|
||||||
|
for ((i = 0; i < n; i++)); do out+="$GLYPH_RULE"; done
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# "dejima | <subtitle>", underlined to its own width.
|
||||||
|
# A blank screen to draw a front door on, scrollback included.
|
||||||
|
#
|
||||||
|
# The picker is reached by navigating to it - the web toolbar's SESS button is
|
||||||
|
# a full page load - and it drew on top of whatever the previous session had
|
||||||
|
# left on screen, so the rows arrived interleaved with the tail of the session
|
||||||
|
# just left. Over SSH it was invisible, because a fresh connection to the
|
||||||
|
# forced command usually starts on an empty screen (#213).
|
||||||
|
#
|
||||||
|
# 3J as well as 2J: without it a swipe up on a phone lands in the middle of
|
||||||
|
# the previous session's output, which is the same confusion one gesture away.
|
||||||
|
#
|
||||||
|
# Its own function rather than part of tui_banner, because dejima-allow's
|
||||||
|
# picker must NOT do this: that one is a prompt inside a shell the operator is
|
||||||
|
# already using, and wiping their scrollback to ask one question is worse than
|
||||||
|
# the interleaving this fixes.
|
||||||
|
#
|
||||||
|
# Rides the same switch as the colour and the glyphs, for the reason stated
|
||||||
|
# above them: one flag is easier to reason about than two, and the terminals
|
||||||
|
# that mangle box drawing are the ones these bytes should not reach either. So
|
||||||
|
# NO_COLOR turns this off as well - a reading of it this file has already
|
||||||
|
# committed to, and the alternative is a picker that answers to two different
|
||||||
|
# ideas of "plain".
|
||||||
|
tui_clear() {
|
||||||
|
[ -n "$C_OFF" ] || return 0
|
||||||
|
printf '\033[H\033[2J\033[3J'
|
||||||
|
}
|
||||||
|
|
||||||
|
tui_banner() {
|
||||||
|
local subtitle="$1"
|
||||||
|
printf '\n %s%sdejima%s %s%s%s %s%s%s\n' \
|
||||||
|
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
|
||||||
|
"$C_MUTED" "$subtitle" "$C_OFF"
|
||||||
|
# 6 for the wordmark, 3 for the bar and the spaces either side.
|
||||||
|
printf ' %s%s%s\n\n' "$C_RULE" "$(tui_rule $((9 + ${#subtitle})))" "$C_OFF"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asks for one of 1..$1 and leaves it in TUI_CHOICE. $2 is the verb the
|
||||||
|
# prompt opens with. Exits the caller outright on anything meaning "not this
|
||||||
|
# time" - q, an empty line, or no terminal to read from.
|
||||||
|
#
|
||||||
|
# The answer comes back in a variable rather than on stdout because those
|
||||||
|
# exits have to be the caller's. In `$(tui_pick ...)` the function runs in a
|
||||||
|
# subshell, where `exit 0` ends the subshell and hands the caller an empty
|
||||||
|
# string it would then use as an index.
|
||||||
|
#
|
||||||
|
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
|
||||||
|
# better than arrow keys, and dejima-attach is the phone's entry point.
|
||||||
|
TUI_CHOICE=""
|
||||||
|
tui_pick() {
|
||||||
|
local count="$1" verb="$2" choice
|
||||||
|
TUI_CHOICE=""
|
||||||
|
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
|
||||||
|
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
|
||||||
|
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
|
||||||
|
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
|
||||||
|
# returns nonzero immediately, and exiting 1 with nothing printed reads as
|
||||||
|
# a broken front door.
|
||||||
|
read -r choice || exit 0
|
||||||
|
case "$choice" in
|
||||||
|
q|Q|'') exit 0 ;;
|
||||||
|
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
# Normalized to base 10 once, here, because the range check and the
|
||||||
|
# subscript the caller does with this do not read a number the same way:
|
||||||
|
# `[` is POSIX test and decimal-only, while $(( )) reads a leading zero as
|
||||||
|
# OCTAL. Without this, "010" - which a phone keypad makes easy to type -
|
||||||
|
# passes the check as 10 and indexes as 8, and the caller acts on a
|
||||||
|
# DIFFERENT session than the one picked, with nothing printed to say so.
|
||||||
|
#
|
||||||
|
# It fixes the two ugly shapes as well. "09" is a value here rather than
|
||||||
|
# the "invalid octal constant" arithmetic error it is to $(( )); and a
|
||||||
|
# number past int64 wraps to one the range check can answer for, where
|
||||||
|
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
|
||||||
|
# expression expected" - which makes the `if` false, so "Out of range."
|
||||||
|
# is never printed and the caller dies on an unbound element.
|
||||||
|
choice="$((10#$choice))"
|
||||||
|
if [ "$choice" -lt 1 ] || [ "$choice" -gt "$count" ]; then
|
||||||
|
echo "Out of range." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TUI_CHOICE="$choice"
|
||||||
|
}
|
||||||
Executable
+190
@@ -0,0 +1,190 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Says when the dejima checkout a launch is running from is behind the branch
|
||||||
|
# it tracks. Advisory: it prints and returns, and the session starts either
|
||||||
|
# way. dejima updates by `git pull`, so "a newer version" means commits on the
|
||||||
|
# branch this checkout already follows - there is no release channel to ask.
|
||||||
|
#
|
||||||
|
# The remote is contacted by URL, from outside the repository, and never by
|
||||||
|
# remote name. `git ls-remote origin` reads the checkout's own config for how
|
||||||
|
# to reach that remote, and core.sshCommand, credential.helper and
|
||||||
|
# url.<base>.insteadOf each name a command git then runs on the host - which
|
||||||
|
# is measurable, not theoretical. dejima mounts its own checkout writable when
|
||||||
|
# it is the workspace (bin/dejima warns about this), so a session that wrote
|
||||||
|
# .git/config there would get host execution out of the *next* launch. That is
|
||||||
|
# the same class as the core.hooksPath route docs/security.md describes, with
|
||||||
|
# one difference that matters: this one would fire on its own, where hooksPath
|
||||||
|
# waits for the operator to run a git command. Reading the URL here and handing
|
||||||
|
# it to a git started outside the repository leaves none of that config in the
|
||||||
|
# path, and protocol.allow keeps the transports to the two that cannot name a
|
||||||
|
# command.
|
||||||
|
#
|
||||||
|
# What still crosses that line is the URL itself, which a session with the same
|
||||||
|
# write can point elsewhere. That costs one outbound request to a host of its
|
||||||
|
# choosing, and no execution; dejima-audit reports the config change it took.
|
||||||
|
#
|
||||||
|
# Runs on the host, before the container exists.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
-h|--help)
|
||||||
|
cat <<'DEJIMA_UPDATE_CHECK_HELP'
|
||||||
|
dejima-update-check - say when this dejima checkout is behind its remote
|
||||||
|
|
||||||
|
Usage: dejima-update-check
|
||||||
|
|
||||||
|
Compares the checkout bin/dejima runs from against the branch it tracks and
|
||||||
|
prints one line when the remote has commits this checkout does not. Run at
|
||||||
|
startup by bin/dejima; safe to run by hand.
|
||||||
|
|
||||||
|
Silent when the checkout is current or ahead, when [core] update_check is off,
|
||||||
|
when HEAD is detached or the branch has no counterpart on the remote, and
|
||||||
|
whenever the remote cannot be reached. A version check that cannot answer is
|
||||||
|
not a reason to interrupt a launch.
|
||||||
|
|
||||||
|
The remote is asked at most once an hour. In between, the answer it last gave
|
||||||
|
is compared against the current HEAD, so the notice goes away as soon as you
|
||||||
|
pull rather than an hour later. Nothing is fetched either way - this reads one
|
||||||
|
branch tip and no objects.
|
||||||
|
|
||||||
|
See docs/configuration.md.
|
||||||
|
DEJIMA_UPDATE_CHECK_HELP
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# provider-lib rather than config-lib alone: an unknown key in the config file
|
||||||
|
# is fatal, and [forge.<name>]/[harness.<name>] keys are only known once the
|
||||||
|
# manifests are loaded.
|
||||||
|
# shellcheck source=dejima-provider-lib.sh
|
||||||
|
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||||
|
# shellcheck source=dejima-color-lib.sh
|
||||||
|
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
|
||||||
|
|
||||||
|
# How long the remote's answer stands before it is asked again. Every launch
|
||||||
|
# reads the cached tip and compares it to HEAD; only the network call is
|
||||||
|
# throttled, so this is a bound on staleness of the remote half, not on how
|
||||||
|
# often the operator is told.
|
||||||
|
CHECK_INTERVAL=3600
|
||||||
|
|
||||||
|
# Alongside everything else dejima keeps on the host. This was under
|
||||||
|
# XDG_STATE_HOME, which is where a throwaway timestamp belongs by the letter
|
||||||
|
# of the spec - but it made ~/.local/state a fifth place dejima writes, for
|
||||||
|
# one file, and `mkdir -p` created that base directory on hosts that had no
|
||||||
|
# other use for it. One root is worth more than the distinction.
|
||||||
|
#
|
||||||
|
# No config key, where the four directories beside it have one: those are
|
||||||
|
# records an operator reads or a directory that gets mounted, and this is a
|
||||||
|
# hash that can be deleted at any moment with no loss. Nothing is served by
|
||||||
|
# being able to move it.
|
||||||
|
_cache_file() {
|
||||||
|
printf '%s/.local/share/dejima/update-check' "$HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only the transports that cannot name a command to run. `ext::` is refused by
|
||||||
|
# git itself, but the check is written against the shape of the URL rather than
|
||||||
|
# against one scheme's current default, and a leading dash would be read as an
|
||||||
|
# option by whatever eventually splits this.
|
||||||
|
_usable_url() {
|
||||||
|
case "$1" in
|
||||||
|
-*|*'::'*) return 1 ;;
|
||||||
|
https://*|ssh://*|file://*|/*) return 0 ;;
|
||||||
|
*@*:*) return 0 ;; # scp-like, which is ssh
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The remote's tip for one branch, or nothing. GIT_DIR and GIT_WORK_TREE are
|
||||||
|
# dropped so an inherited one cannot put a repository's config back in the path
|
||||||
|
# that -C '/' takes it out of.
|
||||||
|
_remote_tip() {
|
||||||
|
local url="$1" ref="$2" out timeout=()
|
||||||
|
command -v timeout >/dev/null 2>&1 && timeout=(timeout 5)
|
||||||
|
out="$(env -u GIT_DIR -u GIT_WORK_TREE "${timeout[@]}" \
|
||||||
|
git -C / \
|
||||||
|
-c protocol.allow=never \
|
||||||
|
-c protocol.https.allow=always \
|
||||||
|
-c protocol.ssh.allow=always \
|
||||||
|
-c protocol.file.allow=always \
|
||||||
|
ls-remote --heads "$url" "$ref" 2>/dev/null)" || return 1
|
||||||
|
printf '%s' "${out%%$'\t'*}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# stderr dropped rather than shown: a config this cannot read is one bin/dejima
|
||||||
|
# has already refused to start on, and a version check is not the place anyone
|
||||||
|
# should first hear about it.
|
||||||
|
[ "$(config_get core update_check 2>/dev/null)" = "on" ] || exit 0
|
||||||
|
|
||||||
|
git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||||
|
# --quiet so a detached HEAD is silence rather than an error on the terminal.
|
||||||
|
branch="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD)" || exit 0
|
||||||
|
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null)" || exit 0
|
||||||
|
|
||||||
|
# The branch's own remote, falling back to origin: a branch created locally has
|
||||||
|
# no branch.<name>.remote until it is pushed, and comparing it against origin's
|
||||||
|
# branch of the same name is the right answer when there is one and silence
|
||||||
|
# when there is not.
|
||||||
|
remote="$(git -C "$REPO_ROOT" config --get "branch.$branch.remote" 2>/dev/null)"
|
||||||
|
[ -n "$remote" ] || remote=origin
|
||||||
|
# The ref it tracks, which is not always its own name - `git worktree add -b x
|
||||||
|
# origin/development` leaves a branch called x tracking refs/heads/development.
|
||||||
|
# Asking for refs/heads/x there gets an empty answer and reports nothing, which
|
||||||
|
# looks exactly like being up to date.
|
||||||
|
upstream="$(git -C "$REPO_ROOT" config --get "branch.$branch.merge" 2>/dev/null)"
|
||||||
|
case "$upstream" in refs/heads/*) ;; *) upstream="refs/heads/$branch" ;; esac
|
||||||
|
url="$(git -C "$REPO_ROOT" config --get "remote.$remote.url" 2>/dev/null)" || exit 0
|
||||||
|
[ -n "$url" ] || exit 0
|
||||||
|
_usable_url "$url" || exit 0
|
||||||
|
|
||||||
|
cache="$(_cache_file)"
|
||||||
|
tip=""
|
||||||
|
if [ -f "$cache" ]; then
|
||||||
|
read -r c_branch c_tip < "$cache" 2>/dev/null || true
|
||||||
|
c_age=$(( $(date +%s) - $(stat -c %Y "$cache" 2>/dev/null || echo 0) ))
|
||||||
|
if [ "${c_branch:-}" = "$upstream" ] && [ "$c_age" -lt "$CHECK_INTERVAL" ]; then
|
||||||
|
tip="${c_tip:-}"
|
||||||
|
cached=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${cached:-}" ]; then
|
||||||
|
# A failed lookup is recorded, not just skipped. Without that, a checkout
|
||||||
|
# with no route to its remote pays the timeout on every single launch.
|
||||||
|
tip="$(_remote_tip "$url" "$upstream")" || tip=""
|
||||||
|
mkdir -p "$(dirname "$cache")" 2>/dev/null \
|
||||||
|
&& printf '%s %s\n' "$upstream" "${tip:--}" > "$cache" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# "-" is a recorded failure; empty is a branch the remote does not have.
|
||||||
|
case "${tip:-}" in ""|"-") exit 0 ;; esac
|
||||||
|
[ "$tip" = "$head_sha" ] && exit 0
|
||||||
|
|
||||||
|
# Ahead, or on a branch that has since been rewritten: having the commit and
|
||||||
|
# it being an ancestor of HEAD are two different questions, and only the second
|
||||||
|
# means there is nothing to report.
|
||||||
|
count=""
|
||||||
|
if git -C "$REPO_ROOT" cat-file -e "$tip^{commit}" 2>/dev/null; then
|
||||||
|
git -C "$REPO_ROOT" merge-base --is-ancestor "$tip" HEAD 2>/dev/null && exit 0
|
||||||
|
count="$(git -C "$REPO_ROOT" rev-list --count "HEAD..$tip" 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
color_init
|
||||||
|
# Both come out of the checkout's .git/config, which is session-writable when
|
||||||
|
# dejima's own checkout is the workspace - the same reason the remote is
|
||||||
|
# reached by URL rather than by name. A name carrying escape sequences would
|
||||||
|
# fire on the terminal of the operator this is trying to inform.
|
||||||
|
_remote_shown="$(sanitize "$remote")"
|
||||||
|
_branch_shown="$(sanitize "${upstream#refs/heads/}")"
|
||||||
|
# The count only when it is known. It is not, whenever the remote's tip is a
|
||||||
|
# commit this checkout has never fetched, which is the common case - and a
|
||||||
|
# number invented for those would be the number of commits it can see, not the
|
||||||
|
# number there are.
|
||||||
|
if [ -n "$count" ] && [ "$count" != "0" ]; then
|
||||||
|
if [ "$count" = "1" ]; then noun="commit"; else noun="commits"; fi
|
||||||
|
printf '%sdejima%s: %s/%s has %s %s this checkout does not.\n' \
|
||||||
|
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" "$C_INFO$count$C_RESET" "$noun" >&2
|
||||||
|
else
|
||||||
|
printf '%sdejima%s: %s/%s has commits this checkout does not.\n' \
|
||||||
|
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" >&2
|
||||||
|
fi
|
||||||
|
printf ' Update with %sgit -C %s pull%s\n' "$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
|
||||||
@@ -10,8 +10,17 @@
|
|||||||
# reason this is code is the pairing in the second block, which resolves one
|
# reason this is code is the pairing in the second block, which resolves one
|
||||||
# directory tree through the filenames of another.
|
# directory tree through the filenames of another.
|
||||||
|
|
||||||
# Registers the waiting/done notification handlers into dejima's settings
|
# Registers the `done` notification handler into dejima's settings layer, in
|
||||||
# layer, in the shape Claude Code reads them.
|
# the shape Claude Code reads them.
|
||||||
|
#
|
||||||
|
# Stop only, though the hook and the host-side watcher both understand
|
||||||
|
# `waiting` as well. Claude Code raises Notification when it needs permission
|
||||||
|
# to run something and when the prompt has sat idle, and a dejima session runs
|
||||||
|
# with permission prompts off - so the first never happens, and what is left
|
||||||
|
# is a timer that expires shortly after the Stop it follows. Registering both
|
||||||
|
# put two pushes on the operator's phone for one event, a minute apart, the
|
||||||
|
# second saying nothing the first had not. `waiting` stays in the enum for a
|
||||||
|
# harness that can say it without having stopped first.
|
||||||
#
|
#
|
||||||
# Claude Code merges hook entries across settings layers rather than letting
|
# Claude Code merges hook entries across settings layers rather than letting
|
||||||
# the higher one replace the lower, so these are added to whatever the
|
# the higher one replace the lower, so these are added to whatever the
|
||||||
@@ -25,8 +34,7 @@
|
|||||||
harness_register_notify() {
|
harness_register_notify() {
|
||||||
local settings="$1" tmp
|
local settings="$1" tmp
|
||||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||||
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
|
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||||
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
|
||||||
"$settings" > "$tmp"; then
|
"$settings" > "$tmp"; then
|
||||||
mv "$tmp" "$settings"
|
mv "$tmp" "$settings"
|
||||||
else
|
else
|
||||||
@@ -444,6 +452,36 @@ harness_transcript_files() {
|
|||||||
done < <(workspace_slugs_sorted "$ws")
|
done < <(workspace_slugs_sorted "$ws")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Which sessions still have a transcript on disk, as "<key>\t<session-id>"
|
||||||
|
# lines. `dejima-epitaph list` matches an epitaph's own session id against
|
||||||
|
# this to say whether resuming the real session is still on offer, and it has
|
||||||
|
# no way to work that out for itself: the id is in the filename here and is
|
||||||
|
# the directory name for the next harness.
|
||||||
|
#
|
||||||
|
# $1 is a workspace, or absent for every key this harness holds transcripts
|
||||||
|
# under - which is what `list --all` needs, since a store key is a slug and a
|
||||||
|
# slug does not resolve back to a path.
|
||||||
|
harness_live_sessions() {
|
||||||
|
local ws="${1:-}" projects key f d
|
||||||
|
projects="$(harness_config_get projects_dir)"
|
||||||
|
[ -n "$projects" ] || return 0
|
||||||
|
if [ -n "$ws" ]; then
|
||||||
|
while IFS=$'\t' read -r key f; do
|
||||||
|
[ -n "$f" ] || continue
|
||||||
|
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
|
||||||
|
done < <(harness_transcript_files "$ws")
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for d in "$projects"/*/; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
key="$(basename "$d")"
|
||||||
|
for f in "$d"/*.jsonl; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Compaction summaries out of this harness's transcripts.
|
# Compaction summaries out of this harness's transcripts.
|
||||||
#
|
#
|
||||||
# Claude Code writes line-delimited JSON, one record per line, with the
|
# Claude Code writes line-delimited JSON, one record per line, with the
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ harness_prepare_config() {
|
|||||||
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&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 "address, or a host you have allowlisted:" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " bin/dejima-allow --project <path> <host>" >&2
|
echo " dejima-allow --project <path> <host>" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "The allowlist is what decides whether the container can reach it" >&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 "at all, so an endpoint that fails this check is one the session" >&2
|
||||||
|
|||||||
@@ -301,6 +301,17 @@ COPY docker/install-forge-bins.sh /tmp/install-forge-bins.sh
|
|||||||
RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr/local/bin \
|
RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr/local/bin \
|
||||||
&& rm -rf /tmp/forges /tmp/install-forge-bins.sh
|
&& rm -rf /tmp/forges /tmp/install-forge-bins.sh
|
||||||
|
|
||||||
|
# The other half of NODE_PATH, which only ever applied to require(): ESM
|
||||||
|
# resolution ignores it entirely and walks up from the importing file looking
|
||||||
|
# for node_modules, so `import { chromium } from "playwright"` fails from
|
||||||
|
# every directory a script is likely to be written in while require() of the
|
||||||
|
# same package succeeds. At the root, that walk always ends here.
|
||||||
|
#
|
||||||
|
# It is worth a line because of how the failure reads. An agent writing the
|
||||||
|
# modern form gets ERR_MODULE_NOT_FOUND, concludes the image has no browser,
|
||||||
|
# and describes what a page should look like instead of going and seeing.
|
||||||
|
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh && \
|
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh && \
|
||||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,50 @@ services:
|
|||||||
# ten per run - becomes a permanent zombie, and a long session eventually
|
# ten per run - becomes a permanent zombie, and a long session eventually
|
||||||
# exhausts the cgroup pids limit and can no longer fork at all.
|
# exhausts the cgroup pids limit and can no longer fork at all.
|
||||||
init: true
|
init: true
|
||||||
|
# An explicit floor rather than Docker's default set plus two. The default
|
||||||
|
# carries about fourteen capabilities and this container uses seven: it
|
||||||
|
# writes and chowns files, runs the firewall, drops privilege twice (gosu
|
||||||
|
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
|
||||||
|
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
|
||||||
|
# NET_BIND_SERVICE are used by nothing here - the last one because the
|
||||||
|
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
|
||||||
|
#
|
||||||
|
# KILL is the one worth explaining, because it is not obvious from reading
|
||||||
|
# entrypoint.sh and was missing from the first version of this list. It is
|
||||||
|
# not for anything the entrypoint does. `dejima-allow --container` adds a
|
||||||
|
# domain to a RUNNING session by appending a server= line and sending
|
||||||
|
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
|
||||||
|
# own user (`user=dnsmasq`), so root in this container cannot signal it
|
||||||
|
# without CAP_KILL. Without this line, adding a domain mid-session fails
|
||||||
|
# with "Operation not permitted", which is a documented feature every
|
||||||
|
# session briefing tells the agent to ask for.
|
||||||
|
#
|
||||||
|
# The general lesson, since the next capability will be the same shape:
|
||||||
|
# what the container needs is not only what its entrypoint does. Things
|
||||||
|
# reach into it later.
|
||||||
|
#
|
||||||
|
# This narrows the ROOT phase. The session user's capabilities were already
|
||||||
|
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
|
||||||
|
# reading CapEff of the running process. What this adds is that the set was
|
||||||
|
# never there to be regained.
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
|
- CHOWN
|
||||||
|
- DAC_OVERRIDE
|
||||||
|
- FOWNER
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
- KILL
|
||||||
|
# No path back up. gosu drops privilege rather than gaining it, so this
|
||||||
|
# does not interfere with it - what it forbids is a setuid binary or a file
|
||||||
|
# capability raising privileges on exec, which is the shape any escalation
|
||||||
|
# from the session user would have to take. The gosu claim becomes
|
||||||
|
# structural instead of only observed.
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
# The credential-proxy TLS terminator binds :443 as the unprivileged
|
# The credential-proxy TLS terminator binds :443 as the unprivileged
|
||||||
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
|
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
|
||||||
# pinning it makes the dependency explicit: a future default change then
|
# pinning it makes the dependency explicit: a future default change then
|
||||||
|
|||||||
+118
-13
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Shadows a forge CLI on PATH, so that two rules hold whatever ran it: the one
|
# Shadows a forge CLI on PATH, so that three rules hold whatever ran it: the
|
||||||
# subcommand a workflow blocks is refused, and a body the session authored says
|
# one subcommand a workflow blocks is refused, a command aimed at another
|
||||||
# so.
|
# repository is refused, and a body the session authored says so.
|
||||||
#
|
#
|
||||||
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
|
# 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
|
# operator's decision, so the sandbox opens a PR and stops - moved off the
|
||||||
@@ -26,6 +26,10 @@
|
|||||||
# "pr gitea-pr create". Both are resolved host-side by
|
# "pr gitea-pr create". Both are resolved host-side by
|
||||||
# bin/dejima from the forge's manifest, never hardcoded
|
# bin/dejima from the forge's manifest, never hardcoded
|
||||||
# to a forge here.
|
# to a forge here.
|
||||||
|
# DEJIMA_FORGE_REPO the "<owner>/<name>" of the repository the session
|
||||||
|
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
|
||||||
|
# DEJIMA_FORGE_REPO_ENV the spellings this forge's CLI
|
||||||
|
# accepts for naming a different one.
|
||||||
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
|
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
@@ -39,7 +43,8 @@ fi
|
|||||||
|
|
||||||
# Outside a dejima container, or with nothing to do, this is a pass-through.
|
# Outside a dejima container, or with nothing to do, this is a pass-through.
|
||||||
if [ -z "${DEJIMA:-}" ] ||
|
if [ -z "${DEJIMA:-}" ] ||
|
||||||
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ]; }; then
|
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
|
||||||
|
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ]; }; then
|
||||||
exec "$real" "$@"
|
exec "$real" "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -100,6 +105,73 @@ if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- the repository the session was launched against ------------------------
|
||||||
|
#
|
||||||
|
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
|
||||||
|
# repo_env). The gitea CLIs declare neither, because they resolve the repo
|
||||||
|
# from origin and refuse another host; `gh` declares both, so on that path the
|
||||||
|
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||||
|
# file an issue on a repository the session has nothing to do with (#187).
|
||||||
|
#
|
||||||
|
# Fast-fail, not a boundary, for the same reason the merge block is not one:
|
||||||
|
# the session holds the token and could call the API directly. What bounds
|
||||||
|
# this is the scope of push_token_file.
|
||||||
|
#
|
||||||
|
# Refused rather than skipped when the launch resolved no repository at all: a
|
||||||
|
# workspace with no origin has nothing for --repo to legitimately name, and
|
||||||
|
# "cannot confirm" is not a reason to let it through.
|
||||||
|
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||||
|
refuse_repo() {
|
||||||
|
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Case-insensitively: forges treat an owner and a repository name as
|
||||||
|
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||||
|
# would be a false one.
|
||||||
|
same_repo() {
|
||||||
|
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||||
|
}
|
||||||
|
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||||
|
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||||
|
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
|
||||||
|
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||||
|
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||||
|
args_left=("$@")
|
||||||
|
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||||
|
arg="${args_left[0]}"
|
||||||
|
args_left=("${args_left[@]:1}")
|
||||||
|
for flag in "${repo_flags[@]}"; do
|
||||||
|
value=""
|
||||||
|
case "$arg" in
|
||||||
|
"$flag")
|
||||||
|
# The value is the next argument, and its absence is
|
||||||
|
# the CLI's error to report rather than this one's.
|
||||||
|
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||||
|
value="${args_left[0]}"
|
||||||
|
;;
|
||||||
|
"$flag"=*) value="${arg#*=}" ;;
|
||||||
|
*)
|
||||||
|
# A one-dash flag may carry its value with no
|
||||||
|
# separator - `-Rowner/name` - which two-dash flags do
|
||||||
|
# not do.
|
||||||
|
case "$flag" in
|
||||||
|
--*) continue ;;
|
||||||
|
-?) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
case "$arg" in "$flag"?*) value="${arg#"$flag"}" ;; *) continue ;; esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ -n "$value" ] || continue
|
||||||
|
same_repo "$value" || refuse_repo "$flag" "$value"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- the authorship disclosure ---------------------------------------------
|
# --- the authorship disclosure ---------------------------------------------
|
||||||
|
|
||||||
[ -n "${DEJIMA_DISCLOSE:-}" ] || exec "$real" "$@"
|
[ -n "${DEJIMA_DISCLOSE:-}" ] || exec "$real" "$@"
|
||||||
@@ -163,12 +235,49 @@ read_body_or_die() {
|
|||||||
body="$(cat -- "$1")" || exit 1
|
body="$(cat -- "$1")" || exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# The path to a temp file holding the marker and the body. Set outside the
|
# The body with the marker on it, whichever way the body arrived.
|
||||||
# command substitution as well, so the caller can delete it afterwards.
|
#
|
||||||
|
# One function for all four spellings, because there were two: --body-file
|
||||||
|
# went through here and --body prepended the marker inline at its own call
|
||||||
|
# site, so a rule added in one place was not in force in the other. That is
|
||||||
|
# the whole reason this exists as a function rather than as a printf repeated
|
||||||
|
# four times.
|
||||||
|
#
|
||||||
|
# Idempotent, which is the rule that was missing. An agent that has read the
|
||||||
|
# project's existing issues has seen every one of them open with this line,
|
||||||
|
# and writes it into the body itself - at which point a second was added and
|
||||||
|
# the artifact said it twice. Each doubled issue then taught the next agent to
|
||||||
|
# do the same, so it spread rather than settling.
|
||||||
|
#
|
||||||
|
# Skipping is safe because the marker is a statement, not a count: a body that
|
||||||
|
# already opens by saying an agent wrote it has disclosed exactly what this
|
||||||
|
# exists to disclose. Anchored at the start, after leading whitespace - the
|
||||||
|
# same words further down are prose ABOUT disclosure rather than the
|
||||||
|
# disclosure, and still get one prepended.
|
||||||
|
#
|
||||||
|
# The whole first LINE has to be the marker, not just begin with it. A prefix
|
||||||
|
# test let a body open `**Filed by LLM agent** - reviewed and confirmed by a
|
||||||
|
# person` and skip, which makes the marker line itself agent-authorable: the
|
||||||
|
# one line here exists to write is the one the session gets to finish. Trailing
|
||||||
|
# whitespace is allowed because it is invisible and changes nothing.
|
||||||
|
with_marker() {
|
||||||
|
local head first
|
||||||
|
head="${1#"${1%%[![:space:]]*}"}"
|
||||||
|
first="${head%%$'\n'*}"
|
||||||
|
first="${first%"${first##*[![:space:]]}"}"
|
||||||
|
if [ "$first" = "$marker" ]; then
|
||||||
|
printf '%s' "$1"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s\n\n%s' "$marker" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The path to a temp file holding the disclosed body. Set outside the command
|
||||||
|
# substitution as well, so the caller can delete it afterwards.
|
||||||
tmp=""
|
tmp=""
|
||||||
disclosed() {
|
disclosed() {
|
||||||
tmp="$(mktemp)" || exit 1
|
tmp="$(mktemp)" || exit 1
|
||||||
printf '%s\n\n%s' "$marker" "$1" > "$tmp" || exit 1
|
with_marker "$1" > "$tmp" || exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Only the first body argument is rewritten. A second would mean the CLI is
|
# Only the first body argument is rewritten. A second would mean the CLI is
|
||||||
@@ -200,18 +309,14 @@ while [ "$#" -gt 0 ]; do
|
|||||||
;;
|
;;
|
||||||
--body)
|
--body)
|
||||||
if [ "$#" -ge 2 ]; then
|
if [ "$#" -ge 2 ]; then
|
||||||
args+=(--body "$marker
|
args+=(--body "$(with_marker "$2")")
|
||||||
|
|
||||||
$2")
|
|
||||||
rewrote=1
|
rewrote=1
|
||||||
shift 2
|
shift 2
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
--body=*)
|
--body=*)
|
||||||
args+=("--body=$marker
|
args+=("--body=$(with_marker "${1#--body=}")")
|
||||||
|
|
||||||
${1#--body=}")
|
|
||||||
rewrote=1
|
rewrote=1
|
||||||
shift
|
shift
|
||||||
continue
|
continue
|
||||||
|
|||||||
+46
-1
@@ -49,14 +49,59 @@ ipset destroy github-domains 2>/dev/null || true
|
|||||||
# level, e.g. no ip6_tables module) - once it's confirmed to work at all, a
|
# level, e.g. no ip6_tables module) - once it's confirmed to work at all, a
|
||||||
# failure partway through must fail the whole script (set -e, no more
|
# failure partway through must fail the whole script (set -e, no more
|
||||||
# blanket `|| true`) rather than silently leaving IPv6 wide open.
|
# blanket `|| true`) rather than silently leaving IPv6 wide open.
|
||||||
|
#
|
||||||
|
# The `else` below used to log that the family was presumably off at the
|
||||||
|
# kernel level, and continue. That inferred a fact about the WORLD from a fact
|
||||||
|
# about a TOOL, and the two are not the same condition: a container whose kernel
|
||||||
|
# lacks ip6_tables, on a host with IPv6 enabled, took that branch, logged a
|
||||||
|
# reassuring line, and ran with IPv6 live and unfiltered. Nothing failed,
|
||||||
|
# because the only thing that had happened was a check that could not tell.
|
||||||
|
#
|
||||||
|
# So the branch asks the kernel instead. /proc/net/if_inet6 lists every IPv6
|
||||||
|
# address on every interface, and it is the netns's own view - present with
|
||||||
|
# `::1` alone when the stack is up and nothing is reachable, absent entirely
|
||||||
|
# when IPv6 is compiled out.
|
||||||
|
#
|
||||||
|
# Loopback does not count. Refusing to start over `::1` would refuse every
|
||||||
|
# container that has no IPv6 connectivity at all, which is a false refusal
|
||||||
|
# for the common case. What matters is an address on an interface that leaves
|
||||||
|
# the box.
|
||||||
|
_ipv6_off_box() {
|
||||||
|
[ -e /proc/net/if_inet6 ] || return 1
|
||||||
|
# address ifindex prefixlen scope flags devname
|
||||||
|
awk '$6 != "lo" { found = 1 } END { exit !found }' /proc/net/if_inet6
|
||||||
|
}
|
||||||
|
|
||||||
if ip6tables -L >/dev/null 2>&1; then
|
if ip6tables -L >/dev/null 2>&1; then
|
||||||
ip6tables -F
|
ip6tables -F
|
||||||
ip6tables -X
|
ip6tables -X
|
||||||
ip6tables -P INPUT DROP
|
ip6tables -P INPUT DROP
|
||||||
ip6tables -P OUTPUT DROP
|
ip6tables -P OUTPUT DROP
|
||||||
ip6tables -P FORWARD DROP
|
ip6tables -P FORWARD DROP
|
||||||
|
elif _ipv6_off_box; then
|
||||||
|
echo "ip6tables is unusable here, and IPv6 is live on an interface that" >&2
|
||||||
|
echo "leaves this container. Turning the family off in this network" >&2
|
||||||
|
echo "namespace, since it cannot be filtered." >&2
|
||||||
|
# Written to /proc directly rather than through sysctl(8), which is one
|
||||||
|
# more thing that has to be in the image for the firewall to hold. Per
|
||||||
|
# interface as well as `all`, because which of those flushes existing
|
||||||
|
# addresses has varied across kernels.
|
||||||
|
for _conf in /proc/sys/net/ipv6/conf/*/disable_ipv6; do
|
||||||
|
[ -w "$_conf" ] || continue
|
||||||
|
echo 1 > "$_conf" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if _ipv6_off_box; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "IPv6 is still up and there is no way to filter it from here, so" >&2
|
||||||
|
echo "every rule below would be bypassable by an AAAA-resolved address." >&2
|
||||||
|
echo "Refusing to start rather than reporting a boundary that is not" >&2
|
||||||
|
echo "there. Either give the container a usable ip6tables (the" >&2
|
||||||
|
echo "ip6_tables module on the host), or disable IPv6 for its network." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "IPv6 is off in this namespace."
|
||||||
else
|
else
|
||||||
echo "ip6tables unusable - assuming IPv6 is unavailable at the kernel level"
|
echo "ip6tables unusable, and no IPv6 address outside loopback - nothing to filter"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reinsert just the rules captured above - nothing else Docker had set up in
|
# Reinsert just the rules captured above - nothing else Docker had set up in
|
||||||
|
|||||||
+296
-144
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
[← dejima README](../README.md)
|
[← dejima README](../README.md)
|
||||||
|
|
||||||
Day-to-day configuration once dejima is installed and running: what a
|
Day-to-day configuration once dejima is installed and running. The reference
|
||||||
project declares versus what you approve, config parity with your host agent
|
table below is every key there is; the sections after it cover what a project
|
||||||
harness setup, a different editor, writable host directories, shared
|
declares versus what you approve, what carries over from your host, mounts and
|
||||||
scratch space, frontend testing, and what the sandbox knows about itself.
|
scratch space, and what the sandbox knows about itself.
|
||||||
|
|
||||||
## Configuration reference
|
## Configuration reference
|
||||||
|
|
||||||
@@ -23,19 +23,23 @@ config file behind them.
|
|||||||
| Key | Default | Purpose |
|
| Key | Default | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
|
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
|
||||||
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
||||||
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
||||||
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
|
| `[core] command` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
|
||||||
|
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
|
||||||
|
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
|
||||||
|
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
|
||||||
|
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
|
||||||
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
||||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
|
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
|
||||||
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
|
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
|
||||||
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||||
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
|
| `[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] 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.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] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
|
||||||
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted |
|
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted. Also settable per launch with `--base-url` |
|
||||||
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one |
|
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
|
||||||
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
|
| `[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] 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] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
|
||||||
@@ -46,21 +50,25 @@ config file behind them.
|
|||||||
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
||||||
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||||
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
|
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
|
||||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
|
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
|
||||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
|
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
|
||||||
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
|
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
|
||||||
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
||||||
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
|
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
|
||||||
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
|
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md. `--no-conversation` turns it off for one launch, and `--no-log` implies it |
|
||||||
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
||||||
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
||||||
| `[audit] keep` | `10` | how many past findings/baselines to retain |
|
| `[audit] keep` | `10` | how many past findings/baselines to retain |
|
||||||
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
|
| `[audit] diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
|
||||||
|
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
|
||||||
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
|
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
|
||||||
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
|
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
|
||||||
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
|
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
|
||||||
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
|
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
|
||||||
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
|
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
|
||||||
|
| `[remote-access] ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
|
||||||
|
| `[remote-access] ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
|
||||||
|
| `[remote-access] ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
|
||||||
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
|
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
|
||||||
|
|
||||||
**No forge is the default**: `[core] forge = none` with
|
**No forge is the default**: `[core] forge = none` with
|
||||||
@@ -71,12 +79,11 @@ opting into when you pick one.
|
|||||||
## Global and per-project domains and dependencies
|
## Global and per-project domains and dependencies
|
||||||
|
|
||||||
Allowed domains and dependencies each stack through their own layers. The
|
Allowed domains and dependencies each stack through their own layers. The
|
||||||
layers **add** to each other, so a narrower layer can only widen what a
|
layers **add** to each other, so a narrower layer can only widen what a broader
|
||||||
broader one already permits: nothing at the project layer can withdraw a
|
one already permits: the set a session gets is the union of every layer that
|
||||||
built-in domain, and nothing at the container layer can withdraw a project
|
applies to it, never an override of one by another. Nothing at the project
|
||||||
one. Ordering them least specific first is how they are listed below and how
|
layer can withdraw a built-in domain, and nothing at the container layer can
|
||||||
they are applied, and it means the set a session gets is the union of every
|
withdraw a project one.
|
||||||
layer that applies to it, never an override of one by another.
|
|
||||||
|
|
||||||
| Config | Layers, least specific first |
|
| Config | Layers, least specific first |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -95,61 +102,79 @@ A project can declare what it needs in two committed files:
|
|||||||
# tarball:<name>@<sha256>:<url> release archive
|
# tarball:<name>@<sha256>:<url> release archive
|
||||||
```
|
```
|
||||||
|
|
||||||
### Declaring that the history is evidence
|
**Declaring grants nothing.** The project directory is bind-mounted
|
||||||
|
read-write, so a session could write these files; they are read only to
|
||||||
|
compare against reality and suggest a command. Approvals live outside the
|
||||||
|
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
|
||||||
|
tree) - global at its root, per project under `projects/<slug>/` - written
|
||||||
|
only by tools you run on the host:
|
||||||
|
|
||||||
A third file, approved the same way as the two above:
|
```sh
|
||||||
|
dejima-project status ~/myproject # declared vs approved vs missing
|
||||||
```
|
dejima-project approve ~/myproject # review and approve the declared set
|
||||||
.dejima/corpus # one rule per line; today, commit-per-step
|
dejima-allow --project ~/myproject crates.io
|
||||||
|
dejima-allow --global crates.io # applies to every project
|
||||||
|
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||||
|
dejima-dep add --global cargo:cargo-nextest
|
||||||
```
|
```
|
||||||
|
|
||||||
An approved `commit-per-step` adds a paragraph to the session briefing
|
Two of those tools also write the declaration, so an approval you make by
|
||||||
requiring **one commit per plan step that writes a file**, rather than one
|
hand does not stay invisible to everyone else on the project:
|
||||||
commit per task, and explaining why. Absent or unapproved, nothing is added
|
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
|
||||||
and nothing changes, which is the default for every project that has not asked
|
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
|
||||||
for it.
|
approval first - that is the part that grants - and report separately on the
|
||||||
|
declaration, so a project you can't write to still gets you the approval.
|
||||||
|
`--global` writes no declaration at all: there is no project to declare in.
|
||||||
|
|
||||||
It goes through approval despite granting no capability, and that is
|
The declaration those two write still grants nothing, and writing to it does
|
||||||
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
|
not promote it. That it is session-writable is exactly why both tools refuse to
|
||||||
session able to turn this on by writing a file would be writing the *next*
|
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
|
||||||
session's system prompt. `bin/dejima` reads the approval store and never a
|
The refusal is the `open` itself rather than a check before it, so it holds even
|
||||||
project's declarations - a static test holds that it does not so much as name
|
against a session still running while you type the command - see
|
||||||
that directory.
|
[Declaration files are session-writable](security.md#declaration-files-are-session-writable).
|
||||||
|
The same write reads the file back to skip an already-declared entry, and that
|
||||||
|
read is capped, since a session can make its own `.dejima/<kind>` arbitrarily
|
||||||
|
large for free.
|
||||||
|
|
||||||
An unknown rule is reported but never offered for approval. One that nothing
|
Neither writer has an inverse for the declaration. `dejima-dep remove
|
||||||
implements would approve cleanly, sit in the store and do nothing, which reads
|
--project` withdraws the approval and deliberately leaves the declaration
|
||||||
as being in force when it is not.
|
alone (it says so, since `dejima-project approve` will otherwise offer the
|
||||||
|
entry again); `dejima-allow --project` has no `remove` at all. Taking
|
||||||
|
something back from the project itself means editing `.dejima/` and
|
||||||
|
committing that.
|
||||||
|
|
||||||
**Check the forge's merge policy before relying on this.** The briefing tells
|
`dejima-project status`/`approve` classify a declared entry against
|
||||||
the agent how to commit, but the setting that can undo all of it is yours: a
|
the union of global and project approvals, so something already approved
|
||||||
squash-merge is survivable, because a clone fetches remote branches and the
|
globally shows as approved, not pending. Unlike `dejima-allow`,
|
||||||
step commits stay reachable through the branch ref, but deleting the branch
|
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
|
||||||
afterwards removes the last thing pointing at them. Several forges offer
|
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
|
||||||
"delete branch on merge" and some default it on. Measured both ways on a
|
scope either, and refuses the flag rather than ignoring it: a dependency
|
||||||
throwaway repository: squash-merge with the branch retained left every step
|
needs an image rebuild, so there is nothing to patch in a live session.
|
||||||
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
|
Approved domains join the allowlist at the next launch. `dejima-allow` can also
|
||||||
nothing, with no error anywhere - a squash-merged `main` looks perfectly
|
patch a running session immediately, but only when run bare or with
|
||||||
healthy. dejima's own history is intact because it merges with merge commits.
|
`--container` - approving here (`--project`/`--global`) never touches one; see
|
||||||
|
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||||
|
A domain that resolves to a private address has no live patch at all. Approved
|
||||||
|
dependencies are baked into a derived image built `FROM` the shared base -
|
||||||
|
first `dejima:<username>-global` if the global store has any dependency
|
||||||
|
approved, then `dejima:<username>-<slug>` on top of that if the project store
|
||||||
|
does - so they take effect on the **next** launch. Because of that rebuild, the
|
||||||
|
usual moment to approve is *after* leaving the sandbox, and persistence never
|
||||||
|
requires a running container.
|
||||||
|
|
||||||
The reason is recoverability rather than tidiness. A step is only legible
|
`dejima-project approve` never attempts to patch a running session: it only
|
||||||
afterwards if the file's state immediately before it and immediately after it
|
ever calls `dejima-allow` without `--container`. If an individual approval
|
||||||
can both be read back from history: before from the commit's parent, after
|
can't be recorded - an unwritable store, a slug collision, a corrupt approval
|
||||||
from the commit. One commit carrying several steps that touch the same file
|
list - it reports that entry as not approved and keeps going, so one bad entry
|
||||||
destroys that, because no step in it can be isolated any more, and
|
can't cost you the rest of the set. It then exits non-zero if anything failed,
|
||||||
reconstructing one would mean building a file state the repository never held.
|
so `dejima-project approve ~/myproject && dejima` won't launch a session
|
||||||
Nothing recovers that later, so it has to be recorded while the work happens.
|
missing the approvals you just confirmed.
|
||||||
|
|
||||||
It lives in the project rather than in `~/.config/dejima/config` because it is
|
A session whose project declares something it doesn't have is told so in its
|
||||||
a fact about the repository. Anyone who clones it and runs dejima gets the same
|
startup context, with the exact command to hand you. Project approvals are
|
||||||
commit discipline, which is what makes the history uniformly usable: a rule
|
inherited by subdirectories, so a git worktree under the project gets its
|
||||||
half the contributors follow produces a history nothing can be taken from.
|
parent's; global approvals apply everywhere regardless.
|
||||||
|
|
||||||
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
|
### Tarball dependencies
|
||||||
|
|
||||||
@@ -198,88 +223,53 @@ interpolated into a `RUN` line that runs as root at build time, so anything
|
|||||||
shell-active is refused outright rather than quoted around, and only `https`
|
shell-active is refused outright rather than quoted around, and only `https`
|
||||||
is accepted.
|
is accepted.
|
||||||
|
|
||||||
**Declaring grants nothing.** The project directory is bind-mounted
|
### Declaring that the history is evidence
|
||||||
read-write, so a session could write these files; they are read only to
|
|
||||||
compare against reality and suggest a command. Approvals live outside the
|
|
||||||
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
|
|
||||||
tree) - global at its root, per project under `projects/<slug>/` - written
|
|
||||||
only by tools you run on the host:
|
|
||||||
|
|
||||||
```sh
|
A third file, approved the same way as the two above:
|
||||||
dejima-project status ~/myproject # declared vs approved vs missing
|
|
||||||
dejima-project approve ~/myproject # review and approve the declared set
|
```
|
||||||
dejima-allow --project ~/myproject crates.io
|
.dejima/corpus # one rule per line; today, commit-per-step
|
||||||
dejima-allow --global crates.io # applies to every project
|
|
||||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
|
||||||
dejima-dep add --global cargo:cargo-nextest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Two of those tools also write the declaration, so an approval you make by
|
An approved `commit-per-step` adds a paragraph to the session briefing
|
||||||
hand does not stay invisible to everyone else on the project:
|
requiring **one commit per plan step that writes a file**, rather than one
|
||||||
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
|
commit per task, and explaining why. Absent or unapproved, nothing is added
|
||||||
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
|
and nothing changes, which is the default for every project that has not asked
|
||||||
approval first - that is the part that grants - and report separately on the
|
for it.
|
||||||
declaration, so a project you can't write to still gets you the approval.
|
|
||||||
`--global` writes no declaration at all: there is no project to declare in.
|
|
||||||
|
|
||||||
The declaration those two write still grants nothing; it is the same
|
It goes through approval despite granting no capability, and that is
|
||||||
untrusted file the section above describes, and writing to it does not
|
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
|
||||||
promote it. That it is session-writable is exactly why both tools refuse to
|
session able to turn this on by writing a file would be writing the *next*
|
||||||
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
|
session's system prompt. `bin/dejima` reads the approval store and never a
|
||||||
The refusal is the `open` itself rather than a check before it, so it holds
|
project's declarations - a static test holds that it does not so much as name
|
||||||
even against a session that is still running while you type the command. See
|
that directory.
|
||||||
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
|
|
||||||
for what that defends against and how. The same write
|
|
||||||
reads the file back to skip an already-declared entry, and that read is
|
|
||||||
capped - a session can make its own `.dejima/<kind>` arbitrarily large for
|
|
||||||
free, and an oversized one is refused rather than loaded into your memory.
|
|
||||||
|
|
||||||
Neither writer has an inverse for the declaration. `dejima-dep remove
|
An unknown rule is reported but never offered for approval. One that nothing
|
||||||
--project` withdraws the approval and deliberately leaves the declaration
|
implements would approve cleanly, sit in the store and do nothing, which reads
|
||||||
alone (it says so, since `dejima-project approve` will otherwise offer the
|
as being in force when it is not.
|
||||||
entry again); `dejima-allow --project` has no `remove` at all. Taking
|
|
||||||
something back from the project itself means editing `.dejima/` and
|
|
||||||
committing that.
|
|
||||||
|
|
||||||
`dejima-project status`/`approve` classify a declared entry against
|
The reason is recoverability rather than tidiness. A step is legible afterwards
|
||||||
the union of global and project approvals, so something already approved
|
only if the file's state immediately before and immediately after it can both
|
||||||
globally shows as approved, not pending. Unlike `dejima-allow`,
|
be read back: before from the commit's parent, after from the commit. One
|
||||||
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
|
commit carrying several steps that touch the same file destroys that, and
|
||||||
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
|
nothing recovers it later, so it has to be recorded while the work happens.
|
||||||
scope either, and refuses the flag rather than ignoring it: a dependency
|
|
||||||
needs an image rebuild, so there is nothing to patch in a live session.
|
|
||||||
|
|
||||||
Approved domains join the allowlist at the next launch. `dejima-allow` can
|
**Check the forge's merge policy before relying on this.** The setting that can
|
||||||
also patch a running session immediately, but only when it is run bare or
|
undo all of it is yours. A squash-merge is survivable, because the step commits
|
||||||
with `--container` - approving here (`--project`/`--global`, with no
|
stay reachable through the branch ref, but "delete branch on merge" - which
|
||||||
`--container`) never touches one; see
|
several forges offer and some default on - removes the last thing pointing at
|
||||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
them. Measured both ways on a throwaway repository: squash-merge with the
|
||||||
A domain that resolves to a private address has no live patch at all - an
|
branch retained left every step commit reachable in a fresh clone; the same
|
||||||
internally-routed host can only be approved and picked up at the next launch.
|
merge with the branch deleted first left none. So a project can adopt the rule,
|
||||||
Approved dependencies are baked into a derived image built `FROM` the shared
|
write every plan correctly, and still yield nothing, with no error anywhere.
|
||||||
base - first `dejima:<username>-global`, if the global store has any
|
dejima's own history is intact because it merges with merge commits.
|
||||||
dependency approved, then `dejima:<username>-<slug>` on top of that,
|
|
||||||
if the project store does - so they need an image rebuild: they take effect
|
|
||||||
on the **next** launch.
|
|
||||||
|
|
||||||
Because of that rebuild, the usual moment to approve is *after* leaving the
|
It lives in the project rather than in `~/.config/dejima/config` because it is
|
||||||
sandbox - so persistence (`--global` or `--project`) never requires a running
|
a fact about the repository: anyone who clones it gets the same commit
|
||||||
container.
|
discipline, and 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*.
|
||||||
`dejima-project approve` never attempts to patch a running session: it only
|
Collecting it is the operator's business; the briefing states the rule the
|
||||||
ever calls `dejima-allow` without `--container`, so recording the approval
|
project adopted and stops there.
|
||||||
is the only thing it does. If an individual approval can't be recorded - an
|
|
||||||
unwritable store, a slug collision, a corrupt approval list - it reports
|
|
||||||
that entry as not approved and keeps going, rather than stopping at the
|
|
||||||
first failure; that holds for dependencies and domains alike, so one bad
|
|
||||||
entry can't cost you the rest of the set. It then exits non-zero if anything
|
|
||||||
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
|
|
||||||
session missing the approvals you just confirmed.
|
|
||||||
|
|
||||||
A session whose project declares something it doesn't have is told so in its
|
|
||||||
startup context, with the exact command to hand you. Project approvals are
|
|
||||||
inherited by subdirectories, so a git worktree under the project gets its
|
|
||||||
parent's; global approvals apply everywhere regardless.
|
|
||||||
|
|
||||||
## Config parity with your host agent harness setup
|
## Config parity with your host agent harness setup
|
||||||
|
|
||||||
@@ -418,6 +408,100 @@ editing the base `Dockerfile`: a dependency added that way is declared,
|
|||||||
approved and layered like any other, and it survives a rebuild. Editing the
|
approved and layered like any other, and it survives a rebuild. Editing the
|
||||||
Dockerfile directly puts it outside that record.
|
Dockerfile directly puts it outside that record.
|
||||||
|
|
||||||
|
## Identities
|
||||||
|
|
||||||
|
**Several accounts on one machine, and none of them yours.** An
|
||||||
|
`[identity.<name>]` section groups what one session acts as - the SSH key, the
|
||||||
|
forge token, the name on the commits - so that pointing a launch at a
|
||||||
|
different account is one word rather than four edits.
|
||||||
|
|
||||||
|
The rest of these documents ask you to give a session an account of its own -
|
||||||
|
the README under [Using this responsibly](../README.md#using-this-responsibly),
|
||||||
|
and security.md's [Give the sandbox its own
|
||||||
|
identity](security.md#give-the-sandbox-its-own-identity). This is how you
|
||||||
|
have more than one of them, which you will: an account is scoped to what it
|
||||||
|
may reach, and one account scoped to everything any session might ever need
|
||||||
|
is the thing separate accounts existed to avoid.
|
||||||
|
|
||||||
|
So the sections are per **agent account**, not per person:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# Pushes to the gitea forge, and can reach nothing on GitHub.
|
||||||
|
[identity.agent-gitea]
|
||||||
|
ssh_key = ~/.ssh/id_agent_gitea
|
||||||
|
user_name = dejima agent (gitea)
|
||||||
|
user_email = agent-gitea@example.invalid
|
||||||
|
token_file = ~/.config/gitea/agent-token
|
||||||
|
|
||||||
|
# A GitHub account, with push rights to the repositories this host works on.
|
||||||
|
[identity.agent-github]
|
||||||
|
ssh_key = ~/.ssh/id_agent_github
|
||||||
|
user_name = dejima agent (github)
|
||||||
|
user_email = agent-github@example.invalid
|
||||||
|
push_token_file = ~/.config/gh/agent-push
|
||||||
|
|
||||||
|
# A second GitHub account, for a repository the one above may not touch.
|
||||||
|
[identity.agent-vendor]
|
||||||
|
ssh_key = ~/.ssh/id_agent_vendor
|
||||||
|
user_name = dejima agent (vendor)
|
||||||
|
user_email = agent-vendor@example.invalid
|
||||||
|
push_token_file = ~/.config/gh/vendor-push
|
||||||
|
```
|
||||||
|
|
||||||
|
Select one with `[core] identity = agent-gitea`, or per launch with
|
||||||
|
`--identity agent-github` - which is how one host runs a session against each
|
||||||
|
of these at the same time, each pushing as itself.
|
||||||
|
|
||||||
|
**The separation is the point.** Three accounts means three things you can
|
||||||
|
scope, revoke and read in a log independently: a session that turns out to
|
||||||
|
have been prompt-injected reached what its own account could reach, and
|
||||||
|
answering "what did it touch" does not start with "everything I can touch".
|
||||||
|
|
||||||
|
**What an identity may set** is deliberately narrow: `ssh_key`, `user_name`,
|
||||||
|
`user_email`, and whichever keys the forges declare as an account's. A forge
|
||||||
|
says which of its own keys those are (`identity_keys` in its manifest), so
|
||||||
|
`token_file` belongs to an identity on the gitea path and `push_token_file`
|
||||||
|
does on the GitHub one, and neither is a list core keeps its own copy of.
|
||||||
|
|
||||||
|
**`default` is the account a session gets when none is named.** With `[core]
|
||||||
|
identity` unset, an `[identity.default]` section is what a launch acts under -
|
||||||
|
the ordinary account, given a name so it can carry the git name and the forge
|
||||||
|
credential too.
|
||||||
|
|
||||||
|
**`[core] ssh_key` is that key under its old name, and is deprecated.** A
|
||||||
|
config with no `[identity.default]` reads it as before; a config with both is
|
||||||
|
refused rather than ranked, because which key a session pushed with should not
|
||||||
|
depend on precedence rules. Every launch that sets it says so and prints the
|
||||||
|
section to move it into - including launches running as some other identity,
|
||||||
|
which are one `[identity.default]` away from that refusal.
|
||||||
|
|
||||||
|
**A credential does not fall through.** An identity that names some and leaves
|
||||||
|
the rest to the section beneath it is refused, naming each one: an identity is
|
||||||
|
how one session's account stays separate from another's. "Set" means written
|
||||||
|
in the file, so a forge key carrying a schema default - `[forge.gitea]
|
||||||
|
token_file` - still answers for an identity that names none.
|
||||||
|
|
||||||
|
**A name with no section is refused.** `[core] identity = agent-gihub` does not
|
||||||
|
quietly use this machine's own credentials - it stops the launch and says so.
|
||||||
|
Pushing under the wrong key fails loudly; pushing under your default key when
|
||||||
|
you asked for another one does not fail at all, which is the direction worth
|
||||||
|
being careful about.
|
||||||
|
|
||||||
|
**`user_name` and `user_email` reach the container's git config**, appended to
|
||||||
|
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
|
||||||
|
touched, which was already true and is what the copy exists for.
|
||||||
|
|
||||||
|
**Selection is the host's, not the project's.** There is no `.dejima/` kind
|
||||||
|
for an identity and a project cannot name one: a project's `.dejima/` is
|
||||||
|
committed and shared with everyone working on it, and both the credential and
|
||||||
|
which account this host holds are that host's own. `[core] identity` and
|
||||||
|
`--identity` are the whole of it today.
|
||||||
|
|
||||||
|
A project-side layer would be a different thing than it sounds - not "this
|
||||||
|
project's credentials", which cannot be shared, but "this project wants
|
||||||
|
whichever account you have called X". That does not exist yet, and this
|
||||||
|
document will say so until it does.
|
||||||
|
|
||||||
## Headless git push without agent forwarding
|
## Headless git push without agent forwarding
|
||||||
|
|
||||||
**The reason to do this is identity, not uptime.** A forwarded agent
|
**The reason to do this is identity, not uptime.** A forwarded agent
|
||||||
@@ -446,15 +530,15 @@ the public half as a Gitea deploy key scoped to just the repos that need
|
|||||||
push access, then set it in the config:
|
push access, then set it in the config:
|
||||||
|
|
||||||
```
|
```
|
||||||
[core]
|
[identity.default]
|
||||||
ssh_key = ~/.ssh/dejima_ed25519
|
ssh_key = ~/.ssh/dejima_ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
||||||
deletes the plaintext copy immediately after - the sandbox user itself never
|
deletes the plaintext copy immediately after - the sandbox user itself never
|
||||||
has a path to the raw key, only the agent socket (see
|
has a path to the raw key, only the agent socket (see
|
||||||
[security notes](security.md) for the full mount rationale). Setting
|
[security notes](security.md) for the full mount rationale). Setting it means
|
||||||
`[core] ssh_key` means a live forwarded agent is no longer required at all.
|
a live forwarded agent is no longer required at all.
|
||||||
|
|
||||||
## Using a different editor/application
|
## Using a different editor/application
|
||||||
|
|
||||||
@@ -556,6 +640,26 @@ colon is refused outright: both lists are colon-separated, so such a path
|
|||||||
cannot be represented in one at all and would otherwise split into two
|
cannot be represented in one at all and would otherwise split into two
|
||||||
mounts that each fail the existence check.
|
mounts that each fail the existence check.
|
||||||
|
|
||||||
|
## Suspending a session
|
||||||
|
|
||||||
|
Ctrl-Z 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 detaching. A suspended session is stopped, so it makes no
|
||||||
|
progress until you resume it, and it belongs to the terminal you started it
|
||||||
|
from. For a session that keeps working while you are away, or that you want to
|
||||||
|
pick up from somewhere else, run dejima inside tmux and use
|
||||||
|
[`dejima-attach`](remote-access.md).
|
||||||
|
|
||||||
## Shared scratch space
|
## Shared scratch space
|
||||||
|
|
||||||
`~/shared` is a read-write bind mount of `[core] share_dir` (default
|
`~/shared` is a read-write bind mount of `[core] share_dir` (default
|
||||||
@@ -581,6 +685,54 @@ means anything you left in it is readable by every session after the one you
|
|||||||
left it for. A credential dropped in there to get one job done, and then
|
left it for. A credential dropped in there to get one job done, and then
|
||||||
forgotten, is a credential you have handed to every session since.
|
forgotten, is a credential you have handed to every session since.
|
||||||
|
|
||||||
|
## Reading what a session left behind
|
||||||
|
|
||||||
|
When a session compacts, the summary it writes is the only durable account of
|
||||||
|
what a long session worked out - and it goes with the container. dejima keeps
|
||||||
|
a copy host-side, in `[epitaph] dir`, from `bin/dejima`'s exit trap. That
|
||||||
|
directory is never mounted into a container.
|
||||||
|
|
||||||
|
Three commands read it, all defaulting to the repository you are standing in:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dejima-epitaph list # what is kept, oldest first
|
||||||
|
dejima-epitaph search "dns proxy" # which one was it
|
||||||
|
dejima-epitaph show 12 # open it in $VISUAL or $EDITOR
|
||||||
|
```
|
||||||
|
|
||||||
|
`show` takes a position from the listing that `list` and `search` print, or
|
||||||
|
an epitaph's filename, and defaults to the newest. It opens an editor when
|
||||||
|
there is a terminal to open one on and writes to stdout when there is not, so
|
||||||
|
`dejima-epitaph show | grep` behaves. `--all` widens `list` and `search` to
|
||||||
|
every project on the machine, which is also how to see everything the store
|
||||||
|
holds.
|
||||||
|
|
||||||
|
**`list` says whether each session is still resumable.** Where it is, the
|
||||||
|
transcript the summary came from is still on disk, and `claude --resume
|
||||||
|
<session>` gives you the session itself rather than a summary of it. The
|
||||||
|
epitaph is what remains after the harness ages that transcript out.
|
||||||
|
|
||||||
|
**Nothing here hands an epitaph back to a session, and that is deliberate.**
|
||||||
|
A session can write its own transcript, so it can author what its own epitaph
|
||||||
|
says. Delivering that to a later session automatically would plant text in
|
||||||
|
its premises as established background, which is the channel the
|
||||||
|
never-mounted store exists to close. Reading one and deciding what to do with
|
||||||
|
it is the operator's, which is why `show` opens an editor rather than
|
||||||
|
injecting anything: the header it opens on records that this preserves what
|
||||||
|
was written rather than vouching for it.
|
||||||
|
|
||||||
|
**Kept until you delete them.** There is no rotation. Epitaphs are an
|
||||||
|
archive, and a count-based cap would delete oldest-first - the entries most
|
||||||
|
likely to hold reasoning nobody remembers. They are small (tens of KB each,
|
||||||
|
against transcripts a hundred times that), so the store is a housekeeping
|
||||||
|
question rather than a disk one: `[epitaph] dir` is a plain directory of
|
||||||
|
plain markdown, and `rm` is the current answer.
|
||||||
|
|
||||||
|
Setting `[epitaph] enabled = off` stops new ones being kept. It does not hide
|
||||||
|
what is already there: `list`, `search` and `show` keep working, because
|
||||||
|
turning the feature off is the point at which wanting to review and clear the
|
||||||
|
store is most likely.
|
||||||
|
|
||||||
## Testing frontend changes
|
## Testing frontend changes
|
||||||
|
|
||||||
Playwright (Chromium) is pre-installed at image build time, browser
|
Playwright (Chromium) is pre-installed at image build time, browser
|
||||||
|
|||||||
+9
-2
@@ -25,20 +25,25 @@ run them.
|
|||||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
|
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
|
||||||
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
|
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
|
||||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
||||||
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
||||||
| `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-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) |
|
| `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) |
|
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
|
||||||
|
| `tools/` | Optional tools a launch can add to its image - not an axis, since a launch enables zero or more. Same manifest rules, and a bar for what may be added: see [docs/tools.md](tools.md) |
|
||||||
| `docs/release.md` | The pass a `development` -> `main` release goes through, and why each part of it is not a test |
|
| `docs/release.md` | The pass a `development` -> `main` release goes through, and why each part of it is not a test |
|
||||||
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
|
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
|
||||||
|
| `forges/github/bin/` | The github forge's one CLI: `github-pr comments`, which shows unresolved review threads. `gh` covers everything else, but thread resolution lives only in GitHub's GraphQL API, so `gh pr view --comments` cannot tell a closed thread from an open one |
|
||||||
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
|
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
|
||||||
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
|
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
|
||||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
||||||
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
|
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
|
||||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
|
| `bin/dejima-color-lib.sh` | The one terminal palette, and the glyphs that ride the same switch. Indexed colour, not 24-bit: gruvbox is the brand and an ANSI slot **is** gruvbox on a gruvbox terminal, but hardcoding the hexes assumes a background dejima does not own. `web/src/page.css` is the exception, since the page sets its own. Not on PATH |
|
||||||
|
| `bin/dejima-tui-lib.sh` | The palette, heading and numbered prompt `dejima-attach` and `dejima-allow` share. Rows stay with each tool, which lists a different thing from a different source. Not on PATH |
|
||||||
|
| `bin/dejima-update-check` | Host-side: says at startup when this checkout is behind the branch it tracks. Reaches the remote by URL from outside the repository, so the checkout's own git config never names a command it runs |
|
||||||
|
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
|
||||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||||
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
|
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
|
||||||
@@ -48,6 +53,7 @@ run them.
|
|||||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||||
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
||||||
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
|
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
|
||||||
|
| `docs/research-review.md` | When to check dejima's reasoning against the field's, the standing questions that are the agenda, and the queue of bypasses not yet run against this implementation |
|
||||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||||
|
|
||||||
`bin/dejima` is the entry point: it resolves the project path,
|
`bin/dejima` is the entry point: it resolves the project path,
|
||||||
@@ -495,6 +501,7 @@ harness-specific path resolving to nothing is the honest answer.
|
|||||||
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
|
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
|
||||||
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). In the plugin | `bin/harness/claude-code.sh` |
|
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). In the plugin | `bin/harness/claude-code.sh` |
|
||||||
| Transcript discovery | where this workspace's transcripts are, what they are called and how they are nested. `harness_transcript_files` in the plugin, answering one `<key>\t<path>` line per file; core takes the key as the store it files under and refuses one that is not a slug this workspace owns. Both `dejima-epitaph` and `dejima-conversation-ship` used to carry Claude Code's layout directly | `bin/harness/<name>.sh`, `bin/dejima-epitaph`, `bin/dejima-conversation-ship` |
|
| Transcript discovery | where this workspace's transcripts are, what they are called and how they are nested. `harness_transcript_files` in the plugin, answering one `<key>\t<path>` line per file; core takes the key as the store it files under and refuses one that is not a slug this workspace owns. Both `dejima-epitaph` and `dejima-conversation-ship` used to carry Claude Code's layout directly | `bin/harness/<name>.sh`, `bin/dejima-epitaph`, `bin/dejima-conversation-ship` |
|
||||||
|
| Live sessions | which sessions still have a transcript, as `<key>\t<session-id>` lines. `harness_live_sessions` in the plugin, because the id is in the filename for one harness and is the directory name for the next; `dejima-epitaph list` matches an epitaph's own session id against the answer to say whether resuming the real session is still on offer. Optional: a plugin that does not implement it leaves the column unknown rather than claiming a transcript is gone | `bin/harness/<name>.sh`, `bin/dejima-epitaph` |
|
||||||
| Transcript update model | whether a transcript is appended to or replaced wholesale. The manifest's `transcript_update`; the shipper implements both and neither is guessed | `harnesses/<name>/harness.conf` |
|
| Transcript update model | whether a transcript is appended to or replaced wholesale. The manifest's `transcript_update`; the shipper implements both and neither is guessed | `harnesses/<name>/harness.conf` |
|
||||||
| Transcript format | the transform from transcript bytes to log lines. In the plugin; the discovery, shipping, batching and Loki labels stay core's | `bin/harness/<name>.sh` |
|
| Transcript format | the transform from transcript bytes to log lines. In the plugin; the discovery, shipping, batching and Loki labels stay core's | `bin/harness/<name>.sh` |
|
||||||
| Compaction format | what a compaction looks like in a transcript, given one file at a time. In the plugin; the store, which keys are legitimate, its modes and the provenance note stay core's | `bin/harness/<name>.sh` |
|
| Compaction format | what a compaction looks like in a transcript, given one file at a time. In the plugin; the store, which keys are legitimate, its modes and the provenance note stay core's | `bin/harness/<name>.sh` |
|
||||||
|
|||||||
@@ -25,9 +25,15 @@
|
|||||||
# 2. Authentication. ttyd is started with no credential of its own (see
|
# 2. Authentication. ttyd is started with no credential of its own (see
|
||||||
# bin/dejima-ttyd), because it is bound to 127.0.0.1 where only this
|
# bin/dejima-ttyd), because it is bound to 127.0.0.1 where only this
|
||||||
# host can reach it - this proxy is the only gate in front of it. A
|
# host can reach it - this proxy is the only gate in front of it. A
|
||||||
# vhost without auth_basic (or something stronger in its place) is an
|
# vhost that authenticates nothing is an unauthenticated host shell on
|
||||||
# unauthenticated host shell on your LAN. Put every listen behind it:
|
# your LAN. Put every listen behind it: a second server block without
|
||||||
# a second server block without auth is the same open door.
|
# auth is the same open door.
|
||||||
|
#
|
||||||
|
# This file uses HTTP Basic because it is the shortest thing to show.
|
||||||
|
# One shared credential, no per-operator identity, no second factor,
|
||||||
|
# revocation by editing htpasswd. docs/remote-access.md, under
|
||||||
|
# "Authenticating the front door", is what that costs and what else
|
||||||
|
# meets the requirement.
|
||||||
#
|
#
|
||||||
# Authentication alone does not stop a page in your browser from
|
# Authentication alone does not stop a page in your browser from
|
||||||
# opening a WebSocket to this vhost and having the browser attach your
|
# opening a WebSocket to this vhost and having the browser attach your
|
||||||
|
|||||||
+63
-13
@@ -123,6 +123,9 @@ operator can set, resolved the same way as any other config key
|
|||||||
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
|
| `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. |
|
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
|
||||||
| `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. |
|
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
|
||||||
|
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling (`--repo x`, `--repo=x`, and a one-dash flag with its value attached). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
|
||||||
|
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
|
||||||
|
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
|
||||||
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
@@ -169,7 +172,7 @@ as environment variables.
|
|||||||
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
|
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
|
||||||
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
|
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
|
||||||
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
|
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
|
||||||
| `block_merge` | `bool` | `off` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. |
|
| `block_merge` | `bool` | `on` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. Defaults on so a workflow that omits it refuses a merge rather than permitting one; every shipped workflow sets it anyway. |
|
||||||
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
|
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
|
||||||
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
|
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
|
||||||
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
|
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
|
||||||
@@ -257,6 +260,26 @@ resuming the session on the host is `claude --resume <session id>`, with
|
|||||||
`{{session_id}}` filled in at composition time; and its briefing fragment is
|
`{{session_id}}` filled in at composition time; and its briefing fragment is
|
||||||
`briefing.md`.
|
`briefing.md`.
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
Not an axis: a launch selects one forge, one workflow and one harness, and
|
||||||
|
enables zero or more tools. Everything else about it is the same - a manifest
|
||||||
|
that is data, validated by the same schema machinery, never sourced.
|
||||||
|
|
||||||
|
The keys are deliberately few. A tool declares what it is, what installs it,
|
||||||
|
what its environment needs and what the session should be told about it. There
|
||||||
|
is no key for a hook, a mount, a domain or a guard, because a tool that needed
|
||||||
|
one of those would be a change to the boundary rather than a program on PATH.
|
||||||
|
|
||||||
|
| Key | Type | Default | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `description` | `text` | *(required)* | One line, shown when the layer builds and in the session briefing. |
|
||||||
|
| `install` | `depspec` | *(required)* | The same grammar a dependency uses. In practice a `tarball:` spec, because a tool has to be pinned by digest - see [docs/tools.md](tools.md) for why that is a rule rather than a preference. |
|
||||||
|
| `install_layout` | `enum:prefix\|binary` | `prefix` | The archive's shape. `prefix` is one enclosing directory, stripped so `bin/` lands in the same place whatever the release called its top level. `binary` is an archive that IS the executable: stripping a component there would strip the binary and extract nothing, silently. |
|
||||||
|
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs the layer sets. **Every name must be namespaced to the tool** - `RTK_*` for `rtk` - and a launch refuses one that is not. This becomes a Dockerfile `ENV`, which every process in the container inherits, so a bare `NO_COLOR` or `DO_NOT_TRACK` changes the harness and everything else too. A harness manifest may use bare names there because a harness is the container's main process; a tool is one program among many. |
|
||||||
|
| `command` | `identifier` | *(required)* | What it puts on PATH. |
|
||||||
|
| `instructions` | `?relpath` | (empty) | A markdown fragment appended to the session briefing under a core-owned heading, so a tool describes itself once and every harness gets the same words. This is the whole of the per-harness wiring: dejima renders it rather than letting the tool's installer patch a harness config. |
|
||||||
|
|
||||||
## Session briefing composition
|
## Session briefing composition
|
||||||
|
|
||||||
The text a `SessionStart` hook injects into the agent is assembled
|
The text a `SessionStart` hook injects into the agent is assembled
|
||||||
@@ -346,6 +369,12 @@ itself:
|
|||||||
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
|
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
|
||||||
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
|
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
|
||||||
workflow's `block_merge = on`; otherwise empty.
|
workflow's `block_merge = on`; otherwise empty.
|
||||||
|
- `DEJIMA_FORGE_REPO_FLAGS` and `DEJIMA_FORGE_REPO_ENV` - the forge's
|
||||||
|
`repo_flags` and `repo_env`, with `DEJIMA_FORGE_REPO` holding the
|
||||||
|
`<owner>/<name>` of the workspace's `origin` remote. Empty when the forge
|
||||||
|
declares neither, and also when the workspace has no origin to read - in
|
||||||
|
which case a command naming any repository is refused, since there is
|
||||||
|
nothing it could legitimately be naming.
|
||||||
- Whatever the forge's own `env_export` names (e.g. `GITEA_API`,
|
- Whatever the forge's own `env_export` names (e.g. `GITEA_API`,
|
||||||
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
|
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
|
||||||
|
|
||||||
@@ -466,18 +495,26 @@ 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
|
N` walked straight past the merge block. Neither was a hard bug to find once
|
||||||
anyone looked.
|
anyone looked.
|
||||||
|
|
||||||
Two known differences remain, both filed rather than fixed:
|
Two differences from the gitea path remain, both now closed in code and worth
|
||||||
|
knowing about because they are what a second forge costs:
|
||||||
|
|
||||||
- **Review feedback is thinner.** `gitea-pr comments` shows unresolved review
|
- **Review feedback needs its own tool.** `gh pr view --comments` prints
|
||||||
threads and hides the rest; GitHub does not expose thread resolution through
|
reviews and comments with no resolution state anywhere in the output,
|
||||||
the REST API `gh` uses for this, so working through PR feedback there
|
because the REST API it uses does not carry any - `isResolved` exists only
|
||||||
re-reads threads already dealt with, with no signal that it is doing so.
|
on `reviewThreads` in GraphQL. So this forge ships `github-pr comments`,
|
||||||
- **`gh --repo` reaches past the sandboxed repository.** The gitea CLIs refuse
|
which does that one query and otherwise matches `gitea-pr comments`:
|
||||||
an origin that is not on the configured host, so they can only act on the
|
unresolved threads by default, `--all` to include the resolved ones marked
|
||||||
repo the sandbox was launched against. `gh` takes `--repo` on nearly every
|
as such, and a count of what was hidden. Everything else `gitea-pr` does has
|
||||||
subcommand, and `forge = github` supplies a deliberately write-capable
|
a `gh` subcommand already.
|
||||||
token. A per-repository fine-grained PAT is the shape of `push_token_file`
|
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
|
||||||
that makes this moot.
|
gitea CLIs refuse an origin that is not on the configured host, so they can
|
||||||
|
only act on the repo the sandbox was launched against. `gh` takes `--repo`
|
||||||
|
on nearly every subcommand and reads `GH_REPO`, so this forge declares both
|
||||||
|
(`repo_flags`, `repo_env`) and `docker/forge-guard.sh` refuses a value that
|
||||||
|
is not the launched-against repository. Fast-fail, not a boundary, exactly
|
||||||
|
as `merge_block` is: the session holds the token and could call the API
|
||||||
|
directly. What actually bounds it is the token's scope, so a per-repository
|
||||||
|
fine-grained PAT is the expected shape of `push_token_file`.
|
||||||
|
|
||||||
So: if you have a choice, `gitea` is the better-worn path. Choose `github`
|
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
|
because that is where your repositories are, which is a good reason - and
|
||||||
@@ -509,6 +546,16 @@ Two things it does not cover. A command that sends no body has none added -
|
|||||||
would be worse than leaving it. And running a forge CLI on the host, outside
|
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.
|
a sandbox, marks nothing: there the person running it wrote the body.
|
||||||
|
|
||||||
|
**Once, whatever the body already says.** A body that already opens with the
|
||||||
|
marker gets no second one - the marker is a statement rather than a count, and
|
||||||
|
a body saying an agent wrote it has disclosed what this exists to disclose.
|
||||||
|
That matters because agents write it themselves: every issue on a project
|
||||||
|
worked on this way opens with that line, so one reading the existing issues
|
||||||
|
concludes it is the house style. The same words further down the body are
|
||||||
|
prose about disclosure rather than the disclosure, and still get one
|
||||||
|
prepended. The session is also told the marker is added for it, which is the
|
||||||
|
half that stops it being written at all.
|
||||||
|
|
||||||
**Selecting `forge = github` is a real posture change, not just a config
|
**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
|
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)
|
every session gets regardless (clone, fetch, `gh` API calls over port 443)
|
||||||
@@ -516,7 +563,10 @@ never needs port 22 at all. It opens it through core's existing GitHub push
|
|||||||
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
|
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
|
||||||
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
|
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
|
||||||
published ranges and nothing else, logged like every other allowed
|
published ranges and nothing else, logged like every other allowed
|
||||||
connection and verified by the firewall's own startup self-check. Only when
|
connection and verified by the firewall's own startup self-check. The
|
||||||
|
port restriction is an IPv4 statement; what makes port 22 shut *at all*
|
||||||
|
without this grant is that the container blocks IPv6 as well, so an
|
||||||
|
allowlist naming one address family is not the whole of it. Only when
|
||||||
the selected workflow actually pushes - `forge = github` with
|
the selected workflow actually pushes - `forge = github` with
|
||||||
`workflow = local-only` leaves port 22 shut. And `[forge.github] push_token_file` is
|
`workflow = local-only` leaves port 22 shut. And `[forge.github] push_token_file` is
|
||||||
deliberately a *separate*, write-capable token from `[github] token_file`
|
deliberately a *separate*, write-capable token from `[github] token_file`
|
||||||
|
|||||||
+34
-5
@@ -8,6 +8,25 @@ consists of.
|
|||||||
`test/release-pass.sh` runs the parts that can be run and prints the parts that
|
`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.
|
cannot, chosen from what the release actually changes. Start there.
|
||||||
|
|
||||||
|
## What is actually required, at this size
|
||||||
|
|
||||||
|
This project has one maintainer and few users, and a release gate nobody has
|
||||||
|
the bandwidth to complete is a gate that gets skipped rather than met. So the
|
||||||
|
bar is deliberately two things:
|
||||||
|
|
||||||
|
1. **An agentic security review of the whole diff**, against the invariants in
|
||||||
|
section 2. This is the gate. It is cheap, it is repeatable, and it is the
|
||||||
|
one thing that catches the class of mistake this project cannot afford.
|
||||||
|
2. **The maintainer runs the build.** Normal usage for a while, and a launch on
|
||||||
|
the other configured harness to confirm it starts. That is the smoke test,
|
||||||
|
and it is stated as what it is rather than dressed up as coverage.
|
||||||
|
|
||||||
|
Everything in section 4 is what a mature version of this project would verify
|
||||||
|
and this one does not. It is printed by `release-pass.sh` and kept in this
|
||||||
|
document because knowing what is unverified is worth more than pretending
|
||||||
|
otherwise - not because a release is blocked on it. When there are more users
|
||||||
|
than one person can apologise to, that changes.
|
||||||
|
|
||||||
## Why a pass at all
|
## Why a pass at all
|
||||||
|
|
||||||
Every check below exists because something got through without it. The pattern
|
Every check below exists because something got through without it. The pattern
|
||||||
@@ -66,23 +85,33 @@ what this repository is defending.
|
|||||||
case, say so where a reader will meet it - the forge and harness maturity
|
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.
|
sections in [providers.md](providers.md) are what this looks like.
|
||||||
|
|
||||||
## 4. Manual testing
|
## 4. Manual testing, which is mostly not done
|
||||||
|
|
||||||
`test/release-pass.sh` prints the list for the paths this release touches. The
|
`test/release-pass.sh` prints the list for the paths this release touches. It
|
||||||
standing entries, and why each is not a test:
|
is an inventory of what nothing has checked, not a checklist that must be
|
||||||
|
completed - see "What is actually required" above. The standing entries, and
|
||||||
|
why each is not a test:
|
||||||
|
|
||||||
| Touched | What to do, and why no test does it |
|
| 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 |
|
| `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 |
|
| `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 |
|
| 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 |
|
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
|
||||||
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
|
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
|
||||||
| `harnesses/` | Launch the harness against a real model |
|
| `harnesses/` | Launch the harness against a real model |
|
||||||
|
|
||||||
## 5. The release pull request
|
## 5. The release pull request
|
||||||
|
|
||||||
Say what was run and what was not, in the body. A queued privileged job, a
|
Say what was run and what was not, in the body. At this size that will mostly
|
||||||
|
be "not", and writing it down is the whole point: an unverified path that is
|
||||||
|
named is one somebody can reach for when a bug turns up, and an unverified
|
||||||
|
path that is implied to be tested costs a day of looking in the wrong place.
|
||||||
|
|
||||||
|
Worth its own line: **whether the release can be rolled back.** A config key
|
||||||
|
added in a release makes the previous `main` refuse to start once an operator
|
||||||
|
adopts it, because an unknown key fails validation - so the rollback that
|
||||||
|
looks like the safety net is not one. Name the keys. A queued privileged job, a
|
||||||
manual check that was skipped, a forge that has still never been launched -
|
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
|
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.
|
PR is where they are still true and still remembered.
|
||||||
|
|||||||
+476
-205
@@ -36,9 +36,9 @@ attached. The two things holding that line are:
|
|||||||
- **[The bind address](#the-bind-address-is-the-one-thing-to-get-right).**
|
- **[The bind address](#the-bind-address-is-the-one-thing-to-get-right).**
|
||||||
`ttyd` binds `127.0.0.1` and your proxy must bind one specific LAN
|
`ttyd` binds `127.0.0.1` and your proxy must bind one specific LAN
|
||||||
address - never the wildcard, never a Docker bridge.
|
address - never the wildcard, never a Docker bridge.
|
||||||
- **Authentication on the proxy.** `ttyd` carries no credential of its own,
|
- **[Authentication on the proxy](#authenticating-the-front-door).** `ttyd`
|
||||||
so a `server` block without `auth_basic` is an unauthenticated host shell
|
carries no credential of its own, so a `server` block that authenticates
|
||||||
for everyone on that network.
|
nothing is an unauthenticated host shell for everyone on that network.
|
||||||
|
|
||||||
What it does **not** add is a way *out* of a sandbox. Configured as
|
What it does **not** add is a way *out* of a sandbox. Configured as
|
||||||
described below, a compromised session cannot reach `ttyd`, cannot reach
|
described below, a compromised session cannot reach `ttyd`, cannot reach
|
||||||
@@ -50,181 +50,8 @@ front door opening *inward*, and that is a full sandbox escape rather than a
|
|||||||
degradation. [Verifying it](#verifying-it) is two commands, and worth
|
degradation. [Verifying it](#verifying-it) is two commands, and worth
|
||||||
running.
|
running.
|
||||||
|
|
||||||
## `[remote-access]`
|
|
||||||
|
|
||||||
A section of the single config file, at `~/.config/dejima/config`
|
|
||||||
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
|
|
||||||
approvals):
|
|
||||||
|
|
||||||
| Key | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
|
|
||||||
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
|
|
||||||
| `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://`
|
|
||||||
scheme; a value without one is refused rather than guessed at, because
|
|
||||||
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
|
|
||||||
from that file instead. Values are restricted to
|
|
||||||
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
|
|
||||||
end up in an HTTP header and in a URL handed to a phone.
|
|
||||||
|
|
||||||
**A malformed config is a hard failure, not a warning.** The whole file is
|
|
||||||
validated on every read, not just the key being asked for: an unknown key, a
|
|
||||||
value outside that character set, or a line that doesn't parse makes
|
|
||||||
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
|
|
||||||
was wrong. A `ttyd_port` that isn't a number in 1-65535 is refused the same
|
|
||||||
way, as is a `ttyd_font_size` outside 8-72 - a size big enough to make one
|
|
||||||
glyph wider than the viewport renders a terminal that shows nothing, which
|
|
||||||
reads as a broken front door rather than as a typo three sections away. A
|
|
||||||
`ttyd_tmux_prefix` that is not a control chord is refused here too, rather
|
|
||||||
than on the phone, hours later, by whoever presses `PREFIX` and gets nothing.
|
|
||||||
|
|
||||||
`dejima-ttyd` and `dejima-notify-watch` are the two scripts that read a
|
|
||||||
*value* out of the config, so a typo anywhere in the file refuses their
|
|
||||||
start. `dejima` reads values too (`audit dir` among them) and refuses the
|
|
||||||
same way. `dejima-attach` alone only asks `remote_enabled` - deliberately
|
|
||||||
bypassing the whole-file validation, so a typo elsewhere in the config can't
|
|
||||||
lock you out of the terminal you'd fix it from.
|
|
||||||
|
|
||||||
```
|
|
||||||
# ~/.config/dejima/config - substitute your own topic and hostname
|
|
||||||
[remote-access]
|
|
||||||
enabled = on
|
|
||||||
ntfy_url = https://ntfy.example.invalid/your-topic-here
|
|
||||||
base_url = https://dejima.example.invalid
|
|
||||||
ttyd_port = 7681
|
|
||||||
# ttyd_font_size = 20 # optional, and applies to the desktop too
|
|
||||||
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
|
|
||||||
```
|
|
||||||
|
|
||||||
**Editing this changes nothing until the services restart.** Every value
|
|
||||||
is read once, at process start - `dejima-ttyd` reads `ttyd_port`,
|
|
||||||
`ttyd_font_size` and `ttyd_tmux_prefix` and then `exec`s `ttyd` with them
|
|
||||||
baked into its argv. So:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
systemctl --user restart dejima-ttyd.service
|
|
||||||
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
|
|
||||||
pgrep -a ttyd # confirm the argv is what you just configured
|
|
||||||
```
|
|
||||||
|
|
||||||
Skipping this is quiet rather than loud: nothing warns, and the front door
|
|
||||||
keeps serving with the old settings, which reads as "the setting had no
|
|
||||||
effect" rather than as "the process is older than the file".
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
Almost none of this is in the sandbox. The container writes one empty file
|
|
||||||
and knows nothing else about the feature; everything that turns that into a
|
|
||||||
notification, and everything that puts a terminal in front of you
|
|
||||||
afterwards, runs on the host as your own user.
|
|
||||||
|
|
||||||
### The session asks you something
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ the sandbox container ────────────────────────────────────────────┐
|
|
||||||
│ claude code, at a Notification or Stop hook │
|
|
||||||
│ │ │
|
|
||||||
│ │ hooks/dejima-notify.sh writes an empty file named │
|
|
||||||
│ ▼ `waiting` or `done` - no content, no stdin, no network │
|
|
||||||
└───────┼────────────────────────────────────────────────────────────┘
|
|
||||||
════════╪═══ container boundary - one bind-mounted directory ═════════
|
|
||||||
┌───────▼─── your machine, as your user ─────────────────────────────┐
|
|
||||||
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/waiting │
|
|
||||||
│ │ │
|
|
||||||
│ │ inotify, or a ~6s poll where inotify-tools is absent │
|
|
||||||
│ ▼ │
|
|
||||||
│ dejima-notify-watch (systemd --user) │
|
|
||||||
│ │ consumes the marker by NAME; never reads its content │
|
|
||||||
│ │ every byte of the push is composed from host-side data │
|
|
||||||
│ ▼ │
|
|
||||||
└───────┼────────────────────────────────────────────────────────────┘
|
|
||||||
════════╪═══════════════ off your machine ════════════════════════════
|
|
||||||
│ curl
|
|
||||||
▼
|
|
||||||
your ntfy topic ──── push ────► your phone
|
|
||||||
"dejima: <project> needs input"
|
|
||||||
link: <base_url>/?arg=<run-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
The marker directory is the sandbox's entire surface here, and it is
|
|
||||||
one-way: a name, out. Nothing the session writes reaches the push, which is
|
|
||||||
why the notification says a project needs input and never what it is asking.
|
|
||||||
[Notifications](#notifications) has the detail, including where that
|
|
||||||
guarantee actually lives.
|
|
||||||
|
|
||||||
### You come back
|
|
||||||
|
|
||||||
```
|
|
||||||
browser, on the link from the push any plain SSH client
|
|
||||||
│ │
|
|
||||||
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
|
|
||||||
│ HTTP Basic over TLS │ key with a
|
|
||||||
▼ ▼ forced command
|
|
||||||
════════╪══════════════════ your LAN ══════════════════╪══════════════
|
|
||||||
┌─ your machine, as your user ───────────────────────────────────────┐
|
|
||||||
│ nginx, listening on ONE LAN address sshd │
|
|
||||||
│ TLS + auth_basic, plus a default_server authorized_keys with │
|
|
||||||
│ that refuses every other name a forced command │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ proxy_pass http://127.0.0.1:7681 │ │
|
|
||||||
│ ▼ │ │
|
|
||||||
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ └──────────────────────┬───────────────────────┘ │
|
|
||||||
│ ▼ │
|
|
||||||
│ dejima-attach [<run-id>] │
|
|
||||||
│ │ a grouped tmux session │
|
|
||||||
│ ▼ │
|
|
||||||
│ YOUR TMUX SERVER │
|
|
||||||
│ the sandbox's window - and every other │
|
|
||||||
│ window on it, one prefix key away │
|
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
|
||||||
═════════════════════ container boundary ═════════════════════════════
|
|
||||||
┌─ the sandbox container ────────────────────────────────────────────┐
|
|
||||||
│ routes to the Docker bridge gateway - your host - and to nothing │
|
|
||||||
│ else of yours. Neither front door is on that address, which is │
|
|
||||||
│ the whole reason `ttyd` is on loopback and nginx is on one │
|
|
||||||
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
|
|
||||||
│ host shell in here. │
|
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
The web door is the one the notification links into: the browser hits your
|
|
||||||
reverse proxy, which authenticates and forwards to a `ttyd` that only this
|
|
||||||
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
|
|
||||||
from `?arg=`. The SSH door skips all of that. It is a key in your own
|
|
||||||
`authorized_keys` pinned to a forced command, so a plain SSH app on the
|
|
||||||
phone runs `dejima-attach` and nothing else no matter what it asks to run,
|
|
||||||
and lands on a numbered picker of live sessions. It needs no proxy, no
|
|
||||||
`ttyd`, and no notification to have been sent.
|
|
||||||
|
|
||||||
Both converge on `dejima-attach`, and both end at *your* tmux server. That
|
|
||||||
convergence is the point: the controls worth spending effort on are the two
|
|
||||||
front doors, because behind them there is no further boundary to cross - a
|
|
||||||
grouped tmux session shares every window on the server, and the prefix key
|
|
||||||
opens new ones regardless.
|
|
||||||
|
|
||||||
### What `off` means
|
|
||||||
|
|
||||||
With `[remote-access] enabled` off (the default), `dejima` builds a
|
|
||||||
`docker compose` invocation with nothing remote-access in it: no
|
|
||||||
session state is written, no marker directory is created or mounted, and no
|
|
||||||
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
|
|
||||||
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
|
|
||||||
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
|
|
||||||
and starts nothing.
|
|
||||||
|
|
||||||
## The bind address is the one thing to get right
|
## The bind address is the one thing to get right
|
||||||
|
|
||||||
This is the step that carries the whole feature's security, and the only one
|
|
||||||
where a plausible-looking choice is a full sandbox escape.
|
|
||||||
|
|
||||||
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
|
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
|
||||||
container's default-route address - the Docker bridge gateway, which is your
|
container's default-route address - the Docker bridge gateway, which is your
|
||||||
host. That is the one host address a sandbox can reach at all. Combine it
|
host. That is the one host address a sandbox can reach at all. Combine it
|
||||||
@@ -307,6 +134,72 @@ asserts this same containment property against a live container (with a
|
|||||||
positive control on the same path, so a passing result can't come from the
|
positive control on the same path, so a passing result can't come from the
|
||||||
container reaching nothing), and it passes on a real Docker host.
|
container reaching nothing), and it passes on a real Docker host.
|
||||||
|
|
||||||
|
## `[remote-access]`
|
||||||
|
|
||||||
|
A section of the single config file, at `~/.config/dejima/config`
|
||||||
|
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
|
||||||
|
approvals):
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
|
||||||
|
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
|
||||||
|
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
|
||||||
|
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
|
||||||
|
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
|
||||||
|
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
|
||||||
|
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
|
||||||
|
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
|
||||||
|
|
||||||
|
```
|
||||||
|
# ~/.config/dejima/config - substitute your own topic and hostname
|
||||||
|
[remote-access]
|
||||||
|
enabled = on
|
||||||
|
ntfy_url = https://ntfy.example.invalid/your-topic-here
|
||||||
|
base_url = https://dejima.example.invalid
|
||||||
|
ttyd_port = 7681
|
||||||
|
# ttyd_font_size = 20 # optional, and applies to the desktop too
|
||||||
|
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
|
||||||
|
```
|
||||||
|
|
||||||
|
**Editing this changes nothing until the services restart.** Every value is
|
||||||
|
read once, at process start, and baked into `ttyd`'s argv. Skipping the restart
|
||||||
|
is quiet rather than loud: the front door keeps serving with the old settings,
|
||||||
|
which reads as "the setting had no effect".
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user restart dejima-ttyd.service
|
||||||
|
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
|
||||||
|
pgrep -a ttyd # confirm the argv is what you just configured
|
||||||
|
```
|
||||||
|
|
||||||
|
**If this host sits behind a firewall it does not control**, the push has to be
|
||||||
|
permitted outbound before any of this works. `dejima-notify-watch` runs on the
|
||||||
|
host, not in the container, so the container's own allowlist has nothing to do
|
||||||
|
with whether it can reach your ntfy server - and a self-hosted one on a private
|
||||||
|
address is exactly the case a host-level egress filter drops. That failure is
|
||||||
|
the quiet kind: nothing errors, `systemctl --user status dejima-notify-watch`
|
||||||
|
looks healthy, and the phone simply never buzzes.
|
||||||
|
|
||||||
|
**A malformed config is a hard failure, not a warning.** The whole file is
|
||||||
|
validated on every read, not just the key being asked for, so an unknown key or
|
||||||
|
an unparseable line makes `dejima-ttyd` and `dejima-notify-watch` refuse to
|
||||||
|
start and say which line was wrong. So does an out-of-range `ttyd_port`
|
||||||
|
(1-65535) or `ttyd_font_size` (8-72), or a `ttyd_tmux_prefix` that is not a
|
||||||
|
control chord - refused here rather than on the phone, hours later, by whoever
|
||||||
|
presses `PREFIX` and gets nothing.
|
||||||
|
|
||||||
|
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://` scheme
|
||||||
|
and are restricted to `A-Za-z0-9:/._~?=%@+-`. A value without a scheme is
|
||||||
|
refused rather than guessed at, because `curl` reads a leading `-K/path` as an
|
||||||
|
*option* that takes the request's URL from that file instead; the character set
|
||||||
|
is because these end up in an HTTP header and in a URL handed to a phone.
|
||||||
|
|
||||||
|
`dejima` itself refuses the same way, since it reads values too.
|
||||||
|
`dejima-attach` alone reads only `remote_enabled`, deliberately bypassing the
|
||||||
|
whole-file validation, so a typo elsewhere in the config can't lock you out of
|
||||||
|
the terminal you'd fix it from.
|
||||||
|
|
||||||
## SSH front door
|
## SSH front door
|
||||||
|
|
||||||
Generate a key that exists only for this, and give it a forced command:
|
Generate a key that exists only for this, and give it a forced command:
|
||||||
@@ -428,8 +321,10 @@ and forgotten. What matters in it:
|
|||||||
|
|
||||||
- **Authentication on every server block.** `ttyd` is started with no
|
- **Authentication on every server block.** `ttyd` is started with no
|
||||||
credential of its own, because the loopback bind means only this host can
|
credential of its own, because the loopback bind means only this host can
|
||||||
reach it - the proxy is the entire gate. A block without `auth_basic` is
|
reach it - the proxy is the entire gate. A block that authenticates nothing
|
||||||
an unauthenticated host shell on your network.
|
is an unauthenticated host shell on your network. The example uses HTTP
|
||||||
|
Basic; [Authenticating the front door](#authenticating-the-front-door) is
|
||||||
|
what that costs and what else meets the requirement.
|
||||||
- **A `default_server` that refuses.** nginx picks a server block by
|
- **A `default_server` that refuses.** nginx picks a server block by
|
||||||
SNI/`Host`, and when nothing matches it serves the **default** for that
|
SNI/`Host`, and when nothing matches it serves the **default** for that
|
||||||
listen address - which, if the front door is the only block on it, is the
|
listen address - which, if the front door is the only block on it, is the
|
||||||
@@ -452,6 +347,163 @@ and forgotten. What matters in it:
|
|||||||
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
|
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
|
||||||
nginx's default is 60 seconds.
|
nginx's default is 60 seconds.
|
||||||
|
|
||||||
|
**The page carries its own colours.** dejima's terminal output uses indexed
|
||||||
|
colour so your terminal's theme fills the slots - but a browser has no theme
|
||||||
|
to inherit, so the front door is handed gruvbox explicitly: all sixteen ANSI
|
||||||
|
slots plus background, foreground and cursor. Without it xterm.js draws with
|
||||||
|
its own defaults, and the same session looks one way over SSH and another in
|
||||||
|
the browser. `bin/dejima-ttyd` sets it, and `web/src/page.css` carries the
|
||||||
|
same values for the chrome around it.
|
||||||
|
|
||||||
|
## Authenticating the front door
|
||||||
|
|
||||||
|
`ttyd` binds loopback and carries no credential, so whatever sits in front of
|
||||||
|
it is the entire gate. The requirement is that it authenticates **every
|
||||||
|
request to every server block, the default one included** - not that it is any
|
||||||
|
particular software. These are the shapes that meet it, roughly in order of
|
||||||
|
how much they change.
|
||||||
|
|
||||||
|
**A network the public internet cannot reach.** Tailscale, WireGuard, or any
|
||||||
|
VPN that puts the listener on an interface nothing outside can route to.
|
||||||
|
First because it removes the exposure rather than authenticating it, because
|
||||||
|
it composes with everything below, and because for a lot of readers it is both
|
||||||
|
the simplest answer and the strongest. It does not replace [the bind
|
||||||
|
address](#the-bind-address-is-the-one-thing-to-get-right): a VPN interface is
|
||||||
|
still one specific address to bind, and the wildcard is still wrong.
|
||||||
|
|
||||||
|
**HTTP Basic**, which is what the example config shows. One shared credential,
|
||||||
|
no per-operator identity, no second factor, revocation that means editing
|
||||||
|
`htpasswd`, and an access log that cannot tell two people apart. For a solo
|
||||||
|
operator that is a reasonable trade, and worth saying that it is one; for
|
||||||
|
anyone else it is the first thing to replace. It also sends the password on
|
||||||
|
every request, which is why TLS is not optional here.
|
||||||
|
|
||||||
|
**Client certificates.** `ssl_client_certificate` and `ssl_verify_client on`
|
||||||
|
in the same block. Per-device identity, revocable through a CRL, and nothing
|
||||||
|
worth stealing sent on every request. The cost is distribution and renewal,
|
||||||
|
and a phone that has to have the certificate installed - the part that will
|
||||||
|
bite, and the part that differs per platform.
|
||||||
|
|
||||||
|
**A forward-auth proxy.** oauth2-proxy or Authelia via nginx `auth_request`:
|
||||||
|
OIDC, per-user identity, and a session cookie rather than a credential on
|
||||||
|
every request. Also where passkeys realistically arrive, since the proxy
|
||||||
|
handles WebAuthn and `ttyd` never learns about it. The cost is another moving
|
||||||
|
piece in the path to a host shell, which is worth naming given what is on the
|
||||||
|
other side of it.
|
||||||
|
|
||||||
|
**Something you already run.** An identity-aware gateway, or an
|
||||||
|
authenticating proxy already in the path. The requirement above is the whole
|
||||||
|
of what this document asks of it.
|
||||||
|
|
||||||
|
**None of these stops a browser that has already authenticated.** Whatever is
|
||||||
|
in front, a page loaded in a session that has passed it can act - which is the
|
||||||
|
same property that makes the front door usable from a phone you unlocked an
|
||||||
|
hour ago. Authentication decides who gets a shell, not what an already-open
|
||||||
|
tab does with one.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Almost none of this is in the sandbox. The container writes one empty file
|
||||||
|
and knows nothing else about the feature; everything that turns that into a
|
||||||
|
notification, and everything that puts a terminal in front of you
|
||||||
|
afterwards, runs on the host as your own user.
|
||||||
|
|
||||||
|
### The session asks you something
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ the sandbox container ────────────────────────────────────────────┐
|
||||||
|
│ claude code, at a Stop hook │
|
||||||
|
│ │ │
|
||||||
|
│ │ hooks/dejima-notify.sh writes an empty file named │
|
||||||
|
│ ▼ `done` - no content, no stdin, no network │
|
||||||
|
└───────┼────────────────────────────────────────────────────────────┘
|
||||||
|
════════╪═══ container boundary - one bind-mounted directory ═════════
|
||||||
|
┌───────▼─── your machine, as your user ─────────────────────────────┐
|
||||||
|
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/done │
|
||||||
|
│ │ │
|
||||||
|
│ │ inotify, or a ~6s poll where inotify-tools is absent │
|
||||||
|
│ ▼ │
|
||||||
|
│ dejima-notify-watch (systemd --user) │
|
||||||
|
│ │ consumes the marker by NAME; never reads its content │
|
||||||
|
│ │ every byte of the push is composed from host-side data │
|
||||||
|
│ ▼ │
|
||||||
|
└───────┼────────────────────────────────────────────────────────────┘
|
||||||
|
════════╪═══════════════ off your machine ════════════════════════════
|
||||||
|
│ curl
|
||||||
|
▼
|
||||||
|
your ntfy topic ──── push ────► your phone
|
||||||
|
"dejima: <project> finished"
|
||||||
|
link: <base_url>/?arg=<run-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The marker directory is the sandbox's entire surface here, and it is
|
||||||
|
one-way: a name, out. Nothing the session writes reaches the push, which is
|
||||||
|
why the notification says a project has stopped and never what it is asking.
|
||||||
|
[Notifications](#notifications) has the detail, including where that
|
||||||
|
guarantee actually lives.
|
||||||
|
|
||||||
|
### You come back
|
||||||
|
|
||||||
|
```
|
||||||
|
browser, on the link from the push any plain SSH client
|
||||||
|
│ │
|
||||||
|
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
|
||||||
|
│ HTTP Basic over TLS │ key with a
|
||||||
|
▼ ▼ forced command
|
||||||
|
════════╪══════════════════ your LAN ══════════════════╪══════════════
|
||||||
|
┌─ your machine, as your user ───────────────────────────────────────┐
|
||||||
|
│ nginx, listening on ONE LAN address sshd │
|
||||||
|
│ TLS + auth, plus a default_server authorized_keys with │
|
||||||
|
│ that refuses every other name a forced command │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ proxy_pass http://127.0.0.1:7681 │ │
|
||||||
|
│ ▼ │ │
|
||||||
|
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────┬───────────────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ dejima-attach [<run-id>] │
|
||||||
|
│ │ a grouped tmux session │
|
||||||
|
│ ▼ │
|
||||||
|
│ YOUR TMUX SERVER │
|
||||||
|
│ the sandbox's window - and every other │
|
||||||
|
│ window on it, one prefix key away │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
═════════════════════ container boundary ═════════════════════════════
|
||||||
|
┌─ the sandbox container ────────────────────────────────────────────┐
|
||||||
|
│ routes to the Docker bridge gateway - your host - and to nothing │
|
||||||
|
│ else of yours. Neither front door is on that address, which is │
|
||||||
|
│ the whole reason `ttyd` is on loopback and nginx is on one │
|
||||||
|
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
|
||||||
|
│ host shell in here. │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The web door is the one the notification links into: the browser hits your
|
||||||
|
reverse proxy, which authenticates and forwards to a `ttyd` that only this
|
||||||
|
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
|
||||||
|
from `?arg=`. The SSH door skips all of that. It is a key in your own
|
||||||
|
`authorized_keys` pinned to a forced command, so a plain SSH app on the
|
||||||
|
phone runs `dejima-attach` and nothing else no matter what it asks to run,
|
||||||
|
and lands on a numbered picker of live sessions. It needs no proxy, no
|
||||||
|
`ttyd`, and no notification to have been sent.
|
||||||
|
|
||||||
|
Both converge on `dejima-attach`, and both end at *your* tmux server. That
|
||||||
|
convergence is the point: the controls worth spending effort on are the two
|
||||||
|
front doors, because behind them there is no further boundary to cross - a
|
||||||
|
grouped tmux session shares every window on the server, and the prefix key
|
||||||
|
opens new ones regardless.
|
||||||
|
|
||||||
|
### What `off` means
|
||||||
|
|
||||||
|
With `[remote-access] enabled` off (the default), `dejima` builds a
|
||||||
|
`docker compose` invocation with nothing remote-access in it: no
|
||||||
|
session state is written, no marker directory is created or mounted, and no
|
||||||
|
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
|
||||||
|
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
|
||||||
|
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
|
||||||
|
and starts nothing.
|
||||||
|
|
||||||
## Reading it on a phone
|
## Reading it on a phone
|
||||||
|
|
||||||
`dejima-ttyd` launches `ttyd` with `--index web/index.html`, which replaces
|
`dejima-ttyd` launches `ttyd` with `--index web/index.html`, which replaces
|
||||||
@@ -461,15 +513,13 @@ there is nothing to turn on.
|
|||||||
`ttyd`'s own page is close to unusable on a phone for anything beyond typing,
|
`ttyd`'s own page is close to unusable on a phone for anything beyond typing,
|
||||||
and not because of a setting anyone missed. xterm.js turns a tap into a mouse
|
and not because of a setting anyone missed. xterm.js turns a tap into a mouse
|
||||||
click, but a touch *drag* is consumed by the browser as viewport scrolling and
|
click, but a touch *drag* is consumed by the browser as viewport scrolling and
|
||||||
never becomes a mouse drag - so nothing downstream ever sees one. tmux binds
|
never becomes a mouse drag, so nothing downstream ever sees one: tmux's
|
||||||
`MouseDrag1Border` to `resize-pane -M` by default and nvim resizes splits from
|
`resize-pane -M` and nvim's split-separator drags have always worked and simply
|
||||||
separator drags whenever `mouse` is on; both have always worked, and simply
|
never received the event. The same missing drag is why selecting text and
|
||||||
never received a drag event from a touch client. The same missing drag is why
|
scrolling an application's output are impossible. Then there is no Ctrl key,
|
||||||
selecting text is impossible, and scrolling an application's output with it.
|
the tmux status bar's window names are a few pixels wide, and `ttyd` serves no
|
||||||
Then there is no Ctrl key, the tmux status bar's window names are a few pixels
|
viewport meta tag at all, so a phone lays the page out at a virtual desktop
|
||||||
wide, and `ttyd` serves no viewport meta tag at all - so a phone lays the page
|
width and scales the result down, which no font size can rescue.
|
||||||
out at a virtual desktop width and scales the result down, which no font size
|
|
||||||
can rescue.
|
|
||||||
|
|
||||||
### Gestures
|
### Gestures
|
||||||
|
|
||||||
@@ -512,7 +562,10 @@ PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
|
|||||||
without closing the tab. It reloads the page without the run-id, which is
|
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
|
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,
|
unaffected: each client gets its own tmux session grouped onto the real one,
|
||||||
and only that view is reaped when you detach.
|
and only that view is reaped when you detach. The picker arrives on a
|
||||||
|
cleared screen, scrollback included - it is a page load rather than a
|
||||||
|
scroll, so otherwise it draws over the tail of the session you just left and
|
||||||
|
a swipe up lands in the middle of it.
|
||||||
- `ZOOM` is prefix + `z`, toggling tmux's pane zoom - the fastest way to make
|
- `ZOOM` is prefix + `z`, toggling tmux's pane zoom - the fastest way to make
|
||||||
one pane usable on a small screen.
|
one pane usable on a small screen.
|
||||||
- `PASTE` reads the phone's clipboard and sends it, wrapped in bracketed-paste
|
- `PASTE` reads the phone's clipboard and sends it, wrapped in bracketed-paste
|
||||||
@@ -524,6 +577,23 @@ PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
|
|||||||
- `KBD` raises the soft keyboard, which is otherwise awkward to get back once
|
- `KBD` raises the soft keyboard, which is otherwise awkward to get back once
|
||||||
it has been dismissed.
|
it has been dismissed.
|
||||||
|
|
||||||
|
**The buttons are colour-coded by what they are for**, in the same gruvbox
|
||||||
|
palette `dejima-attach` uses for the session picker - the two sit either side
|
||||||
|
of one attach and should not look like different programs.
|
||||||
|
|
||||||
|
The colours are a hierarchy, not decoration. `KBD` is filled orange because on
|
||||||
|
a phone it is how you type at all, and it is the one key that has to be
|
||||||
|
findable without looking for it. `⏎` is filled green, being the key a session
|
||||||
|
asks for most. The window and arrow keys are outlined in blue: reached for
|
||||||
|
constantly, but never the thing you are hunting for. Everything else is
|
||||||
|
furniture and stays grey. A latched `CTRL` or `DRAG` still overrides whichever
|
||||||
|
tier its button is in, because the state a key is in matters more than the job
|
||||||
|
it does.
|
||||||
|
|
||||||
|
Set `ttyd_button_colors = off` for the grey every key had before - for a
|
||||||
|
terminal colour scheme this clashes with, or if you would rather it were
|
||||||
|
quiet.
|
||||||
|
|
||||||
**Tapping the terminal does not raise the keyboard.** On a phone, tapping is
|
**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
|
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
|
keyboard and cost a second tap to put it away. `KBD` is the deliberate way to
|
||||||
@@ -675,8 +745,8 @@ cost of losing the correction everywhere else.
|
|||||||
Terminating TLS somewhere else and forwarding here is a supported shape, but
|
Terminating TLS somewhere else and forwarding here is a supported shape, but
|
||||||
read this first: it moves the front door out of the LAN threat model
|
read this first: it moves the front door out of the LAN threat model
|
||||||
everything above is written against. If the outer proxy is internet-facing,
|
everything above is written against. If the outer proxy is internet-facing,
|
||||||
`auth_basic` over TLS is now the only thing between the public internet and
|
whatever authenticates it is now the only thing between the public internet
|
||||||
a host shell. Put something stronger in front of it, and take the
|
and a host shell, and HTTP Basic is a thin thing for that to be. Put something stronger in front of it, and take the
|
||||||
[security notes](security.md) as the floor rather than the ceiling.
|
[security notes](security.md) as the floor rather than the ceiling.
|
||||||
|
|
||||||
The mechanical part has four requirements at the outer hop, and each one
|
The mechanical part has four requirements at the outer hop, and each one
|
||||||
@@ -728,14 +798,24 @@ what the outer proxy sends.
|
|||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
|
`dejima` registers `hooks/dejima-notify.sh` as a `Stop` hook for a
|
||||||
hook for a remote-enabled session, into the throwaway settings override layer
|
remote-enabled session, into the throwaway settings override layer it already
|
||||||
it already generates - never into your real `~/.claude/settings.json`. Your
|
generates - never into your real `~/.claude/settings.json`. Your own hooks
|
||||||
own hooks keep running alongside it, since Claude Code merges hooks across
|
keep running alongside it, since Claude Code merges hooks across layers rather
|
||||||
layers rather than replacing. The layer is mounted read-only, so a session
|
than replacing. The layer is mounted read-only, so a session cannot
|
||||||
cannot unregister its own hook. The hook writes one empty file named
|
unregister its own hook. The hook writes one empty file named `done` into
|
||||||
`waiting` or `done` into this run's marker directory: no content, no stdin,
|
this run's marker directory: no content, no stdin, no network.
|
||||||
no network.
|
|
||||||
|
**One push per stop, and not a second one a minute later.** The hook and the
|
||||||
|
watcher both understand a `waiting` event as well, and Claude Code's
|
||||||
|
`Notification` hook is where it would come from - but that hook fires when
|
||||||
|
Claude needs permission to run something and when the prompt has sat idle,
|
||||||
|
and a dejima session runs with permission prompts off. Only the timer is
|
||||||
|
left, and it expires shortly after the Stop it follows, so registering both
|
||||||
|
sent two pushes for one event with the second saying nothing the first had
|
||||||
|
not. What is given up is being told about a session that has gone idle
|
||||||
|
without having stopped first, which Claude Code cannot currently distinguish;
|
||||||
|
a harness that can may register `waiting` and get its own notification.
|
||||||
|
|
||||||
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
|
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
|
||||||
Same installation shape as above:
|
Same installation shape as above:
|
||||||
@@ -781,8 +861,9 @@ than a new exposure, but a RAM-backed filesystem fails harder and sooner.
|
|||||||
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
|
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||||
logging out, which discards the tmpfs) is the recovery.
|
logging out, which discards the tmpfs) is the recovery.
|
||||||
|
|
||||||
**What a notification contains.** A title of `dejima: <project> needs input`
|
**What a notification contains.** A title of `dejima: <project> finished`
|
||||||
(or `dejima: <project> finished`), a fixed one-line body, and - when
|
(or `dejima: <project> needs input`, for a harness that registers `waiting`),
|
||||||
|
a fixed one-line body, and - when
|
||||||
`base_url` is set - a click link of `<base_url>/?arg=<run-id>` that opens the
|
`base_url` is set - a click link of `<base_url>/?arg=<run-id>` that opens the
|
||||||
web front door already attached to that session. `<project>` is the
|
web front door already attached to that session. `<project>` is the
|
||||||
workspace directory's name, taken from the host-side registry and reduced to
|
workspace directory's name, taken from the host-side registry and reduced to
|
||||||
@@ -808,7 +889,20 @@ last point is what the enum is really for: ntfy's own headers include
|
|||||||
issue an arbitrary HTTP request, so a single attacker-controlled header
|
issue an arbitrary HTTP request, so a single attacker-controlled header
|
||||||
would turn it into a confused deputy inside your network.
|
would turn it into a confused deputy inside your network.
|
||||||
|
|
||||||
**When no notification arrives.** Five things have to happen in order, and a
|
**Where that guarantee stops is the container boundary.** `ntfy_url` is a
|
||||||
|
credential for a channel your phone trusts, sitting in a file on this host.
|
||||||
|
Anyone with your account on this machine can read it, publish whatever they
|
||||||
|
like to that topic, drown it until you mute it, or subscribe to it if it is
|
||||||
|
not write-only. None of that goes through the sandbox, so none of the
|
||||||
|
reasoning above applies to it.
|
||||||
|
|
||||||
|
Scope the topic accordingly: a token that can only write the topic dejima
|
||||||
|
uses, and a **separate** topic for anything you want to trust more than you
|
||||||
|
trust this host. Anything monitoring this machine's own health belongs in that
|
||||||
|
second topic, with its credential not on this machine at all - a box that can
|
||||||
|
forge alerts about itself is not reporting on itself.
|
||||||
|
|
||||||
|
**When no notification arrives.** Six things have to happen in order, and a
|
||||||
break in any of them looks identical from the phone - which is to say, like
|
break in any of them looks identical from the phone - which is to say, like
|
||||||
nothing at all. Walk them from the inside out; each step tells you whether
|
nothing at all. Walk them from the inside out; each step tells you whether
|
||||||
the next one is worth checking.
|
the next one is worth checking.
|
||||||
@@ -817,9 +911,9 @@ the next one is worth checking.
|
|||||||
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
|
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
|
||||||
container with remote access off - `[remote-access] enabled` was off at
|
container with remote access off - `[remote-access] enabled` was off at
|
||||||
launch, and no later change affects a session already running.
|
launch, and no later change affects a session already running.
|
||||||
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
|
2. **Is the hook registered?** The `Stop` entry lives in the throwaway
|
||||||
the throwaway override layer, not in your real `settings.json`:
|
override layer, not in your real `settings.json`:
|
||||||
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
|
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 1.
|
||||||
That file is passed to the harness by `harness-wrapper.sh`, as whatever
|
That file is passed to the harness by `harness-wrapper.sh`, as whatever
|
||||||
its manifest's `settings_args` names (`--settings` for Claude Code);
|
its manifest's `settings_args` names (`--settings` for Claude Code);
|
||||||
if it is missing entirely, this container was not started by `dejima`.
|
if it is missing entirely, this container was not started by `dejima`.
|
||||||
@@ -841,7 +935,11 @@ the next one is worth checking.
|
|||||||
or 403 means the instance wants credentials, which the URL can carry as
|
or 403 means the instance wants credentials, which the URL can carry as
|
||||||
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
|
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
|
||||||
header of its own.
|
header of its own.
|
||||||
5. **Is the phone subscribed to the topic the config names?** Last in the
|
5. **Can this host reach the ntfy server at all?** `curl -sv "$ntfy_url"`
|
||||||
|
from the host, not from a session. A host-level egress filter this machine
|
||||||
|
does not control drops the push with nothing in any log here, and every
|
||||||
|
step above still passes - see the note under `ntfy_url` above.
|
||||||
|
6. **Is the phone subscribed to the topic the config names?** Last in the
|
||||||
list and first to check in practice, since it is the only link with no
|
list and first to check in practice, since it is the only link with no
|
||||||
log anywhere. The topic in `ntfy_url` and the one in the app have to be
|
log anywhere. The topic in `ntfy_url` and the one in the app have to be
|
||||||
the same string.
|
the same string.
|
||||||
@@ -907,3 +1005,176 @@ from shrinking the desktop's view, and the option's only scopes are the
|
|||||||
window - which a grouped session *shares* with yours, so setting it there
|
window - which a grouped session *shares* with yours, so setting it there
|
||||||
would permanently overwrite your choice with nothing to restore afterwards -
|
would permanently overwrite your choice with nothing to restore afterwards -
|
||||||
and global. An operator who deliberately set `smallest` keeps it.
|
and global. An operator who deliberately set `smallest` keeps it.
|
||||||
|
|
||||||
|
## Mounting the box's files
|
||||||
|
|
||||||
|
The front doors above get you a terminal. The other half of working from
|
||||||
|
another machine is the files - the screenshots, specs and scratch output a
|
||||||
|
session leaves behind - and `sshfs` puts those directories in your own file
|
||||||
|
manager and editor over the SSH access you already have.
|
||||||
|
|
||||||
|
Read the next section before setting it up. What it costs is not obvious from
|
||||||
|
the fact that it is only a filesystem mount, and it is not a cost dejima can
|
||||||
|
carry for you: nothing dejima runs is in the path.
|
||||||
|
|
||||||
|
### What mounting it costs you
|
||||||
|
|
||||||
|
**Every byte and every name in those directories was written by a sandboxed
|
||||||
|
session.** That is what they are for - a handoff directory a session cannot
|
||||||
|
write to is not a handoff directory - so this is not a defect to fix but the
|
||||||
|
surface to know about. dejima sanitises session-authored bytes before
|
||||||
|
printing them to your terminal, in `dejima-audit` and `dejima-epitaph`,
|
||||||
|
because that is the one surface it controls. A file manager on another
|
||||||
|
machine is not that surface, and nothing carries the sanitising there.
|
||||||
|
|
||||||
|
**Mounting removes the deciding.** Copying a file off the box when you want
|
||||||
|
it is a decision per file. A mount, and especially one a login unit brings up
|
||||||
|
for you, means the directory is simply present whenever you are logged in -
|
||||||
|
and a desktop does not wait to be asked. A file indexer walks new directories and
|
||||||
|
opens what it finds to extract text from PDFs, archives and office documents.
|
||||||
|
A thumbnailer decodes images and video frames. A previewer parses on
|
||||||
|
selection. Those are image and document parsers, running as you, unsandboxed,
|
||||||
|
reached with no click. dejima's whole premise is that the session may be
|
||||||
|
hostile; none of that software was written with that assumption about a
|
||||||
|
directory on your own disk. This is the [threat
|
||||||
|
model](threat-model.md#ranked-by-what-is-actually-likely)'s fifth-ranked item
|
||||||
|
with its one bound removed - it is still the operator's side of the handoff,
|
||||||
|
but no person decides per file any more.
|
||||||
|
|
||||||
|
**Names are session-chosen too, and a desktop renders names.** Control
|
||||||
|
characters, bidi overrides, and a long name whose visible tail is not its real
|
||||||
|
extension all survive into a file listing. The rule for a terminal applies
|
||||||
|
unchanged to a file manager: what is displayed is not evidence of what it is.
|
||||||
|
|
||||||
|
**Opening the directory as a project is code execution.** Editors act on
|
||||||
|
configuration they find beside the files - `.envrc` for direnv, a `.vscode`
|
||||||
|
directory, a project-local `exrc`, a `.git/config` that any later `git`
|
||||||
|
command run in that directory honours. A session that can write the directory
|
||||||
|
chooses those files as freely as it chooses the screenshots. Pull files out of
|
||||||
|
the mount; do not point a project-opening tool at it.
|
||||||
|
|
||||||
|
**The mount is read-write, and your desktop uses that.** Thumbnail caches,
|
||||||
|
trash directories, editor swap and undo files land in the share, where every
|
||||||
|
later session reads them. An editor swap file can hold buffer content from
|
||||||
|
the file you had open, and deleting something in a file manager usually moves
|
||||||
|
it to a trash directory inside the same mount rather than removing it.
|
||||||
|
|
||||||
|
**Symlinks resolve on your machine.** `sshfs` passes a symlink through as a
|
||||||
|
symlink, so one a session planted resolves against *your* filesystem: a
|
||||||
|
backup job, an indexer, `cp -r` or `rsync -L` pointed at the mount can be led
|
||||||
|
out of it and into your own files. `-o follow_symlinks` inverts that by
|
||||||
|
resolving them on the box instead, which hands you the box's files rather
|
||||||
|
than yours. Neither is what you want, so do not add that option and do not
|
||||||
|
point recursive tools at the mount.
|
||||||
|
|
||||||
|
**What it does not add is a route inward.** A session cannot see the mount,
|
||||||
|
cannot cause one, and gains nothing from the fact that one exists. The ssh
|
||||||
|
credential involved already gets you a shell on that box, so it is not a new
|
||||||
|
grant either. Everything above is about where session-authored bytes end up,
|
||||||
|
which is a machine that appears nowhere in dejima's blast-radius table.
|
||||||
|
|
||||||
|
**Keeping it small** is most of the answer, and unmounting is the biggest
|
||||||
|
lever: none of the above happens to a directory that is not currently there.
|
||||||
|
Then mount the handoff directories rather than the box's home, add `-o ro` for
|
||||||
|
one you only ever pull from, exclude the mount point from your indexer's and
|
||||||
|
your backup tool's scope, and [clear `share_dir` out
|
||||||
|
periodically](configuration.md#shared-scratch-space) - which now means stale
|
||||||
|
files on two machines rather than one.
|
||||||
|
|
||||||
|
### Setting it up
|
||||||
|
|
||||||
|
**Use your ordinary key, not the front-door one.** A `command=` in
|
||||||
|
`authorized_keys` replaces whatever the client asked to run, and a
|
||||||
|
`subsystem sftp` request is not an exception, so a client offering the phone
|
||||||
|
key gets `dejima-attach` where it expected the SFTP protocol and fails
|
||||||
|
without explaining itself. Do not relax that key to make this work: the
|
||||||
|
forced command is the whole of what makes it safe to leave on a phone. The
|
||||||
|
key that already gets you a normal shell on the box is the right one, and it
|
||||||
|
grants nothing new here - you had the shell.
|
||||||
|
|
||||||
|
**Find the host path first.** `~/shared` is where the *container* sees it; on
|
||||||
|
the host it is `[core] share_dir`, which defaults to
|
||||||
|
`~/.local/share/dejima/shared` and is not named `shared` at all.
|
||||||
|
`dejima --print-config | grep share_dir` on the box prints the resolved path.
|
||||||
|
Anything in `[mounts] read_write` needs no lookup, because those mount at the
|
||||||
|
host path they name: `~/specs` in the container is `~/specs` on the box.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sshfs box:.local/share/dejima/shared ~/mnt/dejima-shared \
|
||||||
|
-o IdentityFile=/home/<you>/.ssh/id_ed25519,IdentitiesOnly=yes \
|
||||||
|
-o PasswordAuthentication=no \
|
||||||
|
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 \
|
||||||
|
-o Compression=no
|
||||||
|
```
|
||||||
|
|
||||||
|
`IdentitiesOnly=yes` stops ssh offering every key your agent holds ahead of
|
||||||
|
the one you named, which can exhaust the server's `MaxAuthTries` and fail as
|
||||||
|
though the key were wrong. `PasswordAuthentication=no` makes an unattended
|
||||||
|
mount fail immediately rather than block on a prompt nobody is there to
|
||||||
|
answer. The keepalives are what make `reconnect` worth having: they give up
|
||||||
|
after about 45 seconds, where a laptop that slept or changed network
|
||||||
|
otherwise leaves every `ls` hanging on the kernel's TCP timeout.
|
||||||
|
`Compression=no` because screenshots and PDFs are already compressed.
|
||||||
|
|
||||||
|
Two additions worth knowing rather than copying. `-o
|
||||||
|
Ciphers=aes128-gcm@openssh.com` is faster when both ends have AES-NI, and
|
||||||
|
slower when they do not - ssh's default puts `chacha20-poly1305` first
|
||||||
|
precisely because it wins on hardware without it. And `-o idmap=user` if your
|
||||||
|
account has a different UID on the two machines, or every file arrives owned
|
||||||
|
by whoever holds that number locally.
|
||||||
|
|
||||||
|
A `Host box` block in `~/.ssh/config` carrying the identity and keepalive
|
||||||
|
options keeps all of this to `sshfs box:... ~/mnt/...`, which is worth doing
|
||||||
|
once rather than pasting the long form wherever you need it.
|
||||||
|
|
||||||
|
**Mount it when you want it, and `fusermount3 -u` when you are done.** Two
|
||||||
|
commands is not much friction, and it buys back the thing the section above
|
||||||
|
says a mount costs: while it is unmounted there is no directory for an
|
||||||
|
indexer to walk or a previewer to open, so the window in which your machine
|
||||||
|
acts on session output is one you opened on purpose. Every automatic form
|
||||||
|
below trades that away. There is deliberately no `/etc/fstab` line here -
|
||||||
|
those run as root, which means a key root can read, root's `~/.ssh/config`
|
||||||
|
instead of yours, and `allow_other` before your own account can see inside
|
||||||
|
the mount, at which point every account on the machine can too. That is a
|
||||||
|
worse deal in every direction than typing `sshfs`.
|
||||||
|
|
||||||
|
### On login
|
||||||
|
|
||||||
|
**Prefer mounting on demand.** This is here because a mount you have to
|
||||||
|
remember is a mount you stop using, not because it is the better answer: an
|
||||||
|
always-present directory is exactly the case the cost section is about. Worth
|
||||||
|
it for a directory you genuinely work out of, like `~/specs`; not worth it
|
||||||
|
for one you look at twice a week.
|
||||||
|
|
||||||
|
A **user** service runs as you, so the key in your home directory works
|
||||||
|
without being made readable by anyone else. In
|
||||||
|
`~/.config/systemd/user/dejima-shared.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=dejima shared directory
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/sshfs -f box:.local/share/dejima/shared %h/mnt/dejima-shared \
|
||||||
|
-o IdentityFile=%h/.ssh/id_ed25519,IdentitiesOnly=yes \
|
||||||
|
-o PasswordAuthentication=no \
|
||||||
|
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3
|
||||||
|
ExecStop=/bin/fusermount3 -u %h/mnt/dejima-shared
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`-f` is the load-bearing flag: without it `sshfs` daemonises, systemd watches
|
||||||
|
the parent exit and calls the mount a failure. `fusermount3` is the libfuse3
|
||||||
|
name, `fusermount` the libfuse2 one. Then
|
||||||
|
`systemctl --user enable --now dejima-shared`, and one unit per directory -
|
||||||
|
`%h` expands to your home, so the same file with two paths changed covers
|
||||||
|
`~/specs`.
|
||||||
|
|
||||||
|
`systemctl --user` needs `loginctl enable-linger <you>` if you want the mount
|
||||||
|
to survive you logging out, the same requirement `dejima-notify-watch` has.
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Reviewing the state of the art
|
||||||
|
|
||||||
|
[← dejima README](../README.md)
|
||||||
|
|
||||||
|
dejima's first month, from late July 2026, was spent reasoning from its own
|
||||||
|
threat model without checking it against anyone else's. The first sweep, in
|
||||||
|
August, turned up two things worth having found sooner than that:
|
||||||
|
|
||||||
|
- **A parser-differential bypass class against egress allowlists**, disclosed
|
||||||
|
against another vendor's agent network sandbox: a null byte in a SOCKS5
|
||||||
|
hostname, so the policy check read `attacker-host\x00.google.com` and matched
|
||||||
|
a `.google.com` suffix while the resolver read `attacker-host`. dejima's
|
||||||
|
matcher refused that shape and every neighbouring one, for structural reasons
|
||||||
|
now recorded beside it - it matches whole domains rather than suffixes, and
|
||||||
|
the decision and the dial are bound by the ipset being filled from the
|
||||||
|
answer the resolver returned. One laxity turned up and was fixed.
|
||||||
|
- **The closest comparable project ships the opposite default.** Trail of Bits'
|
||||||
|
Claude Code devcontainer lists network under "not sandboxed" and has full
|
||||||
|
outbound access unless an operator opts in. Working out why was worth more
|
||||||
|
than the comparison: their scope is the host filesystem for an auditor
|
||||||
|
watching a session, and network was never the thing they were containing.
|
||||||
|
|
||||||
|
Neither changed a control, which is the point. A month is not long to have gone
|
||||||
|
unchecked, and finding out was cheap - which is the argument for doing it on a
|
||||||
|
schedule rather than when something goes wrong.
|
||||||
|
|
||||||
|
This is the process. It is deliberately small: the failure mode of a research
|
||||||
|
review is producing a reading list nobody acts on.
|
||||||
|
|
||||||
|
## When to run one
|
||||||
|
|
||||||
|
**Before locking a control.** This repository's rule is to measure the premise
|
||||||
|
before designing on it. Extended one step: ask what measurement would falsify
|
||||||
|
the control, *and* whether someone has already run it - or already bypassed it.
|
||||||
|
|
||||||
|
- Before adding or changing a boundary: a mount, an egress path, a guard, a
|
||||||
|
credential that crosses.
|
||||||
|
- When a control is being justified by reasoning rather than by a test. That is
|
||||||
|
the moment to find out whether the reasoning is known to be wrong.
|
||||||
|
- When a bypass is published for anything in the same class. Another project's
|
||||||
|
egress allowlist bypass is a test case for this one.
|
||||||
|
- Quarterly as a standing sweep, whether or not anything is pending. A threat
|
||||||
|
model written eighteen months ago describes a different attacker.
|
||||||
|
|
||||||
|
That last one is the reason the schedule exists at all. Reported model
|
||||||
|
capability on offensive security tasks has moved fast enough that a sandbox
|
||||||
|
designed against a 2024 agent is not obviously adequate against a 2026 one, and
|
||||||
|
nothing else here re-examines that assumption on any schedule.
|
||||||
|
|
||||||
|
## The standing questions
|
||||||
|
|
||||||
|
The agenda. Each entry is a live claim dejima rests on, phrased so the field can
|
||||||
|
contradict it. Keep it current: a question nobody would answer "no" to is not
|
||||||
|
doing any work.
|
||||||
|
|
||||||
|
1. **Is a hardened container an adequate boundary for an agent assumed
|
||||||
|
hostile?** The field says no for untrusted code, and tiers gVisor and
|
||||||
|
per-workload VM kernels above it. dejima's answer is that escape moves a
|
||||||
|
layer - true where the layer exists. See
|
||||||
|
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||||
|
2. **Does a name-based egress allowlist hold?** It holds against an agent that
|
||||||
|
must resolve a name. It does not hold against exfiltration through an
|
||||||
|
allowed domain or against DNS query-name channels, both of which are listed
|
||||||
|
in [What dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
|
||||||
|
The open part is parser differentials between the matcher and the resolver.
|
||||||
|
3. **Where must egress enforcement sit?** Below the harness: a subprocess with
|
||||||
|
its own HTTP client bypasses anything the harness mediates. dejima enforces
|
||||||
|
at the container's network layer, which satisfies this. Settled unless the
|
||||||
|
architecture changes.
|
||||||
|
4. **Is "the operator merges" the right shape for human-in-the-loop?** The
|
||||||
|
field's phrasing is "human approval on irreversible actions", which is what
|
||||||
|
the merge and push guards implement. Corroborated; stop re-deriving it.
|
||||||
|
5. **What is the current bypass literature for agent sandboxes?** The most
|
||||||
|
valuable question and the one that ages fastest.
|
||||||
|
6. **Does anyone else treat the previous session's output as hostile input to
|
||||||
|
the next one?** The positioning question, and as of the first sweep the
|
||||||
|
answer is no.
|
||||||
|
7. **What does the field consider baseline that dejima does not do?** The
|
||||||
|
cheapest question to ask. It found the absence of timeouts, which is now a
|
||||||
|
[stated non-goal](security.md#why-there-are-no-timeouts) rather than a gap.
|
||||||
|
8. **Is a control argued in one layer honoured in the others?** The three
|
||||||
|
projects are three repositories with three vocabularies, and nothing checks
|
||||||
|
that a claim made in one reaches the next. The first sweep produced the
|
||||||
|
worked example: dejima blocks IPv6 outright and says why, `dejima-ci-hardener`
|
||||||
|
makes the same argument for its job network - and both sibling projects'
|
||||||
|
node-side firewalls were IPv4-only, written later, by people who had already
|
||||||
|
made that argument twice. Each project's tests pass, because each tests its
|
||||||
|
own layer against its own template. This question is the only thing looking
|
||||||
|
at the seams.
|
||||||
|
|
||||||
|
## How to run one
|
||||||
|
|
||||||
|
**1. Sweep, do not read.** Search broadly and read only summaries. This pass
|
||||||
|
produces a shortlist, not understanding. Search the standing questions, not
|
||||||
|
dejima's vocabulary - our terms are ours, and "guarded store" finds nothing.
|
||||||
|
|
||||||
|
**2. Read in full only what could change a control.** A source that agrees
|
||||||
|
changes nothing and can stay a citation. Read the ones describing a bypass of a
|
||||||
|
control that exists here, quantifying something asserted here, or occupying
|
||||||
|
ground assumed empty.
|
||||||
|
|
||||||
|
**3. Get the mechanism, not the headline.** "Egress allowlists are bypassable"
|
||||||
|
is not actionable. "The policy check saw one hostname and the resolver saw
|
||||||
|
another" is a test to write.
|
||||||
|
|
||||||
|
**4. Sort every finding into a bucket.**
|
||||||
|
|
||||||
|
| bucket | what it means | what to do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Prior art** | Someone built this | Read it properly, and start from where they ended. |
|
||||||
|
| **Corroboration** | They hold the same premise | Cite it, stop re-deriving it, and stop treating it as a differentiator. |
|
||||||
|
| **Their control** | They defend something dejima does not | Take the *distinction* if it is real. Do not take the *control* without asking what threat model produced it. |
|
||||||
|
| **Contradiction** | Their default is the opposite | The most valuable bucket. Work out whether the threat models differ before conceding or dismissing. |
|
||||||
|
| **Open ground** | Nobody does this | Say so, and say *why*. Usually a constraint others do not have; occasionally it means everyone else knows something. |
|
||||||
|
|
||||||
|
**5. Write down the contradiction even when it resolves in dejima's favour.**
|
||||||
|
The default-network difference above is the worked example: it reads as dejima
|
||||||
|
being ahead, and the reason turned out to be scope rather than rigour.
|
||||||
|
Recording the reason is what stops a different scope being mistaken for a
|
||||||
|
weaker one - by us, in six months, reading our own note.
|
||||||
|
|
||||||
|
**6. Turn every finding that changes a control into an issue.** A review that
|
||||||
|
ends in a document has not finished.
|
||||||
|
|
||||||
|
## What to record, and where it does not go
|
||||||
|
|
||||||
|
**A sweep is not committed here.** It is written to the shared mount on the
|
||||||
|
operator's machine, alongside the specs, plans and design notes that live
|
||||||
|
there for the same reason: a review is ephemeral, and what belongs in this
|
||||||
|
repository is the *action* it produced, not the reasoning that produced it.
|
||||||
|
|
||||||
|
What lands here is the outcome - an issue, a pull request, a paragraph in the
|
||||||
|
documentation section the finding actually bears on. This page is itself an
|
||||||
|
example: the August 2026 sweep produced a matcher fix, a
|
||||||
|
[not-sandboxed list](security.md#what-dejima-does-not-sandbox), a
|
||||||
|
[statement of which deployment the threat model describes](threat-model.md#which-deployment-this-describes),
|
||||||
|
and a stated non-goal on timeouts. The sweep that produced them stayed on the
|
||||||
|
operator's machine.
|
||||||
|
|
||||||
|
The sweep itself is one page, dated and sourced, in the house style, written
|
||||||
|
where the other ephemeral documents go. Two rules specific to reviews, because
|
||||||
|
both distinctions vanish within a week of writing:
|
||||||
|
|
||||||
|
- **Mark what was read in full and what came from a search summary.** A summary
|
||||||
|
of a bypass is enough to write a test case from and not enough to claim
|
||||||
|
dejima is safe from it.
|
||||||
|
- **Quote claims with their conditions.** "Containers are inadequate" is false
|
||||||
|
without "for untrusted code, where the host kernel is the boundary". The
|
||||||
|
honesty rules here apply to what is read as much as to what is written.
|
||||||
|
|
||||||
|
## The standing test queue
|
||||||
|
|
||||||
|
A bypass from the literature is a hypothesis about *their* implementation until
|
||||||
|
it is run against this one. dejima differs in ways that plausibly change the
|
||||||
|
answer: enforcement is `iptables` and a DNS proxy rather than an in-process
|
||||||
|
proxy, the matcher is an anchored shell regex rather than a string-suffix test,
|
||||||
|
the allowlist matches whole domains rather than suffixes, and the ipset is
|
||||||
|
populated from the answer the resolver returned rather than from a second parse
|
||||||
|
of a string.
|
||||||
|
|
||||||
|
Each entry states a prediction that can fail.
|
||||||
|
|
||||||
|
- **Hostile hostnames against `valid_domain`.** Null bytes, percent-encoding,
|
||||||
|
trailing dots, embedded newlines, IDNA, empty labels. Prediction: all refused.
|
||||||
|
*Run 2026-08-25: all refused except an empty label, which was accepted and
|
||||||
|
has since been fixed.*
|
||||||
|
- **A published SOCKS-style parser differential, run end to end** rather than
|
||||||
|
against the validator alone. Prediction: no path, because nothing in dejima
|
||||||
|
speaks SOCKS and the decision and the dial are bound by the ipset. Not yet
|
||||||
|
run.
|
||||||
|
- **Redirect chains to a host outside the allowlist.** Prediction: fail at the
|
||||||
|
ipset, because dejima does not mediate HTTP and the connection is judged by
|
||||||
|
address. Not yet run.
|
||||||
+83
-117
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
[← dejima README](../README.md)
|
[← dejima README](../README.md)
|
||||||
|
|
||||||
The README states each of these ten rules in a sentence or two. This is the
|
The README states each of these rules in a sentence or two. This is the part
|
||||||
part that didn't fit: why each one matters, what goes wrong without it, and
|
that didn't fit: why each one matters, and what goes wrong without it.
|
||||||
the detail behind the three the README could only gesture at.
|
|
||||||
|
|
||||||
## Check whether they want it first.
|
## Check whether they want it first.
|
||||||
|
|
||||||
@@ -33,45 +32,37 @@ The concrete workflow, step by step:
|
|||||||
6. Open the upstream pull request by hand from there, once you would have
|
6. Open the upstream pull request by hand from there, once you would have
|
||||||
been glad to have written it yourself.
|
been glad to have written it yourself.
|
||||||
|
|
||||||
The reason for the extra hop is what happens without it. An auto-push
|
An auto-push workflow aimed straight at someone else's repository puts a
|
||||||
workflow aimed straight at someone else's repository puts a session's raw
|
session's raw output into that project's pull request queue, unreviewed by
|
||||||
output directly into that project's pull request queue, unreviewed by
|
|
||||||
anyone, the moment it opens. Maintainers already spend real time triaging a
|
anyone, the moment it opens. Maintainers already spend real time triaging a
|
||||||
flood of low-effort AI-generated pull requests; a Godot maintainer's account
|
flood of low-effort AI-generated pull requests; a Godot maintainer's account of
|
||||||
of what that actually costs is worth reading in full:
|
what that costs is worth reading in full:
|
||||||
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
|
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking first
|
||||||
reviewing first doesn't change what the session is capable of writing. It
|
doesn't change what the session is capable of writing. It changes who reads the
|
||||||
changes who reads the output before anyone else has to: you, not a stranger
|
output before anyone else has to: you, not a stranger who owns the project.
|
||||||
who owns the project.
|
|
||||||
|
|
||||||
## Don't point it at proprietary code.
|
## Don't point it at proprietary code.
|
||||||
|
|
||||||
dejima narrows the paths code has to leave through; it doesn't close them,
|
dejima narrows the paths code has to leave through; it doesn't close them.
|
||||||
and "point it only at code you could survive losing" is the practical test
|
Ask it plainly before you start a session, not after: if this checkout's
|
||||||
for that gap. Ask it plainly before you start a session, not after: if this
|
contents ended up somewhere they shouldn't, would that be a bad week or the end
|
||||||
checkout's contents ended up somewhere they shouldn't, would that be a bad
|
of the business? Code you could survive losing may be worth the convenience of
|
||||||
week or the end of the business? Code you could survive losing may be worth
|
unattended agentic work. Code you couldn't shouldn't go in at all.
|
||||||
the convenience of unattended agentic work. Code you couldn't shouldn't go in
|
|
||||||
at all.
|
|
||||||
|
|
||||||
That second one is not a judgement about how good the containment is, and it
|
That is not a judgement about how good the containment is, and it does not get
|
||||||
does not get better with a version bump. There is no containment that fully
|
better with a version bump. No containment fully prevents exfiltration: a
|
||||||
prevents exfiltration: a determined attacker with code execution and any
|
determined attacker with code execution and any outbound path will find a way.
|
||||||
outbound path will find a way. So the question is not whether you trust
|
The question is not whether you trust dejima enough, but whether you can accept
|
||||||
dejima enough, but whether you can accept the risk of the contents leaving.
|
the risk of the contents leaving.
|
||||||
If you cannot, do not hand them to an agent that could send them.
|
|
||||||
|
|
||||||
## Look at what else is in the directory.
|
## Look at what else is in the directory.
|
||||||
|
|
||||||
The bind mount doesn't know which files you meant to hand over. It exposes
|
The bind mount doesn't know which files you meant to hand over. Everything a
|
||||||
the whole checkout, and everything a working tree accumulates over its life
|
working tree accumulates sits there with the same reachability as the source
|
||||||
sits there with the same reachability as the source files you actually care
|
you actually care about: a `.env` a teammate left uncommitted, a database dump
|
||||||
about: a `.env` a teammate left uncommitted, a database dump pulled down for
|
pulled down for local debugging and never deleted, a credential baked into a
|
||||||
local debugging and never deleted, a credential baked into a fixture file,
|
fixture, an old key on a branch that got checked out. All of it is bytes in a
|
||||||
an old key still sitting in the history of a branch that got checked out.
|
directory the session can read and any tool call it makes can touch.
|
||||||
None of that is code in any sense that matters to the agent; all of it is
|
|
||||||
just bytes in a directory the session can read and any tool call it makes
|
|
||||||
can touch.
|
|
||||||
|
|
||||||
Auditing a working tree by eye is not a minute's work, and it is the kind of
|
Auditing a working tree by eye is not a minute's work, and it is the kind of
|
||||||
check that succeeds every time until the once it matters. **Point dejima at a
|
check that succeeds every time until the once it matters. **Point dejima at a
|
||||||
@@ -82,25 +73,22 @@ directory not to hand over.
|
|||||||
|
|
||||||
## An allowlist entry is a grant, not a convenience.
|
## An allowlist entry is a grant, not a convenience.
|
||||||
|
|
||||||
For every allowlist entry you give a dejima agent, consider what an attacker
|
For every allowlist entry, consider what an attacker could do with it. The
|
||||||
could do with it. The answer varies enormously between entries that look
|
answer varies enormously between entries that look similar, and what it turns
|
||||||
similar, and what it turns on is what happens if the call is wrong, whether
|
on is what happens if the call is wrong, whether wrong means a prompt-injected
|
||||||
wrong means a prompt-injected instruction or an ordinary agent mistake.
|
instruction or an ordinary agent mistake.
|
||||||
|
|
||||||
A package registry or a docs site can be wrong without being expensive. A
|
A package registry or a docs site can be wrong without being expensive. A
|
||||||
session that fetches the wrong package version, or scrapes a stale or
|
session that fetches the wrong package version, or scrapes a misleading page,
|
||||||
misleading page, produces bad input to more code, code that still has to
|
produces bad input to more code that still has to pass your own review. The
|
||||||
pass through your own review before any of it ships. The mistake is
|
mistake is contained by the same diff you were always going to read.
|
||||||
contained by the same diff you were always going to read.
|
|
||||||
|
|
||||||
A deploy webhook, a production database API, and a payment provider are a
|
A deploy webhook, a production database API and a payment provider are a
|
||||||
different shape entirely. A single call against any of them is the action,
|
different shape entirely. A single call against any of them *is* the action,
|
||||||
not a step toward one you'll review later: the deployment already ran, the
|
not a step toward one you'll review later: the deployment already ran, the row
|
||||||
row is already written or gone, the charge already happened. There is no
|
is already gone, the charge already happened. Nothing about it passes through
|
||||||
diff downstream that catches it, because nothing about it passes through
|
your repository, so no diff downstream catches it. Allowlisting one gives an
|
||||||
your repository at all. Allowlisting one of these gives an unattended process
|
unattended process standing authority to act on the world.
|
||||||
standing authority to act on the world, the same authority you would think
|
|
||||||
twice about handing a person.
|
|
||||||
|
|
||||||
Ask, before adding an entry: if this call happened by mistake, is the result
|
Ask, before adding an entry: if this call happened by mistake, is the result
|
||||||
something I fix in the next commit, or something I have to explain to
|
something I fix in the next commit, or something I have to explain to
|
||||||
@@ -114,21 +102,14 @@ contained perfectly and still produce something that should not be merged,
|
|||||||
and the sandbox has no opinion either way.
|
and the sandbox has no opinion either way.
|
||||||
|
|
||||||
This point is about the merge, not about code quality in general. What makes
|
This point is about the merge, not about code quality in general. What makes
|
||||||
`main` (or `master`, or whatever your default branch is called) different
|
your default branch different from any other is that other things read it and
|
||||||
from any other branch is that other things read it and believe it. It is
|
believe it. Concretely: dejima-ci-hardener resolves the CI-side egress
|
||||||
worth being precise about why, rather than saying "the released version" in
|
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never from a
|
||||||
some vaguer sense. In this repository, dejima-ci-hardener resolves the
|
branch or a local checkout, and treats whatever it finds there as already
|
||||||
CI-side egress
|
reviewed. That trust is load-bearing, and it is only justified because the one
|
||||||
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
|
way content lands on `main` is through a pull request you merged. Keeping
|
||||||
from a branch or a local checkout, and treats whatever it finds there as
|
insecure or broken code off it is your job; nothing about running the agent in
|
||||||
already reviewed. That trust is load-bearing: the only way content lands on
|
a sandbox does it for you.
|
||||||
`main` is through a pull request you merged, so reading from `main` is how
|
|
||||||
the tool avoids asking a branch what it's allowed to do. So merging onto
|
|
||||||
`main` ships it to users and feeds a trust anchor that other tooling, this
|
|
||||||
one and potentially future ones, is entitled to rely on without re-checking.
|
|
||||||
Keeping insecure or broken code off `main` is what makes that reliance
|
|
||||||
justified, and that job is yours; nothing about running the agent in a
|
|
||||||
sandbox does it for you.
|
|
||||||
|
|
||||||
## Give it its own identity, then own what you merge.
|
## Give it its own identity, then own what you merge.
|
||||||
|
|
||||||
@@ -152,32 +133,27 @@ it accordingly.
|
|||||||
|
|
||||||
## Understand what you ship.
|
## Understand what you ship.
|
||||||
|
|
||||||
The risk here is habituation, not a single lapse. dejima makes iterating
|
The risk here is habituation, not a single lapse. dejima makes iterating cheap
|
||||||
cheap enough that reading every diff starts to feel like the slow part of
|
enough that reading every diff starts to feel like the slow part of the loop,
|
||||||
the loop, and skipping it works fine, repeatedly, until the one time it
|
and skipping it works fine, repeatedly, until the one time it doesn't. A
|
||||||
doesn't. A reviewer who reads every change catches most of what's wrong with
|
reviewer who only reads the changes that felt risky catches none of the ones
|
||||||
it. A reviewer who only reads the ones that felt risky catches none of the
|
that didn't feel risky, which is the shape a subtle bug, or a prompt-injected
|
||||||
ones that didn't feel risky, which is the shape a subtle bug, or a
|
change dressed up as something ordinary, takes. Nothing about a faster loop
|
||||||
prompt-injected change dressed up as something ordinary, takes. Nothing
|
changes what engineering judgement is for.
|
||||||
about a faster loop changes what engineering judgment is for.
|
|
||||||
|
|
||||||
## This is not a blastproof box.
|
## This is not a blastproof box.
|
||||||
|
|
||||||
Read this rule as a statement about what kind of failure to plan for.
|
This is a statement about what kind of failure to plan for. dejima's
|
||||||
dejima's containment is Linux namespaces and a small capability set around
|
containment is Linux namespaces and a small capability set around the session,
|
||||||
the session, the same kind of isolation a container gives any process, not a
|
not a virtual machine boundary; it shares the host kernel with everything else
|
||||||
virtual machine boundary; it shares the host kernel with everything else on
|
on the machine. That buys real things (see [docs/security.md](security.md): no
|
||||||
the machine.
|
working `sudo`, a deny-by-default firewall, read-only mounts over the surfaces
|
||||||
That buys real things (see [docs/security.md](security.md) for the specific
|
that matter most), and every one of them assumes the container stays intact. A
|
||||||
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
|
container-escape vulnerability doesn't erode that protection gradually, it
|
||||||
the surfaces that matter most), and every one of them assumes the container
|
removes every layer at once, because they all sit on the same kernel. So the
|
||||||
itself stays intact. A determined attacker, or a single container-escape
|
assumption to hold is not that the walls will probably hold: it is that if this
|
||||||
vulnerability, doesn't erode that protection gradually; it removes every
|
container is ever compromised at the kernel level, everything reachable from
|
||||||
layer described in that document at once, because they all sit on the same
|
this host account should be treated as compromised with it.
|
||||||
kernel. So the assumption to hold is not that the walls will probably hold.
|
|
||||||
It is that if this container is ever compromised at the kernel level,
|
|
||||||
everything reachable from this host account should be treated as compromised
|
|
||||||
with it, which is a very different thing to plan around than an ordinary bug.
|
|
||||||
|
|
||||||
What "everything reachable from this host account" amounts to is not fixed,
|
What "everything reachable from this host account" amounts to is not fixed,
|
||||||
and it is the one part of this that can be narrowed from outside. Bounding
|
and it is the one part of this that can be narrowed from outside. Bounding
|
||||||
@@ -190,34 +166,24 @@ would have to be bounded.
|
|||||||
## dejima's security says nothing about your software's.
|
## dejima's security says nothing about your software's.
|
||||||
|
|
||||||
These are different axes, not different degrees of the same thing. dejima's
|
These are different axes, not different degrees of the same thing. dejima's
|
||||||
threat model is about the session: what a process running inside the
|
threat model is about the session: what a process running inside the sandbox
|
||||||
sandbox can reach, exfiltrate, or execute while it runs. None of that has
|
can reach, exfiltrate, or execute while it runs. It can contain a session
|
||||||
anything to say about the software that session writes once it's on disk.
|
perfectly and that session can still write a SQL injection, hand-roll a broken
|
||||||
dejima can contain a session perfectly and that session can still write a
|
authentication check, hardcode a secret, or pull in a compromised dependency.
|
||||||
SQL injection, hand-roll a broken authentication check, hardcode a secret,
|
Those are properties of the code, decided when it is written.
|
||||||
or pull in a compromised dependency, because those are properties of the
|
|
||||||
code itself, decided at the moment it's written, not something a sandbox
|
|
||||||
sitting around the process that wrote it can retrofit afterward. If what got
|
|
||||||
built inside dejima turns out to be insecure, containment was never the
|
|
||||||
control that was supposed to catch it.
|
|
||||||
|
|
||||||
**It is not something a future agent can retrofit either.** "We will do a
|
**It is not something a future agent can retrofit either.** Early
|
||||||
security pass later" was already a weak plan before agents made it cheap to
|
architectural decisions close off the fixes for whole classes of problem: where
|
||||||
say. Early architectural decisions
|
trust boundaries sit, what is authenticated and where, what data crosses which
|
||||||
close off the fixes for whole classes of problem: where trust boundaries sit,
|
process, what a single compromised component reaches. A later pass can find a
|
||||||
what is authenticated and where, what data crosses which process, what a
|
missing bounds check. It cannot move a boundary you built a system on top of.
|
||||||
single compromised component reaches. A later pass can find a missing bounds
|
And insecure implementation decisions sit in software for years without
|
||||||
check. It cannot move a boundary you built a system on top of. And insecure
|
announcing themselves, which is exactly why a pass that goes looking once does
|
||||||
implementation decisions sit in software for years without announcing
|
not find them.
|
||||||
themselves, which is exactly why they are not found by a pass that goes
|
|
||||||
looking once.
|
|
||||||
|
|
||||||
So security has to be considered at every point in the work, not scheduled
|
So security has to be considered at every point in the work rather than
|
||||||
as a phase, and dejima will not help you do that. It makes writing a lot of
|
scheduled as a phase, and dejima will not help you do that. It makes writing a
|
||||||
code fast, which makes writing a lot of insecure code fast if nobody is
|
lot of code fast, which makes writing a lot of insecure code fast if nobody is
|
||||||
holding that concern. That responsibility is yours at every step, and it is
|
holding that concern. Review, linting, dependency scanning and tests aimed at
|
||||||
the one thing here no tooling in this repository is aiming at.
|
the failure modes you care about all still have to happen; dejima stands in for
|
||||||
|
none of them.
|
||||||
The same practices you'd apply to code from any other source, review,
|
|
||||||
linting, dependency scanning, tests aimed at the failure modes you care
|
|
||||||
about, still have to happen here. dejima does not stand in for any of them.
|
|
||||||
|
|||||||
+309
-174
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
**Threat model, honestly stated:** this reduces the blast radius of a
|
**Threat model, honestly stated:** this reduces the blast radius of a
|
||||||
misbehaving or prompt-injected coding agent - it does not make it safe to
|
misbehaving or prompt-injected coding agent - it does not make it safe to
|
||||||
point at arbitrary untrusted or adversarial input, and it does not turn
|
point at arbitrary untrusted or adversarial input, and it does not make
|
||||||
`--dangerously-skip-permissions` into something without real risk. Point it
|
running an agent with its permission prompts off safe. Point it
|
||||||
at repositories you already trust. Everything below is about how bad a
|
at repositories you already trust. Everything below is about how bad a
|
||||||
compromised session *inside* that trust boundary can get, not a claim that
|
compromised session *inside* that trust boundary can get, not a claim that
|
||||||
one can't happen.
|
one can't happen.
|
||||||
@@ -16,6 +16,69 @@ person thought to check, and no outside audit or adversarial testing has
|
|||||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||||
|
|
||||||
|
## What dejima does not sandbox
|
||||||
|
|
||||||
|
Every item here is documented somewhere below, and a document organised by
|
||||||
|
control makes a reader assemble the negative space themselves. Most will not.
|
||||||
|
So it is collected here, at the top, where it is the first thing read rather
|
||||||
|
than the thing inferred from silence.
|
||||||
|
|
||||||
|
- **Exfiltration through a domain that is allowed.** The egress allowlist
|
||||||
|
decides *where* a session may connect, not what it may put in the request. A
|
||||||
|
session that can reach a host can encode data into a path or a query it
|
||||||
|
sends there. Allowing a domain at all creates that channel.
|
||||||
|
- **Data encoded into DNS query names.** The DNS proxy answers for allowlisted
|
||||||
|
domains; the names asked for are a channel of their own.
|
||||||
|
- **Anything below the container, where nothing is arranged there.** dejima's
|
||||||
|
blast-radius table has rows beneath the container, and their controls belong
|
||||||
|
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
|
||||||
|
Run on a laptop with no VM layer, the row below the container is the
|
||||||
|
operator's own machine, and dejima has nothing to say about it. See
|
||||||
|
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||||
|
- **Anything finer than a session.** The unit of isolation is one container for
|
||||||
|
one session. An MCP server, a hook, or a tool the session runs inherits the
|
||||||
|
session's whole reach - dejima reports that a session *added* an MCP server
|
||||||
|
(see the audit surfaces below) and does not constrain what that server then
|
||||||
|
does. Per-tool granularity would need a second sandbox inside this one.
|
||||||
|
- **Resource exhaustion.** No time limit, no memory limit, no CPU limit. A
|
||||||
|
session that loops until someone notices is not prevented, and this is a
|
||||||
|
deliberate omission rather than an oversight - see
|
||||||
|
[Why there are no timeouts](#why-there-are-no-timeouts).
|
||||||
|
- **A suspended job. Ctrl-Z stops the observer, not the session.** It
|
||||||
|
suspends `bin/dejima` on the host; the container keeps running, with its
|
||||||
|
mounts, its firewall grants and its forge token, and keeps making requests
|
||||||
|
while the terminal looks stopped. That is deliberate - SIGSTOP mid-tool-call
|
||||||
|
leaves a session wherever it was, and the feature exists to get the terminal
|
||||||
|
back rather than to pause the work - but a stopped job reads as a paused
|
||||||
|
agent, and the difference matters most to whoever suspends one because they
|
||||||
|
are uneasy about what it is doing.
|
||||||
|
- **The first layer's controls against an agent that has stopped
|
||||||
|
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
|
||||||
|
cooperating agent does and stop nothing that has decided otherwise. Only the
|
||||||
|
layers below them are enforcement.
|
||||||
|
|
||||||
|
## Why there are no timeouts
|
||||||
|
|
||||||
|
Every published checklist for sandboxing an agent lists timeouts alongside
|
||||||
|
non-root, egress filtering and read-only mounts. dejima has none, and the
|
||||||
|
reason is a judgement about what dejima is for rather than an item not yet
|
||||||
|
reached.
|
||||||
|
|
||||||
|
**A runaway session costs money and does not cross the boundary.** It burns
|
||||||
|
tokens and holds a CPU until someone looks. Nothing about it reaches the host,
|
||||||
|
widens the allowlist, or writes where a session may not write - the guarantees
|
||||||
|
dejima makes are all still held while it happens. A timeout would protect a
|
||||||
|
budget, which dejima has never claimed to protect.
|
||||||
|
|
||||||
|
**And "unattended" here does not mean "unobserved".** The sessions this is
|
||||||
|
built for run for days, and are checked at every issue and every pull request.
|
||||||
|
They need less babysitting, not none. A wall clock tuned for that is either so
|
||||||
|
long it prevents nothing or short enough to kill work in progress, and killing
|
||||||
|
a session mid-commit is a failure mode of its own.
|
||||||
|
|
||||||
|
If this changes it will be because a resource limit starts protecting the
|
||||||
|
boundary rather than the bill.
|
||||||
|
|
||||||
## Blast radius by layer
|
## Blast radius by layer
|
||||||
|
|
||||||
Where each layer's compromise stops, with and without the thing arranged
|
Where each layer's compromise stops, with and without the thing arranged
|
||||||
@@ -195,13 +258,27 @@ checkout itself and needs no symlink to edit, so a session with write access
|
|||||||
to this repo's own checkout can change what the next launch composes and
|
to this repo's own checkout can change what the next launch composes and
|
||||||
hands to whichever session starts against it.
|
hands to whichever session starts against it.
|
||||||
|
|
||||||
The provider directories are in the exit-time audit's TCB list for that
|
**Which requires dejima's own checkout to be the workspace, and almost never
|
||||||
reason, alongside `bin/`, `hooks/`, `docker/` and `test/`. They hold no code
|
is.** Nothing mounts `forges/`, `workflows/`, `harnesses/`, `tools/` or the
|
||||||
and nothing executes them, but what they hold decides what the next launch
|
rest of the checkout into a container - the resolved values go in, the files
|
||||||
permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`, which
|
do not - so for every launch pointed at some other project, a session cannot
|
||||||
`entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so a
|
read these paths, let alone write them. The rest of this section is about the
|
||||||
line added to a manifest widens the egress boundary on the next run. An
|
one configuration where it can: dejima sandboxing the repository it is itself
|
||||||
ordinary edit is a diff you are about to read and is not reported; one git
|
installed from, which is how dejima is developed. `bin/dejima` says so at
|
||||||
|
launch when it notices.
|
||||||
|
|
||||||
|
In that configuration the provider directories are in the exit-time audit's
|
||||||
|
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
|
||||||
|
hold no code and nothing executes them, but what they hold decides what the
|
||||||
|
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
|
||||||
|
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
|
||||||
|
a line added to a manifest widens the egress boundary on the next run.
|
||||||
|
`tools/*/tool.conf` is the same class, and is read by the host rather than the
|
||||||
|
container: it names a tarball URL, the sha256 checked against it and the
|
||||||
|
layout it is unpacked with, all consumed by the image build - so a session
|
||||||
|
that edits one chooses what the next launch downloads and installs as root.
|
||||||
|
|
||||||
|
An ordinary edit is a diff you are about to read and is not reported; one git
|
||||||
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
|
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
|
||||||
or `skip-worktree` - is a CRITICAL finding. It is still a change a session
|
or `skip-worktree` - is a CRITICAL finding. It is still a change a session
|
||||||
can make; it is one the audit reports. See
|
can make; it is one the audit reports. See
|
||||||
@@ -209,8 +286,8 @@ can make; it is one the audit reports. See
|
|||||||
|
|
||||||
## The mounted project is fully writable
|
## The mounted project is fully writable
|
||||||
|
|
||||||
- `--dangerously-skip-permissions` removes your chance to review tool calls
|
- Running the harness with its permission prompts off removes your chance to
|
||||||
before they run. Only point this at trusted repositories.
|
review tool calls before they run. Only point this at trusted repositories.
|
||||||
- The mounted project itself has **no write protection**. `deny-dangerous-
|
- The mounted project itself has **no write protection**. `deny-dangerous-
|
||||||
bash.sh`, `git-push-guard.sh` and `merge-guard.sh` are regex
|
bash.sh`, `git-push-guard.sh` and `merge-guard.sh` are regex
|
||||||
pattern-matching on the literal Bash command string - a fast-fail guard
|
pattern-matching on the literal Bash command string - a fast-fail guard
|
||||||
@@ -242,178 +319,180 @@ can make; it is one the audit reports. See
|
|||||||
either, but only after the session ends - it doesn't stop them.
|
either, but only after the session ends - it doesn't stop them.
|
||||||
## The exit-time audit
|
## The exit-time audit
|
||||||
|
|
||||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
|
||||||
run and diffs them after the container exits: `.git/config` (and worktree
|
diffs them after the container exits. It is **advisory**: it fires after the
|
||||||
`config.worktree`) keys that run a command on the next `git` -
|
session ended, so a planted `core.hooksPath` is something to clean up before
|
||||||
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
the next `git` or `claude` in that repo, not something that was blocked. It is
|
||||||
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
|
silent when nothing changed and never affects the session's exit code.
|
||||||
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
|
|
||||||
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
|
||||||
`include.path`/`includeif.*.path` (config indirection - these can point at
|
|
||||||
a planted file that itself sets an exec key, so the exec key never
|
|
||||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
|
||||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
|
||||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
|
||||||
hashed by its target, not just its content). When the mounted workspace is
|
|
||||||
this repo itself, it also hashes the sandbox's own trusted computing base -
|
|
||||||
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
|
|
||||||
|
|
||||||
Worth knowing about that first surface, because the sandbox's own workflow
|
**Its diff is time-windowed, not session-attributed:** a finding means a path
|
||||||
makes it easy to get wrong: **a git worktree isolates the working tree, not
|
changed while the session ran, not that the session changed it. A concurrent
|
||||||
`.git/config`.** Config is shared with the parent checkout, so a bare
|
session, another host process, or your own editing all land in the same diff,
|
||||||
`git config <key> <value>` inside a worktree writes the real repository's
|
reported identically.
|
||||||
config, including any exec key above. The briefing now says so, and gives
|
|
||||||
`git -C <path> config`. A guard against an unaware agent, not a boundary:
|
|
||||||
`.git/config` is in the read-write project mount and cannot be read-only
|
|
||||||
without breaking `git push -u`, which writes `branch.<name>.merge` on every
|
|
||||||
push. Detection covers the rest, and did - this was found by the audit
|
|
||||||
reporting a `.git/config` change.
|
|
||||||
|
|
||||||
**Advisory only.** It fires after the session ended, so a planted
|
### What it watches
|
||||||
`core.hooksPath` is something to clean up before the next `git` or
|
|
||||||
`claude` in that repo, not something that was blocked. Silent when nothing
|
|
||||||
changed, and never affects the session's exit code. It does not cover
|
|
||||||
`[core] share_dir` (see
|
|
||||||
[Shared scratch space](configuration.md#shared-scratch-space)), and its
|
|
||||||
coverage is a fixed list rather than a general integrity check, so a config
|
|
||||||
surface it has not been taught about goes unreported.
|
|
||||||
|
|
||||||
**Its diff is time-windowed, not session-attributed:** a finding means a
|
`.git/config` (and worktree `config.worktree`) keys that run a command on the
|
||||||
path changed while the session ran, not that the session changed it. A
|
next `git` - `core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
||||||
concurrent session, another host process, or your own editing all land in
|
`credential.helper`, `core.pager`, `pager.*`, `core.editor`, `sequence.editor`,
|
||||||
the same diff, reported identically.
|
`gpg.program`, `diff.external`, `merge.*.driver`, `diff.*.command`,
|
||||||
- **Approving and ignoring are different decisions, and the difference is
|
`filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
||||||
what you are promising about next time.** Approving records the exact
|
`include.path`/`includeif.*.path` (config indirection - these can point at a
|
||||||
content: `safe <id> <sha>`, so this file with these bytes is fine and any
|
planted file that itself sets an exec key, so the exec key never appears in
|
||||||
other content re-fires. Ignoring records the surface: `spam <id>`, so this
|
`.git/config` directly), `!`-shell aliases - plus content changes under the
|
||||||
path or key is fine whatever it later contains. Approve a hook you have
|
workspace's `.claude/`, `.mcp.json`, `.envrc`, `.vscode/`, and `.git/hooks/`
|
||||||
read; ignore a key some tool rewrites on every run and which you have
|
(symlinks included - a repointed symlink is hashed by its target, not just its
|
||||||
decided you will never care about. The review offers both, and says which
|
content). When the mounted workspace is this repo itself, it also hashes the
|
||||||
is which rather than leaving you to infer it:
|
sandbox's own trusted computing base: `bin/`, `hooks/`, `docker/`,
|
||||||
|
`install.sh`, `test/`.
|
||||||
|
|
||||||
```
|
Coverage is a fixed list rather than a general integrity check, so a config
|
||||||
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
surface it has not been taught about goes unreported. It does not cover
|
||||||
```
|
`[core] share_dir` (see
|
||||||
|
[Shared scratch space](configuration.md#shared-scratch-space)).
|
||||||
|
|
||||||
A finding whose file has since been deleted is **cleared** rather than
|
**A git worktree isolates the working tree, not `.git/config`.** Config is
|
||||||
approved: there is no content left to pin an approval to, and a surface
|
shared with the parent checkout, so a bare `git config <key> <value>` inside a
|
||||||
that does not exist does not run. Nothing is recorded, so if it comes back
|
worktree writes the real repository's config, including any exec key above. The
|
||||||
it comes back as a new finding. Refusing would leave the report
|
briefing says so, and gives `git -C <path> config`. That is a guard against an
|
||||||
unresolvable: `ack` opens CRITICAL reports first, so every other report
|
unaware agent, not a boundary: `.git/config` is in the read-write project mount
|
||||||
would hide behind one no decision could clear.
|
and cannot be read-only without breaking `git push -u`, which writes
|
||||||
|
`branch.<name>.merge` on every push. Detection covers the rest, and did - this
|
||||||
|
was found by the audit reporting a `.git/config` change.
|
||||||
|
|
||||||
Skipping asks again next session, the right default when you are unsure.
|
### Approving and ignoring are different decisions
|
||||||
It matters for keys some tools rewrite on every run: with no decision
|
|
||||||
recorded, one re-fires every session and pushes real findings out of the
|
|
||||||
capped list.
|
|
||||||
|
|
||||||
Severity is not negotiable by either verb. An exec-capable key or shell
|
The difference is what you are promising about next time. Approving records the
|
||||||
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
|
exact content (`safe <id> <sha>`), so this file with these bytes is fine and any
|
||||||
refused against a CRITICAL surface when the baseline is read, not only when
|
other content re-fires. Ignoring records the surface (`spam <id>`), so this path
|
||||||
written - so neither decision can quietly downgrade something that matters.
|
or key is fine whatever it later contains. Approve a hook you have read; ignore
|
||||||
|
a key some tool rewrites on every run and which you have decided you will never
|
||||||
|
care about. The review offers both, and says which is which:
|
||||||
|
|
||||||
- `dejima-audit ack` shows what it is asking you to approve. For a surface
|
```
|
||||||
that changed during the session it prints a unified diff of the
|
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
||||||
pre-session content against the current content; for one created during
|
```
|
||||||
the session it prints the whole current content. To make that possible,
|
|
||||||
`snapshot` stages a copy of each monitored surface's bytes for the
|
|
||||||
duration of the session, and `compare` turns those into **bounded
|
|
||||||
diffs** stored in a `<report>.d/` directory beside the report. What
|
|
||||||
persists after the session is only the diff of a *modified* surface -
|
|
||||||
never a whole file. A created surface stores a marker (its content is
|
|
||||||
live on disk); a removed one stores nothing at all, because a deletion
|
|
||||||
diff *is* the whole former file.
|
|
||||||
|
|
||||||
What it prints is chosen by what changed, which is not always what you
|
Skipping asks again next session, the right default when you are unsure. It
|
||||||
need in order to judge it - three changed lines in a hook script are only
|
matters for keys some tools rewrite on every run: with no decision recorded, one
|
||||||
judgeable by reading the script. `[o]` at the prompt opens the file in
|
re-fires every session and pushes real findings out of the capped list.
|
||||||
`$VISUAL`/`$EDITOR`, read-only where the editor understands how, and
|
|
||||||
returns to the same decision. It opens the file **as it is now**, not the
|
|
||||||
text the report captured, for the same reason an approval pins the content
|
|
||||||
that is on disk at the moment it is recorded: approving something nobody
|
|
||||||
read would be the one outcome worse than not asking.
|
|
||||||
|
|
||||||
Staging and retained diffs live under `$AUDIT_DIR`
|
A report walked to the end stops being counted as waiting, but stays on disk as
|
||||||
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
|
the record of what that session did. `[audit] keep` decides how many are
|
||||||
scheme rests on: nothing in `docker-compose.yml` covers it, so the
|
retained; rotation evicts one, `ack` never does.
|
||||||
container cannot read earlier runs' retained content, and `static.bats`
|
|
||||||
pins that. A store configured *inside* the workspace is readable and
|
|
||||||
deletable from the sandbox, so nothing is retained in that case - only a
|
|
||||||
note saying why.
|
|
||||||
|
|
||||||
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
|
A finding whose file has since been deleted is **cleared** rather than approved:
|
||||||
the next run against that workspace reaps it, identifying dead owners by
|
there is no content left to pin an approval to, and a surface that does not
|
||||||
pid and process start time from an `.owner` file so a concurrent session is
|
exist does not run. Nothing is recorded, so if it comes back it comes back as a
|
||||||
never mistaken for dead. A missing or unreadable owner record falls back to
|
new finding. Refusing would leave the report unresolvable, since `ack` opens
|
||||||
a 24-hour age check rather than guessing.
|
CRITICAL reports first.
|
||||||
|
|
||||||
Content is withheld from the store in several further cases, and in
|
Severity is not negotiable by either verb. An exec-capable key or shell alias is
|
||||||
every one `ack` prints the reason in place of the diff - so an absent
|
CRITICAL whatever the baseline says, and an `ignore` entry is refused against a
|
||||||
diff is never mistaken for an absent change - and still shows the live
|
CRITICAL surface when the baseline is read, not only when written - so neither
|
||||||
current content on your terminal, because withholding is about the
|
decision can quietly downgrade something that matters.
|
||||||
persistent store, not about what you are shown while deciding. Surfaces
|
|
||||||
that hold credentials by convention are never staged in the first
|
|
||||||
place, so their bytes are never even copied transiently: `.envrc`,
|
|
||||||
`.mcp.json` and `.claude/settings.local.json` by exact path, and
|
|
||||||
`.env`/`.env.*` by *basename*, at any depth - so a nested `.claude/.env`
|
|
||||||
is caught too, not just one at the workspace root. A surface that
|
|
||||||
existed before the session but was too large to have been staged (over
|
|
||||||
1 MiB) withholds with "no pre-session copy", not a false claim that it
|
|
||||||
is new. The *current* side is bounded by that same 1 MiB cap, and - like
|
|
||||||
the staged side - checked *before* any copy is taken: a file the session
|
|
||||||
grew past it withholds with "change too large to retain" rather than
|
|
||||||
being copied through `$TMPDIR` first. The ordering is the point, not the
|
|
||||||
cap. `compare` runs from the exit trap on every exit and the size is the
|
|
||||||
session's own choice, so copying first would turn "I filled my own
|
|
||||||
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
|
|
||||||
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
|
|
||||||
A generated diff is withheld if it is binary, over 200 lines
|
|
||||||
or 64 KiB (and is bounded to that as it is generated, for the same
|
|
||||||
reason), or matches a best-effort credential screen - vendor prefixes
|
|
||||||
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
|
|
||||||
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
|
|
||||||
`Authorization: Bearer`, and PEM private-key headers, matched
|
|
||||||
case-insensitively, since `.claude/settings.json`'s `env` block - the one
|
|
||||||
high-value surface this is the only net for - conventionally holds its keys
|
|
||||||
upper-case. The screen is best-effort with real false negatives: the
|
|
||||||
denylist above is the reliable half.
|
|
||||||
|
|
||||||
A git config key with more than one value on either side is not compared.
|
### What `ack` shows you
|
||||||
Git resolves such a key to its last value in FILE order; the audit parses
|
|
||||||
and sorts, so lexicographically-last is not what git runs, and the
|
|
||||||
pre-session side is every value the key had. Comparing anyway produced a
|
|
||||||
diff over values git does not use - two pre-session values read as a
|
|
||||||
deletion that never happened, while the appended value git now executes
|
|
||||||
appeared nowhere. It withholds instead, and must be compared by hand.
|
|
||||||
`include.path` is legitimately multi-valued, so this is an ordinary shape.
|
|
||||||
|
|
||||||
A diff can also come out empty when the config file's TEXT changed but the
|
For a surface that changed during the session it prints a unified diff of the
|
||||||
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
|
pre-session content against the current content; for one created during the
|
||||||
rather than showing nothing - an empty diff for a reported change reads as
|
session it prints the whole current content. To make that possible, `snapshot`
|
||||||
a bug.
|
stages a copy of each monitored surface's bytes for the duration of the session,
|
||||||
|
and `compare` turns those into **bounded diffs** in a `<report>.d/` directory
|
||||||
|
beside the report. What persists afterwards is only the diff of a *modified*
|
||||||
|
surface, never a whole file. A created surface stores a marker (its content is
|
||||||
|
live on disk); a removed one stores nothing, because a deletion diff *is* the
|
||||||
|
whole former file.
|
||||||
|
|
||||||
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
|
What it prints is chosen by what changed, which is not always what you need in
|
||||||
diff bytes one report keeps. Once spent, the rest withhold and both the
|
order to judge it: three changed lines in a hook script are only judgeable by
|
||||||
report and the stderr summary add an alarm line: that many changed
|
reading the script. `[o]` at the prompt opens the file in `$VISUAL`/`$EDITOR`,
|
||||||
host-persistence surfaces in one session is a signal in itself, not a limit
|
read-only where the editor understands how, and returns to the same decision. It
|
||||||
worth raising. `diffs = off` in `[audit]` disables retention and staging.
|
opens the file **as it is now**, for the same reason an approval pins the content
|
||||||
|
on disk at the moment it is recorded: approving something nobody read would be
|
||||||
|
the one outcome worse than not asking.
|
||||||
|
|
||||||
|
Staging and retained diffs live under `$AUDIT_DIR`
|
||||||
|
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the scheme
|
||||||
|
rests on: nothing in `docker-compose.yml` covers it, so the container cannot read
|
||||||
|
earlier runs' retained content, and `static.bats` pins that. A store configured
|
||||||
|
*inside* the workspace is readable and deletable from the sandbox, so nothing is
|
||||||
|
retained in that case, only a note saying why.
|
||||||
|
|
||||||
|
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory; the
|
||||||
|
next run against that workspace reaps it, identifying dead owners by pid and
|
||||||
|
process start time from an `.owner` file so a concurrent session is never
|
||||||
|
mistaken for dead. A missing or unreadable owner record falls back to a 24-hour
|
||||||
|
age check rather than guessing.
|
||||||
|
|
||||||
|
### When content is withheld from the store
|
||||||
|
|
||||||
|
In every case below `ack` prints the reason in place of the diff - so an absent
|
||||||
|
diff is never mistaken for an absent change - and still shows the live current
|
||||||
|
content on your terminal, because withholding is about the persistent store, not
|
||||||
|
about what you are shown while deciding.
|
||||||
|
|
||||||
|
Surfaces that hold credentials by convention are never staged in the first
|
||||||
|
place, so their bytes are never even copied transiently: `.envrc`, `.mcp.json`
|
||||||
|
and `.claude/settings.local.json` by exact path, and `.env`/`.env.*` by
|
||||||
|
*basename*, at any depth - so a nested `.claude/.env` is caught too.
|
||||||
|
|
||||||
|
A surface that existed before the session but was too large to have been staged
|
||||||
|
(over 1 MiB) withholds with "no pre-session copy", not a false claim that it is
|
||||||
|
new. The *current* side is bounded by that same cap, and checked *before* any
|
||||||
|
copy is taken: a file the session grew past it withholds with "change too large
|
||||||
|
to retain" rather than being copied through `$TMPDIR` first. The ordering is the
|
||||||
|
point, not the cap. `compare` runs from the exit trap on every exit and the size
|
||||||
|
is the session's own choice, so copying first would turn "I filled my own
|
||||||
|
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so host
|
||||||
|
RAM - and into a whole copy left there if the exit was a `SIGKILL`.
|
||||||
|
|
||||||
|
A generated diff is withheld if it is binary, over 200 lines or 64 KiB (bounded
|
||||||
|
as it is generated, for the same reason), or matches a best-effort credential
|
||||||
|
screen: vendor prefixes including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`,
|
||||||
|
`github_pat_`, `glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
|
||||||
|
`Authorization: Bearer`, and PEM private-key headers, matched case-insensitively
|
||||||
|
since `.claude/settings.json`'s `env` block - the one high-value surface this is
|
||||||
|
the only net for - conventionally holds its keys upper-case. The screen has real
|
||||||
|
false negatives; the denylist is the reliable half.
|
||||||
|
|
||||||
|
A git config key with more than one value on either side is not compared. Git
|
||||||
|
resolves such a key to its last value in FILE order; the audit parses and sorts,
|
||||||
|
so lexicographically-last is not what git runs, and the pre-session side is every
|
||||||
|
value the key had. Comparing anyway produced a diff over values git does not use:
|
||||||
|
two pre-session values read as a deletion that never happened, while the appended
|
||||||
|
value git now executes appeared nowhere. It withholds instead, and must be
|
||||||
|
compared by hand. `include.path` is legitimately multi-valued, so this is an
|
||||||
|
ordinary shape.
|
||||||
|
|
||||||
|
A diff can also come out empty when the config file's TEXT changed but the parsed
|
||||||
|
value did not, e.g. a re-quoted value. That withholds too, saying so rather than
|
||||||
|
showing nothing, since an empty diff for a reported change reads as a bug.
|
||||||
|
|
||||||
|
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total diff
|
||||||
|
bytes one report keeps. Once spent, the rest withhold and both the report and the
|
||||||
|
stderr summary add an alarm line: that many changed host-persistence surfaces in
|
||||||
|
one session is a signal in itself, not a limit worth raising. `diffs = off` in
|
||||||
|
`[audit]` disables retention and staging.
|
||||||
|
|
||||||
|
Two residual limits on top of the screen's false negatives. The prompt truncates
|
||||||
|
at 40 lines, naming the file so you can read the rest, so a large planted change
|
||||||
|
is cut exactly where you would want it whole. And the `SIGKILL` case is an
|
||||||
|
exposure window, not just a cleanup mechanic: until the next run reaps it, the
|
||||||
|
staging directory holds whole pre-session file copies of every monitored surface,
|
||||||
|
for as long as the workspace goes unused.
|
||||||
|
|
||||||
|
### Findings reach your terminal as untrusted text
|
||||||
|
|
||||||
|
They name a path or config key the session may have chosen. One containing an
|
||||||
|
ASCII or C1 control or a Unicode bidi override - which can scroll, blank or
|
||||||
|
reorder what you read, and split a name across the audit's own records - is
|
||||||
|
written hex-encoded behind a `{hex}` marker and decodes with
|
||||||
|
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is, so
|
||||||
|
the marker appearing is itself worth a look. The encoded form is what `ack`
|
||||||
|
records, so what you acknowledge is the file on disk rather than a readable
|
||||||
|
approximation of its name.
|
||||||
|
|
||||||
Two residual limits on top of the screen's false negatives. The prompt
|
|
||||||
truncates at 40 lines, naming the file so you can read the rest - a large
|
|
||||||
planted change is cut exactly where you would want it whole. And the
|
|
||||||
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
|
|
||||||
the next run reaps it, the staging directory holds whole pre-session file
|
|
||||||
copies of every monitored surface, for as long as the workspace goes
|
|
||||||
unused.
|
|
||||||
- Findings name a path or config key the session may have chosen, so they
|
|
||||||
reach your terminal as untrusted text. One containing an ASCII or C1
|
|
||||||
control or a Unicode bidi override - which can scroll, blank or reorder
|
|
||||||
what you read, and split a name across the audit's own records - is written
|
|
||||||
hex-encoded behind a `{hex}` marker and decodes with
|
|
||||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
|
|
||||||
so the marker appearing is itself worth a look. The encoded form is what
|
|
||||||
`ack` records, so what you acknowledge is the file on disk rather than a
|
|
||||||
readable approximation of its name.
|
|
||||||
## One project's session cannot reach another's
|
## One project's session cannot reach another's
|
||||||
|
|
||||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||||
@@ -705,11 +784,29 @@ session, where the credential deliberately never goes.
|
|||||||
the README) has no `sudo`/setuid path back to root once the container is up:
|
the README) has no `sudo`/setuid path back to root once the container is up:
|
||||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||||
that user via `gosu` before execing the configured command (`[core] command`,
|
that user via `gosu` before execing the configured command (`[core] command`,
|
||||||
`claude` by default). A compromised session can't
|
or the selected harness's own if that is unset). A compromised session can't
|
||||||
re-run `init-firewall.sh` with a wider allowlist to open its own
|
re-run `init-firewall.sh` with a wider allowlist to open its own
|
||||||
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
||||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||||
user gets a permission error running `iptables` directly.
|
user gets a permission error running `iptables` directly.
|
||||||
|
- A forge credential reaches the container as `-e NAME`, never
|
||||||
|
`-e NAME=value`. The value comes from the launcher's own environment, so it
|
||||||
|
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
|
||||||
|
`/proc/<pid>/cmdline`, which is world-readable and which `ps` prints to
|
||||||
|
anyone. `docker inspect` still shows the resolved value to whoever can
|
||||||
|
reach the daemon - that is daemon access rather than any process at all,
|
||||||
|
and it is not something the launcher can change.
|
||||||
|
- The container runs with `no-new-privileges` and an explicit capability
|
||||||
|
floor - `cap_drop: ALL` plus the ones it actually uses: what the
|
||||||
|
entrypoint's root phase needs, and `KILL`. `KILL` is there for
|
||||||
|
`dejima-allow --container`, not for the entrypoint - adding a domain to a
|
||||||
|
running session signals dnsmasq, and dnsmasq runs as its own user. The list
|
||||||
|
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
|
||||||
|
privileges on exec, which is the shape any escalation from the session user
|
||||||
|
would have to take; the second means the capabilities nothing here uses were
|
||||||
|
never in the bounding set to be regained. Both are asserted against a running
|
||||||
|
container rather than against the compose file, and the capability list the
|
||||||
|
test uses is read out of that file so the two cannot drift.
|
||||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||||
see
|
see
|
||||||
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
||||||
@@ -855,6 +952,14 @@ strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
|
|||||||
leaving it open would bypass the whole firewall on any host with IPv6
|
leaving it open would bypass the whole firewall on any host with IPv6
|
||||||
egress.
|
egress.
|
||||||
|
|
||||||
|
Where `ip6tables` cannot be used at all, the firewall does not conclude that
|
||||||
|
IPv6 is therefore absent - it asks the kernel, and treats an address on any
|
||||||
|
interface other than loopback as the family being live. It then turns IPv6
|
||||||
|
off in the container's own network namespace, and **refuses to start** if it
|
||||||
|
cannot. A tool that will not run and a family that is not there are different
|
||||||
|
conditions, and reporting the second on evidence of the first is how a
|
||||||
|
boundary comes to be described rather than held.
|
||||||
|
|
||||||
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
||||||
Docker host itself can resolve it: containers inherit the host's resolver.
|
Docker host itself can resolve it: containers inherit the host's resolver.
|
||||||
|
|
||||||
@@ -901,12 +1006,15 @@ is precisely when nothing is running to probe in the first place. Pass
|
|||||||
`dejima-allow --global --container <id> crates.io` records the approval and
|
`dejima-allow --global --container <id> crates.io` records the approval and
|
||||||
patches that session immediately, in that order, so the approval is never
|
patches that session immediately, in that order, so the approval is never
|
||||||
lost to a live-session failure. If more than one dejima container is
|
lost to a live-session failure. If more than one dejima container is
|
||||||
running, the bare form above refuses and asks you to disambiguate with
|
running, the bare form above refuses rather than guessing. On a terminal it
|
||||||
`--container <name>` - `docker ps` finds it, or use the
|
offers the running sessions to pick from, by project; anywhere else - a
|
||||||
container's own ID, which `sandbox-context.sh` already hands the agent in
|
script, a pipe, a session asking its operator to run this - it prints the same
|
||||||
its startup context (and which is always available inside the sandbox as
|
rows and stops, because a prompt nobody can answer is worse than a refusal.
|
||||||
`cat /etc/hostname`), so there's no need for it to guess one and risk
|
Either way the answer is in front of you instead of in a separate `docker ps`.
|
||||||
"successfully" updating someone else's session instead.
|
A session has its own ID regardless: `sandbox-context.sh` hands it to the
|
||||||
|
agent in its startup context, and `cat /etc/hostname` has it inside the
|
||||||
|
sandbox, so there is no need for it to guess one and risk "successfully"
|
||||||
|
updating someone else's session instead.
|
||||||
|
|
||||||
A domain allowed this way stays allowed for the rest of the session even
|
A domain allowed this way stays allowed for the rest of the session even
|
||||||
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
|
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
|
||||||
@@ -987,6 +1095,33 @@ name. It belongs in that harness's worked example in
|
|||||||
[development.md](development.md#adding-a-new-harness) alongside the rest of
|
[development.md](development.md#adding-a-new-harness) alongside the rest of
|
||||||
what it can and cannot do.
|
what it can and cannot do.
|
||||||
|
|
||||||
|
## The one request dejima itself makes
|
||||||
|
|
||||||
|
Everything above bounds what a *session* reaches. dejima's own host side
|
||||||
|
reaches one place, once an hour at most: `bin/dejima-update-check` asks the
|
||||||
|
remote your checkout already tracks for the tip of the branch it already
|
||||||
|
follows, so that a launch can say when the checkout is behind. It is
|
||||||
|
`ls-remote`, so no objects are downloaded, and `[core] update_check = off`
|
||||||
|
stops it entirely. There is no other endpoint and no telemetry - a domain
|
||||||
|
you have not already configured dejima to push to is never contacted.
|
||||||
|
|
||||||
|
It reaches that remote by URL, from outside the repository, and never by
|
||||||
|
remote name. `git ls-remote origin` reads the checkout's own config for how
|
||||||
|
to get there, and `core.sshCommand`, `credential.helper` and
|
||||||
|
`url.<base>.insteadOf` each name a command git then runs on the host. dejima
|
||||||
|
mounts its own checkout writable when that checkout is the workspace, so a
|
||||||
|
session that wrote `.git/config` there would otherwise get host execution out
|
||||||
|
of the next launch. That is the same class as the `core.hooksPath` route
|
||||||
|
above, with one difference worth stating: `hooksPath` waits for the operator
|
||||||
|
to run a git command, and a check that runs at every startup does not wait
|
||||||
|
for anything. Handing the URL to a git started outside the repository leaves
|
||||||
|
none of that config in the path, and `protocol.allow` holds the transports to
|
||||||
|
ones that cannot name a command.
|
||||||
|
|
||||||
|
What still crosses is the URL itself, which a session with that same write can
|
||||||
|
point at a host of its choosing. That costs one outbound request and no
|
||||||
|
execution, and the audit reports the config change it took.
|
||||||
|
|
||||||
## Network request logging
|
## Network request logging
|
||||||
|
|
||||||
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
||||||
|
|||||||
+96
-5
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
[← dejima README](../README.md)
|
[← dejima README](../README.md)
|
||||||
|
|
||||||
|
What dejima defends, what it does not, and where its boundary passes out of
|
||||||
|
its hands. [security.md](security.md) is the control-by-control account; this
|
||||||
|
is the shape of the thing those controls are arranged against.
|
||||||
|
|
||||||
**And none of it has been reviewed by anyone else.** This is early
|
**And none of it has been reviewed by anyone else.** This is early
|
||||||
development: the reasoning here is one person's, the tests assert what that
|
development: the reasoning here is one person's, the tests assert what that
|
||||||
person thought to check, and no outside audit or adversarial testing has
|
person thought to check, and no outside audit or adversarial testing has
|
||||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||||
|
|
||||||
What dejima defends, what it does not, and where its boundary passes out of
|
|
||||||
its hands. [security.md](security.md) is the control-by-control account; this
|
|
||||||
is the shape of the thing those controls are arranged against.
|
|
||||||
|
|
||||||
## What this tool assumes
|
## What this tool assumes
|
||||||
|
|
||||||
**The agent is hostile, or will be.** Not badly behaved: captured, mid-session,
|
**The agent is hostile, or will be.** Not badly behaved: captured, mid-session,
|
||||||
@@ -62,12 +62,47 @@ to instructions, because it arrives as instructions.
|
|||||||
4. **Cross-project context poisoning.** A session writes another project's
|
4. **Cross-project context poisoning.** A session writes another project's
|
||||||
agent memory or transcripts, and a later session there reads planted text
|
agent memory or transcripts, and a later session there reads planted text
|
||||||
as established background.
|
as established background.
|
||||||
5. **Container escape.** Real, and it recurs, but it needs an unpatched host
|
5. **The operator running something the session asked them to run.** dejima
|
||||||
|
tells agents to hand host-side work over rather than attempt it - a domain
|
||||||
|
to allow, a dependency to approve, a command only the host can run. That is
|
||||||
|
a designed, legitimate, frequent workflow, which is exactly what makes it a
|
||||||
|
delivery mechanism: a script that does what it says and one thing more is
|
||||||
|
indistinguishable at the moment of running it. Nothing here inspects what
|
||||||
|
crosses that boundary, and nothing can - the whole point of the handoff is
|
||||||
|
that a person decides.
|
||||||
|
6. **Container escape.** Real, and it recurs, but it needs an unpatched host
|
||||||
or a working zero-day, and the two rows it moves between are both already
|
or a working zero-day, and the two rows it moves between are both already
|
||||||
in the table below.
|
in the table below.
|
||||||
|
|
||||||
The ordering is the point. Nothing above the last item requires an exploit.
|
The ordering is the point. Nothing above the last item requires an exploit.
|
||||||
|
|
||||||
|
Item 5 is not an argument for making the handoff harder; a session that cannot
|
||||||
|
ask is a session that works around you instead. It is an argument for being
|
||||||
|
able to answer, afterwards, what that actually did to the machine - which is
|
||||||
|
the layer below this repository's, and the reason the sister project is
|
||||||
|
looking at detection rather than at more enforcement.
|
||||||
|
|
||||||
|
**The handoff directories are item 5 with the person taken out.** `[core]
|
||||||
|
share_dir` and everything in `[mounts] read_write` exist so that session
|
||||||
|
output survives the container, so every byte and every filename in them is
|
||||||
|
session-authored by design. On the box that is bounded by how rarely anyone
|
||||||
|
looks. Mount one on a workstation - over sshfs, or by any sync that runs on
|
||||||
|
its own - and the directory is simply present, at which point a desktop opens
|
||||||
|
what appears in it without being asked: an indexer parsing documents and
|
||||||
|
archives to extract text, a thumbnailer decoding images, a previewer, an
|
||||||
|
editor honouring project configuration it finds beside the files. Each is a
|
||||||
|
parser running as the operator, outside the container, reached with no
|
||||||
|
decision per file. Item 5's last sentence is that a person decides; here
|
||||||
|
nobody does.
|
||||||
|
|
||||||
|
It stays item 5 rather than becoming a row of its own, because the shape is
|
||||||
|
the same: the session initiates none of it. It cannot see the mount, cannot
|
||||||
|
cause one, and the ssh credential involved already gave that operator a shell
|
||||||
|
on the box. What changes is where session-authored bytes get opened, and the
|
||||||
|
machine they get opened on is not a row in the table below.
|
||||||
|
[remote-access.md](remote-access.md#what-mounting-it-costs-you) has the
|
||||||
|
specifics and what shrinks them.
|
||||||
|
|
||||||
## Blast radius by layer
|
## Blast radius by layer
|
||||||
|
|
||||||
Read the last two columns together: the middle one is what the layer reaches
|
Read the last two columns together: the middle one is what the layer reaches
|
||||||
@@ -127,6 +162,58 @@ has the two tiers and where each one's boundary sits.
|
|||||||
Neither is required to run dejima, and the container row holds without them.
|
Neither is required to run dejima, and the container row holds without them.
|
||||||
What they change is what the row below it costs.
|
What they change is what the row below it costs.
|
||||||
|
|
||||||
|
**A survey of comparable projects found none with rows below the container at
|
||||||
|
all** - their boundary is between the agent and the host, and what is under
|
||||||
|
the host is outside their threat models. The same survey found nothing
|
||||||
|
treating a forge or a CI runner as part of an agent's blast radius, which is
|
||||||
|
what the second sister project exists for: a session that can open a pull
|
||||||
|
request can execute code on a machine dejima never touches.
|
||||||
|
|
||||||
|
Recorded as an observation from one day of secondary sources rather than as a
|
||||||
|
claim to have surveyed the field exhaustively. The reason it is here at all is
|
||||||
|
that it changes what to defend: if the layers below and beside the container
|
||||||
|
are ones nobody else is examining, they are also ones where nobody else's
|
||||||
|
mistakes are available to learn from.
|
||||||
|
|
||||||
|
## Which deployment this describes
|
||||||
|
|
||||||
|
The table above describes dejima **as deployed on a devbox VM with the two
|
||||||
|
sister projects arranged around it**. Say that plainly, because the README
|
||||||
|
also invites running dejima on a laptop, and there the table is shorter than
|
||||||
|
it looks: the container row holds exactly as described, and the row below it
|
||||||
|
is the operator's own machine with nothing arranged against it.
|
||||||
|
|
||||||
|
That matters more than it used to. The field's own ordering for isolating an
|
||||||
|
agent whose contents are assumed hostile puts a hardened container below
|
||||||
|
gVisor and below a per-workload VM kernel, on the grounds that a container
|
||||||
|
shares the host kernel and that container *runtime* vulnerabilities bypass
|
||||||
|
syscall filtering rather than being caught by it. dejima's answer to that
|
||||||
|
ordering is not to dispute it. It is that escape moves a layer instead of
|
||||||
|
ending the argument - which is a better answer where the next layer exists,
|
||||||
|
and no answer at all where it does not.
|
||||||
|
|
||||||
|
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
|
||||||
|
On a laptop, dejima is a hardened container and the honest description of what
|
||||||
|
is under it is "your home directory, and dejima has nothing to say about it".
|
||||||
|
|
||||||
|
**Load-bearing for what, exactly**, because the field's tiering invites a
|
||||||
|
reading that project does not accept and has never claimed. A per-workload VM
|
||||||
|
kernel is reached for elsewhere as a *containment* boundary. That is not what
|
||||||
|
the row below the container buys here. `dejima-devbox-hardener`'s own threat
|
||||||
|
model states in three places that its firewall bounds where a compromise can
|
||||||
|
**spread**, not whether it can phone home: an escape reopens internet egress,
|
||||||
|
deliberately, because deny-by-default earns its keep where the protected set
|
||||||
|
is enumerable and the internet is not.
|
||||||
|
|
||||||
|
Read the row as written, then: it cuts LAN reach to what is declared, and it
|
||||||
|
is enforced from outside the guest so root in the VM cannot flush it. It does
|
||||||
|
not restore the egress control the container layer provides. Anyone writing
|
||||||
|
this sentence more confidently than that is making a promise on another
|
||||||
|
repository's behalf.
|
||||||
|
|
||||||
|
**What this repository can do about that is say so**, which is the whole of
|
||||||
|
this section. Nothing in dejima can bound its own host.
|
||||||
|
|
||||||
## What this repository does not claim
|
## What this repository does not claim
|
||||||
|
|
||||||
- **That the egress allowlist bounds what reaches the model.** It bounds what
|
- **That the egress allowlist bounds what reaches the model.** It bounds what
|
||||||
@@ -146,6 +233,10 @@ What they change is what the row below it costs.
|
|||||||
- **That a finding from the exit-time audit was caused by the session.** It
|
- **That a finding from the exit-time audit was caused by the session.** It
|
||||||
reports that a surface changed while the session ran. A concurrent session
|
reports that a surface changed while the session ran. A concurrent session
|
||||||
or the operator's own editing lands in the same diff.
|
or the operator's own editing lands in the same diff.
|
||||||
|
- **That what a session leaves in a handoff directory is safe for another
|
||||||
|
machine to open.** `share_dir` and `[mounts] read_write` carry
|
||||||
|
session-authored bytes and session-chosen filenames out of the container on
|
||||||
|
purpose. Sanitising happens where dejima does the printing, and stops there.
|
||||||
- **That the allowlist bounds exfiltration once a session has push access.**
|
- **That the allowlist bounds exfiltration once a session has push access.**
|
||||||
A commit is an outbound channel to a host that is on the list by definition.
|
A commit is an outbound channel to a host that is on the list by definition.
|
||||||
See [ci.md](ci.md).
|
See [ci.md](ci.md).
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
# Optional tools
|
||||||
|
|
||||||
|
A tool is a program dejima can put in a session's image that is not the
|
||||||
|
harness and not a project dependency: something an agent uses to work, that
|
||||||
|
dejima has looked at first.
|
||||||
|
|
||||||
|
None is enabled by default.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# ~/.config/dejima/config - on this machine, always
|
||||||
|
[core]
|
||||||
|
tools = rtk
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
# .dejima/tools in the project - shared, and approved before it takes effect
|
||||||
|
rtk
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dejima --include rtk # or -i rtk, repeatable, for one launch
|
||||||
|
```
|
||||||
|
|
||||||
|
The three are a union rather than a precedence chain: they answer different
|
||||||
|
questions - what this machine always wants, what this project needs, what this
|
||||||
|
run is for - and none is a correction of another. The project's half is a
|
||||||
|
declaration like every other file in `.dejima/`: it grants nothing until
|
||||||
|
`dejima-project approve` records it in a store outside the container's reach.
|
||||||
|
|
||||||
|
## What "optional tool" does not mean
|
||||||
|
|
||||||
|
**It does not mean a bare install.** This is the part worth stating first,
|
||||||
|
because it is the whole difference between this and adding a line to
|
||||||
|
`.dejima/dependencies`.
|
||||||
|
|
||||||
|
A dependency is something a project says it needs and an operator agrees to.
|
||||||
|
Nobody claims to have read it. A tool is something **dejima ships**, and the
|
||||||
|
bar for adding one is that dejima has hardened it. That bar applies to every
|
||||||
|
tool added this way, not only the first:
|
||||||
|
|
||||||
|
- **Pinned and checksummed.** A published artifact at a fixed version with a
|
||||||
|
`sha256` in the manifest, verified before it is unpacked. Never the
|
||||||
|
project's own `curl | sh` installer, which fetches whatever its default
|
||||||
|
branch points at today - an image build that produces a different binary on
|
||||||
|
different days is one nothing has reviewed.
|
||||||
|
- **Its network behaviour established, not assumed.** Read the source for
|
||||||
|
outbound calls, then check the artifact you are actually shipping, because
|
||||||
|
the two can differ. Anything it wants to reach is off unless the firewall
|
||||||
|
allowlists it, and a tool that needs a domain allowlisted to function is one
|
||||||
|
to argue about rather than to add.
|
||||||
|
- **Telemetry off at the tool's own switch as well as at the firewall.** Two
|
||||||
|
independent offs, because either one can be wrong.
|
||||||
|
- **Wired up by dejima, not by the tool's installer.** Most of these ship an
|
||||||
|
`init` that patches a harness's config file, and what it patches is often
|
||||||
|
the same hook surface dejima's guards live on. dejima renders the wiring
|
||||||
|
itself so the ordering, and what the file ends up containing, stay dejima's.
|
||||||
|
- **The guards still hold with it enabled**, and a test says so.
|
||||||
|
|
||||||
|
- **It sets only its own environment.** A tool's `image_env` becomes a
|
||||||
|
Dockerfile `ENV` that every process in the container inherits, so a bare
|
||||||
|
name reaches the harness too. Names must be namespaced to the tool and a
|
||||||
|
launch refuses one that is not. This is the bar's newest line, and it is
|
||||||
|
here because rtk broke it: it shipped with `NO_COLOR=1`, and a session with
|
||||||
|
the tool enabled lost colour in the harness, git and everything else.
|
||||||
|
|
||||||
|
If a tool cannot clear that, it is not an optional tool. It might still be a
|
||||||
|
dependency a project declares and an operator approves, where the claim being
|
||||||
|
made is much smaller.
|
||||||
|
|
||||||
|
## What a tool may declare
|
||||||
|
|
||||||
|
Deliberately little. A tool's manifest names what it is, what installs it,
|
||||||
|
what its environment needs, and what the session should be told about it.
|
||||||
|
There is no key for a hook, a mount, a domain or a guard - a tool that needed
|
||||||
|
one of those would be a change to the boundary rather than a program on PATH,
|
||||||
|
and would have to be argued for rather than declared.
|
||||||
|
|
||||||
|
See `docs/providers.md` for the key-by-key table.
|
||||||
|
|
||||||
|
## rtk
|
||||||
|
|
||||||
|
[rtk](https://github.com/rtk-ai/rtk) (Apache-2.0) compresses command output
|
||||||
|
before it reaches the model: `rtk git status` runs git and prints a shorter
|
||||||
|
form of the result. It makes no model calls of its own - it is a deterministic
|
||||||
|
filter, so nothing about it sends your code anywhere.
|
||||||
|
|
||||||
|
What was checked, since the list above is only worth as much as its worst
|
||||||
|
entry:
|
||||||
|
|
||||||
|
- **Pinned** to the published `x86_64-unknown-linux-musl` release by digest,
|
||||||
|
taken from the artifact and confirmed against a second download.
|
||||||
|
- **Telemetry exists and the project's README does not mention it.** An HTTPS
|
||||||
|
ping is compiled into the release binary - visible in the shipped artifact's
|
||||||
|
strings, not only in the source - gated on a consent flag that defaults to
|
||||||
|
unset. It is off three ways here: consent unset, `RTK_TELEMETRY_DISABLED=1`
|
||||||
|
in the image environment, and the endpoint is not allowlisted, so the
|
||||||
|
request cannot leave the container even if the first two were wrong.
|
||||||
|
- **It resolves wrapped commands through PATH.** Measured: `rtk git status`
|
||||||
|
with a stub `git` earlier on PATH runs the stub. So dejima's PATH shadows -
|
||||||
|
the git guard and the forge guard - still apply to a command run through it,
|
||||||
|
which is the property that matters most here.
|
||||||
|
- **Its Claude Code integration installs a `PreToolUse` hook that rewrites
|
||||||
|
commands**, in the same slot dejima's guards occupy. dejima does not install
|
||||||
|
that hook. See below.
|
||||||
|
|
||||||
|
### Why the hook is not installed
|
||||||
|
|
||||||
|
`rtk init` registers a hook that rewrites a Bash command before it runs -
|
||||||
|
`git push origin main` becomes `rtk git push origin main`. That is the same
|
||||||
|
`PreToolUse`/`Bash` slot as `deny-dangerous-bash.sh`, `git-push-guard.sh` and
|
||||||
|
`merge-guard.sh`, and the ordering between a third-party rewrite and dejima's
|
||||||
|
guards is not something dejima would control once the tool's installer had
|
||||||
|
written it.
|
||||||
|
|
||||||
|
So dejima tells the session the tool exists instead, which is exactly what
|
||||||
|
rtk's own Cline integration does. That is weaker: the agent chooses when to
|
||||||
|
use it rather than getting it automatically. It is also the conservative first
|
||||||
|
position for the first tool of this kind, and the thing that would change it
|
||||||
|
is a measurement - establishing how Claude Code merges hooks across settings
|
||||||
|
layers, and whether a guard sees the original command or the rewritten one -
|
||||||
|
rather than a preference.
|
||||||
|
|
||||||
|
Note what is not being claimed: a session can run `rtk git push` itself
|
||||||
|
whether or not dejima installs the hook. The guards hold because they hold at
|
||||||
|
the PATH and hook layers, not because the tool is absent.
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
x86_64 only. The tarball spec names one architecture (issue #97), so an arm64
|
||||||
|
host cannot use this entry.
|
||||||
@@ -6,6 +6,12 @@ merge_block = gitea-pr merge
|
|||||||
# marker is core's (docker/forge-guard.sh); this only says which commands carry
|
# marker is core's (docker/forge-guard.sh); this only says which commands carry
|
||||||
# one and what each of them is writing.
|
# 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
|
disclose = pr:gitea-pr create, pr:gitea-pr edit, issue:gitea-issue create, issue:gitea-issue edit, comment:gitea-issue comment
|
||||||
|
# Which of the keys above belong to a PERSON rather than to the project. A
|
||||||
|
# project's .dejima/ is committed and shared; a token path is per-user and
|
||||||
|
# per-machine, so a project may not declare one and an [identity.<name>]
|
||||||
|
# section may set one. One declaration drives both rules, so they cannot
|
||||||
|
# disagree about which key is which.
|
||||||
|
identity_keys = token_file
|
||||||
keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/token
|
keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/token
|
||||||
requires = ssh_host api token_file
|
requires = ssh_host api token_file
|
||||||
host_keys = ssh_host
|
host_keys = ssh_host
|
||||||
|
|||||||
Executable
+215
@@ -0,0 +1,215 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# The one thing `gh` cannot do (#186).
|
||||||
|
#
|
||||||
|
# `gh pr view --comments` prints reviews and comments with no resolution state
|
||||||
|
# anywhere in the output, because the REST API it uses does not carry any.
|
||||||
|
# `isResolved` exists only on `reviewThreads` in the GraphQL API. So an agent
|
||||||
|
# or an operator working through PR feedback re-reads and re-answers threads
|
||||||
|
# that were closed a week ago, with nothing saying so - quiet in the direction
|
||||||
|
# that wastes the most work.
|
||||||
|
#
|
||||||
|
# Everything else `gitea-pr` does has a `gh` subcommand already, which is why
|
||||||
|
# this ships one command rather than a second CLI. It reads and never writes,
|
||||||
|
# so there is no authorship disclosure to apply: docker/forge-guard.sh shadows
|
||||||
|
# `gh`, not this.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
echo "Usage: github-pr comments PR_NUMBER [--all]"
|
||||||
|
echo ""
|
||||||
|
echo "Shows a pull request's comments, reviews and inline review threads."
|
||||||
|
echo "Unresolved threads only by default; --all includes the ones already"
|
||||||
|
echo "resolved, marked as such."
|
||||||
|
echo ""
|
||||||
|
echo "The repository is the 'origin' remote of the checkout you are in, and"
|
||||||
|
echo "must be on github.com. Authentication is gh's own - GH_TOKEN, or"
|
||||||
|
echo "whatever 'gh auth status' reports."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same shape as gitea-pr's: resolve from origin and refuse another host, so
|
||||||
|
# this tool cannot be pointed at a repository the checkout is not for. Covers
|
||||||
|
# ssh, scp-style and https remotes by taking the last two path components
|
||||||
|
# rather than parsing any of them as a URL.
|
||||||
|
function github_owner_repo() {
|
||||||
|
local remote_url stripped name rest owner
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||||
|
echo "No 'origin' remote found in this repository" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
case "$remote_url" in
|
||||||
|
*github.com[:/]*) ;;
|
||||||
|
*) echo "origin remote ($remote_url) is not on github.com; github-pr only supports this host" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
stripped="${remote_url%/}"
|
||||||
|
stripped="${stripped%.git}"
|
||||||
|
name="${stripped##*/}"
|
||||||
|
rest="${stripped%/*}"
|
||||||
|
owner="${rest##*[:/]}"
|
||||||
|
if [ -z "$owner" ] || [ -z "$name" ] || [ "$owner" = "$stripped" ]; then
|
||||||
|
echo "Could not read an owner/name out of the origin remote ($remote_url)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s/%s' "$owner" "$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The number reaches gh as a typed GraphQL variable rather than being spliced
|
||||||
|
# into a path, so this is not the URL-traversal check gitea-pr's is. It is
|
||||||
|
# here so a mistyped argument fails with something a person can act on instead
|
||||||
|
# of a GraphQL type error.
|
||||||
|
function require_number() {
|
||||||
|
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Expected a PR number, got '${1:-}'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -lt 1 ]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
subcommand="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$subcommand" in
|
||||||
|
-h|--help|help) usage; exit 0 ;;
|
||||||
|
comments) ;;
|
||||||
|
*) echo "Unknown subcommand: $subcommand" >&2; usage >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
require_number "${1:-}"
|
||||||
|
pr_number="$1"
|
||||||
|
shift || true
|
||||||
|
show_all=0
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--all) show_all=1 ;;
|
||||||
|
*) echo "Unknown argument: $1" >&2
|
||||||
|
echo "Usage: github-pr comments PR_NUMBER [--all]" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
owner_repo="$(github_owner_repo)"
|
||||||
|
owner="${owner_repo%%/*}"
|
||||||
|
repo="${owner_repo##*/}"
|
||||||
|
|
||||||
|
# gh's own message, then one line saying what was being asked for. Captured
|
||||||
|
# rather than piped straight into jq: on a 404 gh prints "Not Found" and exits,
|
||||||
|
# and jq then fails on the message text, so the last thing on the terminal is
|
||||||
|
# "Cannot index string with string" rather than the fact that the PR does not
|
||||||
|
# exist.
|
||||||
|
api_or_die() {
|
||||||
|
local what="$1"; shift
|
||||||
|
local out
|
||||||
|
if ! out="$("$@" 2>&1)"; then
|
||||||
|
printf '%s\n' "$out" >&2
|
||||||
|
echo "github-pr: could not $what for ${owner_repo}#${pr_number}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Issue comments ==="
|
||||||
|
api_or_die "fetch issue comments" \
|
||||||
|
gh api --paginate "repos/${owner}/${repo}/issues/${pr_number}/comments" \
|
||||||
|
| jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||||
|
|
||||||
|
echo "=== Reviews ==="
|
||||||
|
api_or_die "fetch reviews" \
|
||||||
|
gh api --paginate "repos/${owner}/${repo}/pulls/${pr_number}/reviews" \
|
||||||
|
| jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
|
||||||
|
|
||||||
|
echo "=== Inline review comments ==="
|
||||||
|
|
||||||
|
# reviewThreads, not the REST review comments, for the whole reason this tool
|
||||||
|
# exists: `isResolved` is only here. `isOutdated` comes with it and is worth
|
||||||
|
# printing - a thread against a hunk that no longer exists is usually already
|
||||||
|
# handled, and reads as outstanding otherwise.
|
||||||
|
#
|
||||||
|
# Paginated by hand rather than with `gh api --paginate`: that flag emits one
|
||||||
|
# JSON document per page for GraphQL, which would have to be reassembled here
|
||||||
|
# anyway, and the bound below is the point. A server that ignored `after`
|
||||||
|
# would otherwise loop forever, and this runs unattended.
|
||||||
|
PAGE_SIZE=50
|
||||||
|
PAGE_MAX=20
|
||||||
|
read -r -d '' QUERY <<'GRAPHQL' || true
|
||||||
|
query($owner: String!, $name: String!, $number: Int!, $size: Int!, $cursor: String) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
pullRequest(number: $number) {
|
||||||
|
reviewThreads(first: $size, after: $cursor) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
nodes {
|
||||||
|
isResolved
|
||||||
|
isOutdated
|
||||||
|
path
|
||||||
|
line
|
||||||
|
comments(first: 100) {
|
||||||
|
pageInfo { hasNextPage }
|
||||||
|
nodes { author { login } body diffHunk createdAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GRAPHQL
|
||||||
|
|
||||||
|
threads='[]'
|
||||||
|
cursor=""
|
||||||
|
page=1
|
||||||
|
while [ "$page" -le "$PAGE_MAX" ]; do
|
||||||
|
if [ -z "$cursor" ]; then
|
||||||
|
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
|
||||||
|
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE")"
|
||||||
|
else
|
||||||
|
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
|
||||||
|
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE" \
|
||||||
|
-f cursor="$cursor")"
|
||||||
|
fi
|
||||||
|
# GraphQL answers a PR number that does not exist with a null pullRequest
|
||||||
|
# and no error at all, so this is the check that catches it rather than
|
||||||
|
# adding null to an array and failing with jq's wording.
|
||||||
|
if [ "$(jq -r '.data.repository.pullRequest // "null"' <<< "$json")" = "null" ]; then
|
||||||
|
echo "github-pr: no pull request ${owner_repo}#${pr_number}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
threads="$(jq -n --argjson a "$threads" --argjson b "$json" \
|
||||||
|
'$a + $b.data.repository.pullRequest.reviewThreads.nodes')"
|
||||||
|
if [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<< "$json")" != "true" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
cursor="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<< "$json")"
|
||||||
|
page=$((page + 1))
|
||||||
|
done
|
||||||
|
if [ "$page" -gt "$PAGE_MAX" ]; then
|
||||||
|
echo "warning: stopped reading review threads after $PAGE_MAX pages - some may be missing" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A thread whose replies did not all fit is reported rather than silently
|
||||||
|
# truncated, for the same reason the page bound is: a feedback tool that drops
|
||||||
|
# the comment someone is waiting on is worse than one that says it might have.
|
||||||
|
truncated="$(jq '[.[] | select(.comments.pageInfo.hasNextPage)] | length' <<< "$threads")"
|
||||||
|
[ "$truncated" -eq 0 ] || \
|
||||||
|
echo "warning: $truncated thread(s) have more replies than were fetched" >&2
|
||||||
|
|
||||||
|
resolved_count="$(jq '[.[] | select(.isResolved)] | length' <<< "$threads")"
|
||||||
|
if [ "$show_all" -eq 1 ]; then
|
||||||
|
visible="$threads"
|
||||||
|
else
|
||||||
|
visible="$(jq '[.[] | select(.isResolved | not)]' <<< "$threads")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The hunk, not just the path, and the same layout gitea-pr prints: whoever
|
||||||
|
# reads one of these tools should not have to learn the other's output.
|
||||||
|
jq -r '.[]
|
||||||
|
| . as $t
|
||||||
|
| .comments.nodes[]
|
||||||
|
| "[\(.author.login // "?") @ \($t.path):\($t.line // "?")\(if $t.isResolved then " (resolved)" else "" end)\(if $t.isOutdated then " (outdated)" else "" end)]\n\($t.comments.nodes[0].diffHunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"' \
|
||||||
|
<<< "$visible"
|
||||||
|
|
||||||
|
if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
|
||||||
|
echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
|
||||||
|
fi
|
||||||
@@ -24,7 +24,25 @@ capabilities = pr issues
|
|||||||
pr_command = gh pr
|
pr_command = gh pr
|
||||||
issue_command = gh issue
|
issue_command = gh issue
|
||||||
merge_block = gh pr merge
|
merge_block = gh pr merge
|
||||||
|
# How this CLI can be pointed at a repository other than the one the session
|
||||||
|
# was launched against. `gh` takes --repo/-R on essentially every subcommand
|
||||||
|
# and reads GH_REPO from the environment, so the ordinary path can file an
|
||||||
|
# issue or leave a comment anywhere the token reaches (#187). The gitea CLIs
|
||||||
|
# have no equivalent - they resolve the repo from origin and refuse another
|
||||||
|
# host - which is why these keys are empty for that forge rather than absent
|
||||||
|
# from the schema.
|
||||||
|
#
|
||||||
|
# Fast-fail, not a boundary, exactly as merge_block is: the session holds the
|
||||||
|
# token and could call the API directly. What actually bounds this is the
|
||||||
|
# scope of push_token_file, which is why docs/providers.md says a
|
||||||
|
# repository-scoped fine-grained PAT is the expected shape.
|
||||||
|
repo_flags = --repo -R
|
||||||
|
repo_env = GH_REPO
|
||||||
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
|
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
|
||||||
|
# See the gitea manifest's note: this is the key an identity supplies and a
|
||||||
|
# project may not. The read-only [github] token_file is core's rather than
|
||||||
|
# this forge's, and is not here for that reason.
|
||||||
|
identity_keys = push_token_file
|
||||||
keys = push_token_file:?path
|
keys = push_token_file:?path
|
||||||
requires = push_token_file
|
requires = push_token_file
|
||||||
fixed_hosts = github.com ssh.github.com api.github.com
|
fixed_hosts = github.com ssh.github.com api.github.com
|
||||||
@@ -33,5 +51,11 @@ hosts_allowlisted_by_core = on
|
|||||||
# declared: the entrypoint links the merge shadow over a command only when it
|
# 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
|
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise rather
|
||||||
# than leave a merge block that looks enforced and is not.
|
# than leave a merge block that looks enforced and is not.
|
||||||
install = system:gh
|
#
|
||||||
|
# github-pr is dejima's, and exists for one thing gh cannot do: thread
|
||||||
|
# resolution lives only in the GraphQL API, so `gh pr view --comments` shows
|
||||||
|
# resolved and unresolved review threads alike and an agent working through
|
||||||
|
# feedback re-answers what was closed a week ago (#186). It reads and never
|
||||||
|
# writes, so nothing is shadowed over it.
|
||||||
|
install = system:gh bin/github-pr
|
||||||
env_export = GH_TOKEN=@push_token_file
|
env_export = GH_TOKEN=@push_token_file
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
|
|||||||
Tell the user to run, outside the sandbox:
|
Tell the user to run, outside the sandbox:
|
||||||
dejima-project approve \"$project_dir\"
|
dejima-project approve \"$project_dir\"
|
||||||
(or, for one domain right now:
|
(or, for one domain right now:
|
||||||
bin/dejima-allow --container $container_id <domain>)"
|
dejima-allow --container $container_id <domain>)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Notification/Stop hook. bin/dejima registers it, per session, into the
|
# Notification hook. The selected harness's plugin registers it, per session,
|
||||||
# throwaway settings.json copy it generates for a session that has remote
|
# into the throwaway settings.json copy bin/dejima generates for a session
|
||||||
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
|
# that has remote access enabled, and nowhere else. Every sandbox gets such a
|
||||||
# from the operator's real settings.json; registering it in that real file
|
# copy, derived from the operator's real settings.json; registering it there
|
||||||
# instead would put this hook into all of them - and into the host's own
|
# instead would put this hook into all of them - and into the host's own
|
||||||
# Claude Code, which reads that file directly - where there is no marker
|
# Claude Code, which reads that file directly - where there is no marker
|
||||||
# directory to write into. The copy is mounted read-only (see
|
# directory to write into. The copy is mounted read-only (see
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ link_bin dejima-project
|
|||||||
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
|
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
|
||||||
link_bin dejima-audit
|
link_bin dejima-audit
|
||||||
link_bin dejima-epitaph
|
link_bin dejima-epitaph
|
||||||
|
link_bin dejima-update-check
|
||||||
|
|
||||||
# Remote access (opt-in, see docs/remote-access.md). All three are inert
|
# Remote access (opt-in, see docs/remote-access.md). All three are inert
|
||||||
# without [remote-access] enabled = on - they refuse to run at all - so
|
# without [remote-access] enabled = on - they refuse to run at all - so
|
||||||
|
|||||||
+811
-5
@@ -27,7 +27,7 @@ setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
teardown() {
|
teardown() {
|
||||||
rm -rf "$CONFIG_DIR" ${STUB_DIR:+"$STUB_DIR"}
|
rm -rf "$CONFIG_DIR" ${STUB_DIR:+"$STUB_DIR"} ${UPD_DIR:+"$UPD_DIR"}
|
||||||
}
|
}
|
||||||
|
|
||||||
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||||
@@ -487,6 +487,145 @@ write_config() {
|
|||||||
|| { echo "the refusal does not name the key: $output"; return 1; }
|
|| { echo "the refusal does not name the key: $output"; return 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- identities: the half of a project's config that belongs to a person -----
|
||||||
|
#
|
||||||
|
# A project's .dejima/ is committed and shared; an ssh key path and a token
|
||||||
|
# path are per-user and per-machine. An identity is where those live, so a
|
||||||
|
# project can name an account without naming a path.
|
||||||
|
|
||||||
|
_identity_fixture() {
|
||||||
|
ID_DIR="$(mktemp -d)"
|
||||||
|
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
|
||||||
|
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
|
||||||
|
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
|
||||||
|
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||||
|
"token_file = $ID_DIR/default-token" \
|
||||||
|
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
|
||||||
|
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
|
||||||
|
'[identity.oss]' "ssh_key = $ID_DIR/oss-key" \
|
||||||
|
'[identity.keyless]' "token_file = $ID_DIR/work-token"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an identity supplies the ssh key and the forge credential" {
|
||||||
|
_identity_fixture
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
grep -qx "forge.gitea.token_file=$ID_DIR/work-token" <<< "$output" \
|
||||||
|
|| { echo "$output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an identity that leaves a credential to the section beneath it is refused" {
|
||||||
|
# This used to fall through: oss names no token_file, so [forge.gitea]
|
||||||
|
# answered for it, and a session launched as oss talked to the forge as
|
||||||
|
# whichever account owns that token. An identity is how one session's
|
||||||
|
# account is kept separate from another's, so a credential crossing that
|
||||||
|
# line is the feature not working rather than a convenience it offers.
|
||||||
|
_identity_fixture
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
|
||||||
|
[ "$status" -ne 0 ] || { echo "the borrowed token was accepted: $output"; return 1; }
|
||||||
|
# Which credential, by name - "something is wrong with your identity" is
|
||||||
|
# not something an operator can act on.
|
||||||
|
[[ "$output" == *"[forge.gitea] token_file"* ]] \
|
||||||
|
|| { echo "the refusal does not name the credential: $output"; return 1; }
|
||||||
|
# And nothing resolved is printed, for the same reason the missing-section
|
||||||
|
# refusal prints nothing: it would show this machine's token under oss.
|
||||||
|
[[ "$output" != *"forge.gitea.token_file=$ID_DIR/default-token"* ]] \
|
||||||
|
|| { echo "printed the borrowed credential anyway: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an identity that leaves the ssh key to [core] is refused" {
|
||||||
|
# The other half, and the one the ambiguity was about: [core] ssh_key is
|
||||||
|
# the key a session gets when no identity is named, so an identity that
|
||||||
|
# names none is asking to push as itself with the default account's key.
|
||||||
|
_identity_fixture
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity keyless --print-config
|
||||||
|
[ "$status" -ne 0 ] || { echo "the borrowed key was accepted: $output"; return 1; }
|
||||||
|
[[ "$output" == *"[core] ssh_key"* ]] \
|
||||||
|
|| { echo "the refusal does not name the key: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a credential belonging to a forge this launch does not use is not borrowed" {
|
||||||
|
# Only the selected forge's keys. Refusing over a value nothing on this
|
||||||
|
# launch reads would make an unrelated section able to block a launch.
|
||||||
|
_identity_fixture
|
||||||
|
printf '[forge.github]\npush_token_file = %s\n' "$ID_DIR/default-token" \
|
||||||
|
>> "$CONFIG_DIR/config"
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||||
|
[ "$status" -eq 0 ] \
|
||||||
|
|| { echo "an unused forge's credential blocked the launch: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "with no identity named, the config's own credentials are the ones used" {
|
||||||
|
# The unnamed default, which is what [core] ssh_key means. Nothing about
|
||||||
|
# the refusals above touches a host that never names an identity.
|
||||||
|
_identity_fixture
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
grep -qx "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|
||||||
|
|| { echo "$output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an identity that names nothing is refused, not ignored" {
|
||||||
|
# The whole point of the refusal: falling through to this machine's own
|
||||||
|
# credentials would push under the wrong account with nothing saying so.
|
||||||
|
# Pushing under the wrong key fails loudly; pushing under the default key
|
||||||
|
# when you asked for another does not fail at all.
|
||||||
|
_identity_fixture
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity wrok --print-config
|
||||||
|
[ "$status" -ne 0 ] || { echo "a nonexistent identity was accepted"; return 1; }
|
||||||
|
[[ "$output" == *"wrok"* ]] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" != *"core.ssh_key="* ]] \
|
||||||
|
|| { echo "printed a resolved config under an identity that does not exist"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a flag beats the identity, and the identity beats the file" {
|
||||||
|
_identity_fixture
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
# [core] identity in the file, overridden by the flag.
|
||||||
|
write_config '[core]' 'identity = work' "ssh_key = $ID_DIR/default-key" \
|
||||||
|
'[identity.work]' "ssh_key = $ID_DIR/work-key" \
|
||||||
|
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" \
|
||||||
|
|| { echo "--identity did not beat [core] identity: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an identity may only set what a person owns" {
|
||||||
|
# Not a second config file with different rules about what may be in it.
|
||||||
|
_identity_fixture
|
||||||
|
write_config '[core]' 'forge = gitea' '[identity.work]' 'firewall = off'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity work --print-config
|
||||||
|
[ "$status" -ne 0 ] || { echo "an identity set [core] firewall"; return 1; }
|
||||||
|
[[ "$output" == *"firewall"* ]] || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an identity's git name reaches the container's gitconfig copy" {
|
||||||
|
# Appended to the COPY dejima already makes, so the real ~/.gitconfig is
|
||||||
|
# untouched - which was already true and is what the copy is for.
|
||||||
|
_identity_fixture
|
||||||
|
grep -q 'identity_git_field' "$REPO_ROOT/bin/dejima" \
|
||||||
|
|| { echo "the launcher no longer reads the identity's git name"; return 1; }
|
||||||
|
# Written with git itself rather than printf: the value is the operator's
|
||||||
|
# own text, and a name with a quote or a backslash in it has to survive
|
||||||
|
# into a file git reads back the same way.
|
||||||
|
grep -q 'git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig"' "$REPO_ROOT/bin/dejima" \
|
||||||
|
|| { echo "the git name is no longer written with git"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
@test "--harness selects a harness for one launch without changing the default" {
|
@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
|
# 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
|
# to. Everything else was already in place - harnesses configure side by
|
||||||
@@ -639,6 +778,88 @@ write_config() {
|
|||||||
[[ "$output" == *"core.command=nvim"* ]]
|
[[ "$output" == *"core.command=nvim"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the narrowing flags only ever narrow" {
|
||||||
|
# Each of these turns something off for one launch and has no counterpart
|
||||||
|
# that turns it on. A config saying off is a decision; a flag that could
|
||||||
|
# undo it in passing would make the file's answer the weaker one.
|
||||||
|
write_config '[logging]' 'loki_url = https://loki.example.com' 'conversation = on' \
|
||||||
|
'[epitaph]' 'enabled = on' \
|
||||||
|
'[audit]' 'strict = off'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[[ "$output" == *"logging.conversation=on"* ]] || { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
run "$REPO_ROOT/bin/dejima" --no-conversation --print-config
|
||||||
|
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
|
||||||
|
# and only that: the DNS and firewall log still ships.
|
||||||
|
[[ "$output" == *"logging.loki_url=https://loki.example.com"* ]] \
|
||||||
|
|| { echo "--no-conversation took the whole log with it"; return 1; }
|
||||||
|
|
||||||
|
run "$REPO_ROOT/bin/dejima" --no-log --print-config
|
||||||
|
grep -qx 'logging.loki_url=' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
# --no-log implies --no-conversation: shipping the transcript needs an
|
||||||
|
# endpoint, and leaving the key on while clearing the endpoint would read
|
||||||
|
# as conversation logging still being in force.
|
||||||
|
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
run "$REPO_ROOT/bin/dejima" --no-epitaph --print-config
|
||||||
|
[[ "$output" == *"epitaph.enabled=off"* ]] || { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
run "$REPO_ROOT/bin/dejima" --strict-audit --print-config
|
||||||
|
[[ "$output" == *"audit.strict=on"* ]] || { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
# None of them takes a value, so none can be turned back on by passing one.
|
||||||
|
local f
|
||||||
|
for f in --no-log --no-conversation --no-epitaph --strict-audit; do
|
||||||
|
run "$REPO_ROOT/bin/dejima" "$f" on --print-config
|
||||||
|
[ "$status" -ne 0 ] || { echo "$f accepted a value: $output"; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a harness-key flag sets the selected harness's key" {
|
||||||
|
# --model is one flag over harnesses that spell the setting differently or
|
||||||
|
# do not have it at all, so what it resolves to depends on the selection.
|
||||||
|
write_config '[core]' 'harness = cline' \
|
||||||
|
'[harness.cline]' 'model = from-the-file'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[[ "$output" == *"harness.cline.model=from-the-file"* ]] || { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
run "$REPO_ROOT/bin/dejima" --model from-the-flag --base-url http://127.0.0.1:8080 \
|
||||||
|
--context-window 60000 --print-config
|
||||||
|
[[ "$output" == *"harness.cline.model=from-the-flag"* ]] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"harness.cline.base_url=http://127.0.0.1:8080"* ]] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"harness.cline.context_window=60000"* ]] || { echo "$output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a harness-key flag is resolved after every flag, not where it appears" {
|
||||||
|
# --harness may come after --model, so which harness the key belongs to is
|
||||||
|
# not known until the whole line has been read. Resolving eagerly would
|
||||||
|
# bind the flag to whatever the config happened to say.
|
||||||
|
write_config '[core]' 'harness = claude-code'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --model chosen --harness cline --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"harness.cline.model=chosen"* ]] || { echo "$output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a harness-key flag is refused for a harness without that key" {
|
||||||
|
# Silently doing nothing is the failure worth avoiding: --model against a
|
||||||
|
# harness with no model setting reads as having chosen one.
|
||||||
|
write_config '[core]' 'harness = claude-code'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --model x --print-config
|
||||||
|
[ "$status" -ne 0 ] || { echo "--model was accepted for claude-code"; return 1; }
|
||||||
|
[[ "$output" == *"claude-code"* ]] || { echo "the refusal does not name the harness: $output"; return 1; }
|
||||||
|
[[ "$output" == *"model"* ]] || { echo "the refusal does not name the key: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a harness-key flag names itself in a validation failure" {
|
||||||
|
# _set_override reports the key by default, and --context-window's key is
|
||||||
|
# context_window - an option nobody can type.
|
||||||
|
write_config '[core]' 'harness = cline'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --context-window 5 --print-config
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"--context-window"* ]] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" != *"--context_window"* ]] || { echo "reported the key, not the flag: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@test "a flag value is validated against the same schema type" {
|
@test "a flag value is validated against the same schema type" {
|
||||||
write_config '[core]' 'firewall = on'
|
write_config '[core]' 'firewall = on'
|
||||||
run "$REPO_ROOT/bin/dejima" --cmd 'rm -rf /' --print-config
|
run "$REPO_ROOT/bin/dejima" --cmd 'rm -rf /' --print-config
|
||||||
@@ -1199,6 +1420,12 @@ exit 0
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$STUB_DIR/docker"
|
chmod +x "$STUB_DIR/docker"
|
||||||
docker_log="$(mktemp)"
|
docker_log="$(mktemp)"
|
||||||
|
# A launch runs dejima-update-check, which would ls-remote this checkout's
|
||||||
|
# own origin. Off here rather than in each fixture's config, because the
|
||||||
|
# launch is what triggers it. Its cache needs nothing further: it lands
|
||||||
|
# under $HOME, which every launch fixture already points somewhere of its
|
||||||
|
# own.
|
||||||
|
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
|
||||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
|
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
|
||||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||||
run "$REPO_ROOT/bin/dejima" "$1"
|
run "$REPO_ROOT/bin/dejima" "$1"
|
||||||
@@ -1319,6 +1546,8 @@ exit 0
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$STUB_DIR/docker"
|
chmod +x "$STUB_DIR/docker"
|
||||||
docker_log="$(mktemp)"
|
docker_log="$(mktemp)"
|
||||||
|
# Same as _rw_launch: the launch would otherwise reach the network.
|
||||||
|
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
|
||||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||||
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
|
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
|
||||||
@@ -1755,21 +1984,34 @@ _epitaph_fixture() {
|
|||||||
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||||
mkdir -p "$EPI_TR/$EPI_SLUG"
|
mkdir -p "$EPI_TR/$EPI_SLUG"
|
||||||
# The transcripts directory reaches the tool through the harness's own
|
# The transcripts directory reaches the tool through the harness's own
|
||||||
# config key now, not as an argument: where transcripts live is the
|
# config key, not as an argument: where transcripts live is the harness's,
|
||||||
# harness's, and dejima-epitaph no longer knows the shape of it.
|
# and dejima-epitaph no longer knows the shape of it. `list` needs the same
|
||||||
|
# key to report whether the session behind an epitaph is still resumable.
|
||||||
write_config '[epitaph]' "dir = $EPI_STORE" \
|
write_config '[epitaph]' "dir = $EPI_STORE" \
|
||||||
'[harness.claude-code]' "projects_dir = $EPI_TR"
|
'[harness.claude-code]' "projects_dir = $EPI_TR"
|
||||||
}
|
}
|
||||||
|
|
||||||
# One compact_boundary carrying the metadata, then the summary itself.
|
# One compact_boundary carrying the metadata, then the summary itself. The
|
||||||
|
# session id matches the transcript's filename, as Claude Code's own do -
|
||||||
|
# that pairing is what `list` resolves to decide a session is still there.
|
||||||
_epitaph_transcript() {
|
_epitaph_transcript() {
|
||||||
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
|
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
|
||||||
|
local ts="${4:-2026-01-02T03:04:05.000Z}"
|
||||||
{
|
{
|
||||||
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
|
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
|
||||||
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"s-1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"%s"}}\n' "$uid" "$text"
|
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"%s","timestamp":"%s","message":{"content":"%s"}}\n' "$uid" "$1" "$ts" "$text"
|
||||||
} > "$f"
|
} > "$f"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Three epitaphs in a known order, so an index means something to assert on.
|
||||||
|
_epitaph_three() {
|
||||||
|
_epitaph_fixture
|
||||||
|
_epitaph_transcript s1 "the firewall drops IPv6" aaaa1111 2026-01-02T03:04:05.000Z
|
||||||
|
_epitaph_transcript s2 "the DNS proxy rewrote resolv" bbbb2222 2026-02-03T04:05:06.000Z
|
||||||
|
_epitaph_transcript s3 "worked out the audit baseline" cccc3333 2026-03-04T05:06:07.000Z
|
||||||
|
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
@test "an epitaph is written out of the container's reach" {
|
@test "an epitaph is written out of the container's reach" {
|
||||||
_epitaph_fixture
|
_epitaph_fixture
|
||||||
_epitaph_transcript s1 "the thing this session worked out"
|
_epitaph_transcript s1 "the thing this session worked out"
|
||||||
@@ -1842,6 +2084,128 @@ _epitaph_transcript() {
|
|||||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "list shows one row per epitaph, oldest first" {
|
||||||
|
_epitaph_three
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||||
|
# Chronological by the timestamp in the name, which is what makes an
|
||||||
|
# index stable enough for `show <n>` to mean anything.
|
||||||
|
grep -q '^ *1 2026-01-02' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
grep -q '^ *3 2026-03-04' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "list says whether the session behind an epitaph is still resumable" {
|
||||||
|
# Resuming the real session beats reading a summary of it, so the listing
|
||||||
|
# has to say which of the two is still on offer.
|
||||||
|
_epitaph_three
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||||
|
[ "$(grep -c 'resumable' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||||
|
rm -f "$EPI_TR/$EPI_SLUG/s2.jsonl"
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||||
|
[ "$(grep -c 'resumable' <<< "$output")" -eq 2 ] || { echo "$output"; return 1; }
|
||||||
|
grep -q '^ *2 .*aged out' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "reading the store is not gated on [epitaph] enabled" {
|
||||||
|
# Turning epitaph off must not strand what it already kept: the operator
|
||||||
|
# who switches it off is the one most likely to want to look at, and get
|
||||||
|
# rid of, what is there.
|
||||||
|
_epitaph_three
|
||||||
|
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off' \
|
||||||
|
'[harness.claude-code]' "projects_dir = $EPI_TR"
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||||
|
# Writing still is.
|
||||||
|
_epitaph_transcript s4 "should not be kept" dddd4444 2026-04-05T06:07:08.000Z
|
||||||
|
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
|
||||||
|
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 3 ]
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "list says the resumable state is unknown rather than guessing it" {
|
||||||
|
# Which sessions still have a transcript is the harness's answer. With no
|
||||||
|
# harness there is nobody to ask, and "aged out" would be a claim this
|
||||||
|
# cannot support - the transcripts may be sitting right there. The config
|
||||||
|
# is written directly because write_config seeds a harness otherwise.
|
||||||
|
_epitaph_three
|
||||||
|
printf '[epitaph]\ndir = %s\n' "$EPI_STORE" > "$CONFIG_DIR/config"
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[ "$(grep -c 'resumable' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[ "$(grep -c 'aged out' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "search finds an epitaph by content and reports its listing index" {
|
||||||
|
# The names are a timestamp and a uuid, so nothing about a filename says
|
||||||
|
# which session it was. Search is the only way in.
|
||||||
|
_epitaph_three
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" search "resolv" "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
grep -q '1 of 3' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
# The index it prints is the position in the same listing `list` shows,
|
||||||
|
# so `show <n>` straight after a search opens what the search found.
|
||||||
|
grep -qE '^ *2 20260203' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" show 2 "$EPI_WS"
|
||||||
|
grep -q 'the DNS proxy rewrote resolv' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "search matching nothing says so without failing" {
|
||||||
|
_epitaph_three
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" search "nothing-in-any-of-them" "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
grep -q 'no epitaph matches' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "show writes to stdout when there is no terminal to open an editor on" {
|
||||||
|
# `dejima-epitaph show | grep` must not try to launch an editor, and a
|
||||||
|
# test is the same case as a pipe.
|
||||||
|
_epitaph_three
|
||||||
|
run env VISUAL=/bin/false EDITOR=/bin/false \
|
||||||
|
"$REPO_ROOT/bin/dejima-epitaph" show 1 "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
grep -q 'the firewall drops IPv6' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
# Newest by default.
|
||||||
|
run env VISUAL=/bin/false "$REPO_ROOT/bin/dejima-epitaph" show "$EPI_WS"
|
||||||
|
grep -q 'worked out the audit baseline' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "show opens the editor and leaves the epitaph at 0600" {
|
||||||
|
command -v script >/dev/null 2>&1 || skip "no script(1) to allocate a pty"
|
||||||
|
_epitaph_three
|
||||||
|
local ed; ed="$(mktemp -d)"
|
||||||
|
# Rewrites through a temp file and a rename, which is what an editor
|
||||||
|
# does and what drops the mode to the umask's.
|
||||||
|
printf '#!/bin/bash\nprintf "%%s\\n" "$1" > %s/got\nprintf edited > "$1.t" && mv "$1.t" "$1"\n' \
|
||||||
|
"$ed" > "$ed/ed"
|
||||||
|
chmod +x "$ed/ed"
|
||||||
|
local f; f="$(find "$EPI_STORE" -name '*.md' | sort | head -1)"
|
||||||
|
chmod 600 "$f"
|
||||||
|
run env VISUAL="$ed/ed" EDITOR="$ed/ed" script -qec \
|
||||||
|
"$REPO_ROOT/bin/dejima-epitaph show 1 $EPI_WS" /dev/null
|
||||||
|
[ -f "$ed/got" ] || { echo "the editor was never run: $output"; return 1; }
|
||||||
|
[ "$(cat "$ed/got")" = "$f" ] || { echo "opened $(cat "$ed/got"), wanted $f"; return 1; }
|
||||||
|
[ "$(stat -c %a "$f")" = "600" ] \
|
||||||
|
|| { echo "left at $(stat -c %a "$f") after the editor rewrote it"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS" "$ed"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an unknown epitaph subcommand is refused" {
|
||||||
|
_epitaph_fixture
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" delete-everything "$EPI_WS"
|
||||||
|
[ "$status" -eq 2 ] || { echo "$output"; return 1; }
|
||||||
|
grep -q 'usage:' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
@test "the epitaph store is not mounted into the container" {
|
@test "the epitaph store is not mounted into the container" {
|
||||||
# The whole point: a session must not be able to read what earlier
|
# The whole point: a session must not be able to read what earlier
|
||||||
# sessions concluded, or edit what this one is about to record.
|
# sessions concluded, or edit what this one is about to record.
|
||||||
@@ -2185,6 +2549,53 @@ STUB
|
|||||||
rm -rf "$workspace" "$RW_HOME" "$root" "${docker_log:-}"
|
rm -rf "$workspace" "$RW_HOME" "$root" "${docker_log:-}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "a forge credential never reaches the docker command line" {
|
||||||
|
# /proc/<pid>/cmdline is world-readable, so a secret passed as
|
||||||
|
# `-e NAME=value` is visible to every process on the host via ps - and
|
||||||
|
# from there into whatever captures ps output. That is how #226 was
|
||||||
|
# found: an operator ran ps while debugging something unrelated and
|
||||||
|
# pasted the result.
|
||||||
|
#
|
||||||
|
# `-e NAME` takes the value from the launcher's own environment instead,
|
||||||
|
# where it lives in /proc/<pid>/environ - mode 0400, owner only.
|
||||||
|
#
|
||||||
|
# This repository already made the argument one layer down: gitea-pr
|
||||||
|
# feeds curl its auth header on stdin rather than as an argument, and
|
||||||
|
# says why. The test is here so the two cannot drift apart again.
|
||||||
|
_rw_setup
|
||||||
|
workspace="$(mktemp -d)"
|
||||||
|
local tokfile="$RW_HOME/tok" secret="tok-$RANDOM-do-not-leak"
|
||||||
|
printf '%s\n' "$secret" > "$tokfile"
|
||||||
|
write_config '[core]' "ssh_key = $RW_KEY" 'forge = gitea' 'workflow = local-only' \
|
||||||
|
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||||
|
"token_file = $tokfile"
|
||||||
|
STUB_DIR="$(mktemp -d)"
|
||||||
|
local argv_log; argv_log="$(mktemp)"
|
||||||
|
cat > "$STUB_DIR/docker" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf '%s\n' "$*" >> "$SANDBOX_TEST_ARGV_LOG"
|
||||||
|
exit 0
|
||||||
|
STUB
|
||||||
|
chmod +x "$STUB_DIR/docker"
|
||||||
|
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||||
|
SANDBOX_TEST_DOCKER_LOG=/dev/null SANDBOX_TEST_ARGV_LOG="$argv_log" \
|
||||||
|
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
local argv; argv="$(cat "$argv_log")"
|
||||||
|
[ -n "$argv" ] || { echo "the stub captured no docker invocation"; return 1; }
|
||||||
|
# The whole point.
|
||||||
|
[[ "$argv" != *"$secret"* ]] \
|
||||||
|
|| { echo "the token is in the docker command line"; return 1; }
|
||||||
|
# And it is still passed, by name, or the forge simply has no credential.
|
||||||
|
[[ "$argv" == *"-e GITEA_TOKEN"* ]] \
|
||||||
|
|| { echo "GITEA_TOKEN is not passed at all: $argv"; return 1; }
|
||||||
|
# Specifically the bare form. `-e GITEA_TOKEN=` would also match above.
|
||||||
|
[[ "$argv" != *"-e GITEA_TOKEN="* ]] \
|
||||||
|
|| { echo "passed as NAME=value after all: $argv"; return 1; }
|
||||||
|
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR" "$argv_log"
|
||||||
|
}
|
||||||
|
|
||||||
@test "the harness layer creates the harness's own state directories and environment" {
|
@test "the harness layer creates the harness's own state directories and environment" {
|
||||||
# These were in the base image, imposed on every image dejima builds. They
|
# These were in the base image, imposed on every image dejima builds. They
|
||||||
# are the selected harness's now, and have to actually reach its layer -
|
# are the selected harness's now, and have to actually reach its layer -
|
||||||
@@ -2226,3 +2637,398 @@ STUB
|
|||||||
|| { echo "the declared environment is missing from the layer:"; echo "$df"; return 1; }
|
|| { echo "the declared environment is missing from the layer:"; echo "$df"; return 1; }
|
||||||
rm -rf "$workspace" "$RW_HOME" "$dockerfile_log"
|
rm -rf "$workspace" "$RW_HOME" "$dockerfile_log"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- dejima-update-check: is this checkout behind the branch it tracks? ---
|
||||||
|
#
|
||||||
|
# A real checkout and a real repository for it to track, neither of them this
|
||||||
|
# one. bin/ and the provider trees are copied rather than symlinked because
|
||||||
|
# the script finds its own repository through readlink -f "$0", which would
|
||||||
|
# follow a symlink straight back to the repository running the tests.
|
||||||
|
#
|
||||||
|
# No `git push` anywhere below: dejima's own forge guard refuses a push to a
|
||||||
|
# path, so a fixture built that way passes on a host and fails inside a
|
||||||
|
# sandbox. The remote is an ordinary repository committed to directly.
|
||||||
|
# GIT_CONFIG_GLOBAL and _SYSTEM neutralised alongside the identity this
|
||||||
|
# already pins: a host with commit.gpgsign on, or an insteadOf rewrite, would
|
||||||
|
# otherwise reach into a fixture that is supposed to depend on nothing.
|
||||||
|
_upd_git() {
|
||||||
|
local d="$1"; shift
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
|
git -C "$d" -c user.email=t@t -c user.name=t -c commit.gpgsign=false "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
_upd_commit() {
|
||||||
|
_upd_git "$1" commit -q --allow-empty -m "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
_upd_fixture() {
|
||||||
|
UPD_DIR="$(mktemp -d)"
|
||||||
|
UPD_ORIGIN="$UPD_DIR/origin"
|
||||||
|
UPD_CHECKOUT="$UPD_DIR/checkout"
|
||||||
|
# Stated, not inherited: the cache lands under $HOME, and a test that left
|
||||||
|
# that alone would write into whoever ran it.
|
||||||
|
export HOME="$UPD_DIR/home"
|
||||||
|
# -b trunk so the fixture does not depend on the host's
|
||||||
|
# init.defaultBranch, and so no guard has an opinion about the name.
|
||||||
|
git init -q -b trunk "$UPD_ORIGIN"
|
||||||
|
_upd_commit "$UPD_ORIGIN" first
|
||||||
|
git clone -q "$UPD_ORIGIN" "$UPD_CHECKOUT"
|
||||||
|
cp -r "$REPO_ROOT/bin" "$REPO_ROOT/forges" "$REPO_ROOT/harnesses" \
|
||||||
|
"$REPO_ROOT/workflows" "$REPO_ROOT/tools" "$UPD_CHECKOUT/"
|
||||||
|
printf '[core]\nharness = claude-code\n' > "$CONFIG_DIR/config"
|
||||||
|
}
|
||||||
|
|
||||||
|
_upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
|
||||||
|
|
||||||
|
# Where _cache_file puts it, named once rather than at each assertion.
|
||||||
|
_upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
|
||||||
|
|
||||||
|
@test "a checkout level with its remote says nothing" {
|
||||||
|
_upd_fixture
|
||||||
|
run _upd_check
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ] || { echo "said: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a checkout behind its remote is told, and told how to update" {
|
||||||
|
_upd_fixture
|
||||||
|
_upd_commit "$UPD_ORIGIN" second
|
||||||
|
run _upd_check
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"origin/trunk"* ]] \
|
||||||
|
|| { echo "does not name what it is behind: $output"; return 1; }
|
||||||
|
# The path, because the checkout that is behind is not necessarily the
|
||||||
|
# directory the operator is standing in when they read this.
|
||||||
|
[[ "$output" == *"git -C $UPD_CHECKOUT pull"* ]] \
|
||||||
|
|| { echo "does not say how to update: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the commit count is given only when the commits are actually here" {
|
||||||
|
_upd_fixture
|
||||||
|
_upd_commit "$UPD_ORIGIN" second
|
||||||
|
# Nothing fetched, so the tip is a commit this checkout has never seen and
|
||||||
|
# there is no number to give. Counting what it can reach would report the
|
||||||
|
# commits it already has, which is zero, not the one it is missing.
|
||||||
|
run _upd_check
|
||||||
|
[[ "$output" != *" 0 "* ]] || { echo "invented a count: $output"; return 1; }
|
||||||
|
[[ "$output" == *"has commits this checkout does not"* ]] \
|
||||||
|
|| { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
git -C "$UPD_CHECKOUT" fetch -q origin
|
||||||
|
rm -f "$(_upd_cache)"
|
||||||
|
run _upd_check
|
||||||
|
[[ "$output" == *"has 1 commit this checkout does not"* ]] \
|
||||||
|
|| { echo "no count once the commit is present: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a checkout ahead of its remote says nothing" {
|
||||||
|
_upd_fixture
|
||||||
|
_upd_commit "$UPD_CHECKOUT" local-work
|
||||||
|
run _upd_check
|
||||||
|
[ -z "$output" ] || { echo "said: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a detached HEAD is silence, not a git error on the terminal" {
|
||||||
|
_upd_fixture
|
||||||
|
_upd_commit "$UPD_ORIGIN" second
|
||||||
|
git -C "$UPD_CHECKOUT" checkout -q --detach HEAD
|
||||||
|
run _upd_check
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ] || { echo "said: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "[core] update_check off reaches the remote not at all" {
|
||||||
|
_upd_fixture
|
||||||
|
_upd_commit "$UPD_ORIGIN" second
|
||||||
|
printf '[core]\nharness = claude-code\nupdate_check = off\n' > "$CONFIG_DIR/config"
|
||||||
|
run _upd_check
|
||||||
|
[ -z "$output" ] || { echo "said: $output"; return 1; }
|
||||||
|
# Silence alone would also be produced by asking and saying nothing. The
|
||||||
|
# cache is written on every lookup, including a failed one, so its absence
|
||||||
|
# is what shows no lookup happened.
|
||||||
|
[ ! -e "$(_upd_cache)" ] \
|
||||||
|
|| { echo "it asked anyway"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the remote is asked once, and the cached answer is compared to HEAD" {
|
||||||
|
_upd_fixture
|
||||||
|
_upd_commit "$UPD_ORIGIN" second
|
||||||
|
run _upd_check
|
||||||
|
[[ "$output" == *"commits this checkout does not"* ]] || { echo "$output"; return 1; }
|
||||||
|
|
||||||
|
# Unreachable now. A second run inside the interval still reports, which
|
||||||
|
# is what makes this a throttle on the network call rather than on the
|
||||||
|
# notice - told once and then silence would be worse than never telling.
|
||||||
|
mv "$UPD_ORIGIN" "$UPD_ORIGIN.gone"
|
||||||
|
run _upd_check
|
||||||
|
[[ "$output" == *"commits this checkout does not"* ]] \
|
||||||
|
|| { echo "the notice lasted one launch: $output"; return 1; }
|
||||||
|
|
||||||
|
# And it stops the moment HEAD catches up, from the same cached answer,
|
||||||
|
# without waiting for the interval to expire.
|
||||||
|
mv "$UPD_ORIGIN.gone" "$UPD_ORIGIN"
|
||||||
|
git -C "$UPD_CHECKOUT" fetch -q origin
|
||||||
|
git -C "$UPD_CHECKOUT" reset -q --hard origin/trunk
|
||||||
|
run _upd_check
|
||||||
|
[ -z "$output" ] || { echo "still complaining after updating: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the remote is not reached through the checkout's own git config" {
|
||||||
|
# The whole reason this tool talks to a URL from outside the repository.
|
||||||
|
# dejima mounts its own checkout writable when that is the workspace, so
|
||||||
|
# .git/config there is session-writable - and core.sshCommand names a
|
||||||
|
# command git runs on the host. Reaching the remote by name would turn a
|
||||||
|
# session's config write into host execution on the next launch, with no
|
||||||
|
# operator action at all.
|
||||||
|
_upd_fixture
|
||||||
|
# No port in the URL: git refuses to pass one to an sshCommand it classes
|
||||||
|
# as "simple", which would fail the control for a reason unrelated to what
|
||||||
|
# it tests. .invalid never resolves, so the mitigated path fails at once
|
||||||
|
# instead of reaching for an sshd that may exist on whoever runs this.
|
||||||
|
git -C "$UPD_CHECKOUT" remote set-url origin ssh://git@example.invalid/x.git
|
||||||
|
# A file rather than a message: the tool sends git's stderr to /dev/null,
|
||||||
|
# so an assertion on what it printed would hold whether the command ran or
|
||||||
|
# not. Execution has to be observed by its effect.
|
||||||
|
local ran="$UPD_DIR/repo-config-ran"
|
||||||
|
git -C "$UPD_CHECKOUT" config core.sshCommand \
|
||||||
|
"sh -c 'touch $ran; exit 1' --"
|
||||||
|
git -C "$UPD_CHECKOUT" config credential.helper "!sh -c 'touch $ran'"
|
||||||
|
|
||||||
|
run _upd_check
|
||||||
|
[ ! -e "$ran" ] || { echo "the checkout's own config ran"; return 1; }
|
||||||
|
|
||||||
|
# The control, so this cannot pass because the threat quietly went away.
|
||||||
|
# If a future git stops honouring a repository's core.sshCommand here,
|
||||||
|
# that is worth knowing rather than reading as a mitigation working.
|
||||||
|
run timeout 20 git -C "$UPD_CHECKOUT" ls-remote origin
|
||||||
|
[ -e "$ran" ] \
|
||||||
|
|| { echo "reaching it by name no longer runs repo config: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a remote URL that could name a command is refused, not run" {
|
||||||
|
_upd_fixture
|
||||||
|
local marker="$UPD_DIR/pwned" url
|
||||||
|
for url in "ext::sh -c \"touch $UPD_DIR/pwned\"" \
|
||||||
|
"--upload-pack=touch $UPD_DIR/pwned" \
|
||||||
|
"http://example.invalid/x.git"; do
|
||||||
|
# Written into the file directly: `git remote set-url` rejects a value
|
||||||
|
# starting with a dash itself, which would leave that case untested.
|
||||||
|
sed -i "s#\turl = .*#\turl = $url#" "$UPD_CHECKOUT/.git/config"
|
||||||
|
rm -f "$(_upd_cache)"
|
||||||
|
run _upd_check
|
||||||
|
[ "$status" -eq 0 ] || { echo "$url exited $status"; return 1; }
|
||||||
|
[ ! -e "$marker" ] || { echo "$url ran"; return 1; }
|
||||||
|
# Nothing recorded either, so a refused URL does not also poison the
|
||||||
|
# cache for the hour after someone fixes it.
|
||||||
|
[ ! -e "$(_upd_cache)" ] \
|
||||||
|
|| { echo "$url was attempted"; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a branch is compared against what it tracks, not against its own name" {
|
||||||
|
# `git worktree add -b x origin/development` leaves a branch called x
|
||||||
|
# tracking refs/heads/development. Asking the remote for refs/heads/x gets
|
||||||
|
# an empty answer, and an empty answer is indistinguishable from being up
|
||||||
|
# to date - so the whole check goes quiet on exactly the branches someone
|
||||||
|
# developing dejima is standing on.
|
||||||
|
_upd_fixture
|
||||||
|
git -C "$UPD_CHECKOUT" checkout -q -b feature --track origin/trunk
|
||||||
|
_upd_commit "$UPD_ORIGIN" second
|
||||||
|
run _upd_check
|
||||||
|
[[ "$output" == *"origin/trunk"* ]] \
|
||||||
|
|| { echo "compared against the wrong ref: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- what a session wrote, on its way to the operator's terminal ---
|
||||||
|
#
|
||||||
|
# Reading an epitaph is the tool's stated purpose, and every byte of one came
|
||||||
|
# from a session transcript. Same class as the audit findings, which is why
|
||||||
|
# both go through the same sanitize()/sanitize_body() in dejima-color-lib.sh.
|
||||||
|
# Colour is off in these: bats captures the output, so nothing is a terminal
|
||||||
|
# and every escape byte left is one the fixture planted.
|
||||||
|
_epitaph_hostile() {
|
||||||
|
_epitaph_fixture
|
||||||
|
# \u001b through the JSON, which is how it would actually arrive: the
|
||||||
|
# extractor decodes it, so the store holds a real ESC byte.
|
||||||
|
local f="$EPI_TR/$EPI_SLUG/s1.jsonl"
|
||||||
|
{
|
||||||
|
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"\\u001b[31mmanual","preTokens":900,"postTokens":90}}\n'
|
||||||
|
printf '{"type":"user","isCompactSummary":true,"uuid":"aaaabbbb","sessionId":"s1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"first line\\u001b[2Jcleared\\nsecond\\u0007 line"}}\n'
|
||||||
|
} > "$f"
|
||||||
|
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "showing an epitaph strips the escapes a session put in it" {
|
||||||
|
_epitaph_hostile
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" show 1 "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal"; return 1; }
|
||||||
|
[[ "$output" != *$'\007'* ]] || { echo "a BEL reached the terminal"; return 1; }
|
||||||
|
# The text either side of the escape survives - this strips control bytes,
|
||||||
|
# it does not drop the content they were hidden in.
|
||||||
|
[[ "$output" == *"first line"* ]] && [[ "$output" == *"cleared"* ]] \
|
||||||
|
|| { echo "$output"; return 1; }
|
||||||
|
# And it is still a body: the field rule would have taken the newlines
|
||||||
|
# with the rest of C0 and folded the whole epitaph onto one line.
|
||||||
|
[ "${#lines[@]}" -gt 1 ] || { echo "the body was folded to one line"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "searching epitaphs strips the escapes out of what it prints" {
|
||||||
|
_epitaph_hostile
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" search 'line' "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal"; return 1; }
|
||||||
|
[[ "$output" == *"first line"* ]] || { echo "the hit is gone entirely: $output"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "listing epitaphs strips the escapes out of the header fields" {
|
||||||
|
# trigger comes off the transcript, and the filename is built from its
|
||||||
|
# timestamp and uuid - so the row is session-authored on both sides.
|
||||||
|
_epitaph_hostile
|
||||||
|
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal"; return 1; }
|
||||||
|
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the update notice strips the escapes out of a remote's name" {
|
||||||
|
# Both the remote name and the branch it tracks are read from the
|
||||||
|
# checkout's .git/config, which is session-writable when dejima's own
|
||||||
|
# checkout is the workspace.
|
||||||
|
_upd_fixture
|
||||||
|
_upd_commit "$UPD_ORIGIN" second
|
||||||
|
git -C "$UPD_CHECKOUT" config "branch.trunk.remote" "$(printf 'ori\033[2Jgin')"
|
||||||
|
git -C "$UPD_CHECKOUT" config "remote.$(printf 'ori\033[2Jgin').url" "$UPD_ORIGIN"
|
||||||
|
git -C "$UPD_CHECKOUT" config "remote.$(printf 'ori\033[2Jgin').fetch" '+refs/heads/*:refs/remotes/o/*'
|
||||||
|
run _upd_check
|
||||||
|
[[ "$output" == *"commits this checkout does not"* ]] \
|
||||||
|
|| { echo "no notice at all: $output"; return 1; }
|
||||||
|
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a launch does not reach the network to check for updates" {
|
||||||
|
# dejima-update-check runs on every launch, and this suite drives dozens
|
||||||
|
# of them. Each gets its own HOME and so its own empty cache, so without
|
||||||
|
# the fixtures turning it off every one of them would ls-remote the real
|
||||||
|
# origin: a five-second stall apiece wherever that origin is unreachable,
|
||||||
|
# and an $output that depends on how far behind the checkout happens to
|
||||||
|
# be. CI never sees it - a pull_request checkout is a detached HEAD, and
|
||||||
|
# the tool exits on that before it looks at a remote.
|
||||||
|
local workspace git_log real_git
|
||||||
|
_rw_setup
|
||||||
|
workspace="$(mktemp -d)"; git -C "$workspace" init -q
|
||||||
|
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||||
|
git_log="$(mktemp)"
|
||||||
|
real_git="$(command -v git)"
|
||||||
|
# A wrapper rather than a replacement: bin/dejima uses git for other
|
||||||
|
# things on this path, and a stub that answered nothing would fail the
|
||||||
|
# launch for an unrelated reason. On PATH rather than in _rw_launch's own
|
||||||
|
# stub directory, which it recreates on every call.
|
||||||
|
GIT_WRAP="$(mktemp -d)"
|
||||||
|
cat > "$GIT_WRAP/git" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\\n' "\$*" >> "$git_log"
|
||||||
|
exec "$real_git" "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$GIT_WRAP/git"
|
||||||
|
PATH="$GIT_WRAP:$PATH"
|
||||||
|
_rw_launch "$workspace"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[ -s "$git_log" ] || { echo "the wrapper was never reached, so this proves nothing"; return 1; }
|
||||||
|
run grep -c 'ls-remote' "$git_log"
|
||||||
|
[ "$output" -eq 0 ] || { echo "the launch called out:"; grep ls-remote "$git_log"; return 1; }
|
||||||
|
rm -rf "$workspace" "$GIT_WRAP"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "[identity.default] is what a launch acts under when none is named" {
|
||||||
|
# The unnamed default gets a name, which is what lets it carry a git name
|
||||||
|
# and a forge credential beside the key rather than those coming from
|
||||||
|
# three different places.
|
||||||
|
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
|
||||||
|
write_config '[core]' 'forge = gitea' \
|
||||||
|
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||||
|
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||||
|
"token_file = $ID_DIR/tok" 'user_name = dejima agent'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" \
|
||||||
|
|| { echo "the default identity did not supply the key: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
|
||||||
|
# Two spellings of one thing. Which key a session pushed with is not
|
||||||
|
# something to work out from precedence rules.
|
||||||
|
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
|
||||||
|
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||||
|
'[identity.default]' "ssh_key = $ID_DIR/new-key"
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[ "$status" -ne 0 ] || { echo "both spellings were accepted: $output"; return 1; }
|
||||||
|
[[ "$output" == *"[core] ssh_key"* ]] && [[ "$output" == *"[identity.default]"* ]] \
|
||||||
|
|| { echo "the refusal does not name both: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "[core] ssh_key on its own still works, and says what replaced it" {
|
||||||
|
# Deprecated, not removed: it is how every config written before
|
||||||
|
# identities existed says this.
|
||||||
|
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
|
||||||
|
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/old-key" <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"[identity.default]"* ]] \
|
||||||
|
|| { echo "nothing said what replaced it: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a named identity still beats [identity.default]" {
|
||||||
|
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
|
||||||
|
write_config '[core]' 'forge = none' \
|
||||||
|
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||||
|
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||||
|
run "$REPO_ROOT/bin/dejima" --identity bot --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/bot-key" <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the [core] ssh_key deprecation is warned even when a launch does not use it" {
|
||||||
|
# A config with [core] ssh_key and a named identity is one migration away
|
||||||
|
# from the refusal - adding [identity.default] would stop it starting.
|
||||||
|
# Finding that out on the launch that breaks is worse than being told now,
|
||||||
|
# so the warning is about the config rather than about this launch.
|
||||||
|
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
|
||||||
|
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||||
|
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"[core] ssh_key is deprecated"* ]] \
|
||||||
|
|| { echo "no migration warning: $output"; return 1; }
|
||||||
|
# And says it is not the key in play, so the reader is not left thinking
|
||||||
|
# this launch pushed with it.
|
||||||
|
[[ "$output" == *"runs as bot"* ]] \
|
||||||
|
|| { echo "the warning does not say which account is in force: $output"; return 1; }
|
||||||
|
# The path, because "move it" is not actionable without knowing what.
|
||||||
|
[[ "$output" == *"$ID_DIR/old-key"* ]] \
|
||||||
|
|| { echo "the warning does not name the key: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a migrated config is warned about nothing" {
|
||||||
|
# The other half of a deprecation warning: it has to stop. One that fires
|
||||||
|
# after the migration is one an operator learns to ignore, which is how
|
||||||
|
# the next warning gets missed too.
|
||||||
|
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
|
||||||
|
write_config '[core]' 'forge = none' \
|
||||||
|
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
|
||||||
|
run "$REPO_ROOT/bin/dejima" --print-config
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" != *"deprecated"* ]] \
|
||||||
|
|| { echo "still warning after the migration: $output"; return 1; }
|
||||||
|
grep -qx "core.ssh_key=$ID_DIR/key" <<< "$output" \
|
||||||
|
|| { echo "the migrated key is not the one in force: $output"; return 1; }
|
||||||
|
rm -rf "$ID_DIR"
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+143
-9
@@ -27,7 +27,15 @@ import time
|
|||||||
|
|
||||||
HELPER = os.path.abspath(sys.argv[1])
|
HELPER = os.path.abspath(sys.argv[1])
|
||||||
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
|
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
|
||||||
WORK = os.environ.get("TTY_SUSPEND_WORK") or "/tmp"
|
# Required, not defaulted. The fallback was "/tmp", and the three paths below
|
||||||
|
# are then fixed names in a world-writable directory that this fixture goes on
|
||||||
|
# to WRITE and EXECUTE. Every bats caller passes a directory, so nothing
|
||||||
|
# shipped ever took that branch - but this runs on CI hosts, and a predictable
|
||||||
|
# executed path is not a thing to leave lying around for the one caller that
|
||||||
|
# forgets (#249).
|
||||||
|
WORK = os.environ.get("TTY_SUSPEND_WORK")
|
||||||
|
if not WORK:
|
||||||
|
sys.exit("tty-suspend-harness: set TTY_SUSPEND_WORK to a directory of its own")
|
||||||
RECEIVED = os.path.join(WORK, "received")
|
RECEIVED = os.path.join(WORK, "received")
|
||||||
LAUNCHER = os.path.join(WORK, "launcher.sh")
|
LAUNCHER = os.path.join(WORK, "launcher.sh")
|
||||||
CONTAINER = os.path.join(WORK, "container.py")
|
CONTAINER = os.path.join(WORK, "container.py")
|
||||||
@@ -36,12 +44,51 @@ CONTAINER = os.path.join(WORK, "container.py")
|
|||||||
# "Onward" is a file, which is what lets a test see whether a keystroke reached
|
# "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.
|
# the container or was turned into a signal before it could.
|
||||||
open(CONTAINER, "w").write('''#!/usr/bin/env python3
|
open(CONTAINER, "w").write('''#!/usr/bin/env python3
|
||||||
import os, signal, sys, termios, tty
|
import fcntl, os, signal, struct, sys, termios, tty
|
||||||
fd = 0
|
fd = 0
|
||||||
saved = termios.tcgetattr(fd)
|
saved = termios.tcgetattr(fd)
|
||||||
signal.signal(signal.SIGWINCH,
|
|
||||||
lambda *a: (sys.stdout.write("CONTAINER-REDRAW\\r\\n"), sys.stdout.flush()))
|
def _size():
|
||||||
|
return struct.unpack("HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\\0" * 8))[:2]
|
||||||
|
|
||||||
|
# Redraws only when the dimensions ACTUALLY moved, which is what both shipped
|
||||||
|
# harnesses do. Measured on a pty, bytes written in a fixed window after each
|
||||||
|
# kind of poke:
|
||||||
|
#
|
||||||
|
# idle same-size SIGWINCH size changed and back
|
||||||
|
# Claude Code 0 0 3337
|
||||||
|
# Cline 0 0 17810
|
||||||
|
# nvim (control) 0 3356 6724
|
||||||
|
#
|
||||||
|
# The first version of this stand-in redrew on the bare signal, like nvim. That
|
||||||
|
# is the one behaviour the programs dejima actually runs do not have, so the
|
||||||
|
# suite could not see that the resume poke was a no-op for both of them.
|
||||||
|
_last = [_size()]
|
||||||
|
|
||||||
|
def _on_winch(*a):
|
||||||
|
now = _size()
|
||||||
|
if now == _last[0]:
|
||||||
|
return
|
||||||
|
_last[0] = now
|
||||||
|
sys.stdout.write("CONTAINER-REDRAW\\r\\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGWINCH, _on_winch)
|
||||||
|
# The finding of #179, as something a test can observe: the client survives
|
||||||
|
# SIGSTOP and does not survive SIGTSTP, so the launcher has to start it with
|
||||||
|
# SIGTSTP already ignored. Read back rather than caught - installing a handler
|
||||||
|
# would REPLACE the inherited SIG_IGN and defeat the very thing being checked,
|
||||||
|
# which is what the first version of this did.
|
||||||
|
_ign = 0
|
||||||
|
for _line in open("/proc/self/status"):
|
||||||
|
if _line.startswith("SigIgn"):
|
||||||
|
_ign = int(_line.split()[1], 16)
|
||||||
|
if _ign & (1 << (signal.SIGTSTP - 1)):
|
||||||
|
sys.stdout.write("CONTAINER-TSTP-IGNORED\\r\\n")
|
||||||
|
sys.stdout.flush()
|
||||||
tty.setraw(fd)
|
tty.setraw(fd)
|
||||||
|
# What a full-screen harness does on startup, and does not undo on a suspend.
|
||||||
|
sys.stdout.write("\\033[?25l")
|
||||||
sys.stdout.write("CONTAINER-ATTACHED\\r\\n"); sys.stdout.flush()
|
sys.stdout.write("CONTAINER-ATTACHED\\r\\n"); sys.stdout.flush()
|
||||||
open(%r, "wb").close()
|
open(%r, "wb").close()
|
||||||
try:
|
try:
|
||||||
@@ -71,13 +118,34 @@ finally:
|
|||||||
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
|
# 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
|
# existed. It is the control: a harness that cannot see the bug cannot be
|
||||||
# trusted to see the fix either.
|
# trusted to see the fix either.
|
||||||
start_helper = "" if os.environ.get("TTY_SUSPEND_NO_HELPER") else \
|
HELPER_EXPECTED = not os.environ.get("TTY_SUSPEND_NO_HELPER")
|
||||||
'"%s" &\nhelper=$!' % HELPER
|
start_helper = '"%s" &\nhelper=$!' % HELPER if HELPER_EXPECTED else ""
|
||||||
|
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
|
||||||
|
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
|
||||||
|
# before the launcher stops itself. Without the conversion the client is the
|
||||||
|
# one thing that must not be stopped by a catchable signal.
|
||||||
open(LAUNCHER, "w").write('''#!/bin/bash
|
open(LAUNCHER, "w").write('''#!/bin/bash
|
||||||
sleep 300 &
|
sleep 300 &
|
||||||
sleep 300 &
|
sleep 300 &
|
||||||
%s
|
%s
|
||||||
python3 %s
|
trap '' TSTP
|
||||||
|
python3 %s < /dev/tty &
|
||||||
|
client=$!
|
||||||
|
trap - TSTP
|
||||||
|
on_tstp() {
|
||||||
|
kill -STOP "$client" 2>/dev/null || true
|
||||||
|
printf '\\033[?25h' > /dev/tty 2>/dev/null || true
|
||||||
|
trap - TSTP
|
||||||
|
kill -TSTP $$
|
||||||
|
trap on_tstp TSTP
|
||||||
|
kill -CONT "$client" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap on_tstp TSTP
|
||||||
|
while kill -0 "$client" 2>/dev/null; do
|
||||||
|
wait "$client" && break
|
||||||
|
case "$?" in 146|148) continue ;; esac
|
||||||
|
break
|
||||||
|
done
|
||||||
kill "${helper:-0}" 2>/dev/null || true
|
kill "${helper:-0}" 2>/dev/null || true
|
||||||
echo LAUNCHER-DONE
|
echo LAUNCHER-DONE
|
||||||
''' % (start_helper, CONTAINER))
|
''' % (start_helper, CONTAINER))
|
||||||
@@ -99,13 +167,18 @@ if pid == 0:
|
|||||||
if slave > 2:
|
if slave > 2:
|
||||||
os.close(slave)
|
os.close(slave)
|
||||||
os.close(master)
|
os.close(master)
|
||||||
env = dict(os.environ, PS1="PROMPT> ")
|
# LC_ALL=C so the shell's job-control notice is the "Stopped" this expects.
|
||||||
|
# Left to the environment it is whatever locale the runner happens to have,
|
||||||
|
# and the assertion then fails on a translated message with nothing about
|
||||||
|
# the terminal handling having changed (#249).
|
||||||
|
env = dict(os.environ, PS1="PROMPT> ", LC_ALL="C", LANG="C")
|
||||||
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
|
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
|
||||||
os.execvpe(SHELL, args, env)
|
os.execvpe(SHELL, args, env)
|
||||||
os.close(slave)
|
os.close(slave)
|
||||||
|
|
||||||
seen = []
|
seen = []
|
||||||
failures = []
|
failures = []
|
||||||
|
transcript_all = ""
|
||||||
|
|
||||||
|
|
||||||
def pump(seconds):
|
def pump(seconds):
|
||||||
@@ -119,7 +192,10 @@ def pump(seconds):
|
|||||||
return
|
return
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return
|
return
|
||||||
seen.append(chunk.decode("utf-8", "replace"))
|
text = chunk.decode("utf-8", "replace")
|
||||||
|
seen.append(text)
|
||||||
|
global transcript_all
|
||||||
|
transcript_all += text
|
||||||
|
|
||||||
|
|
||||||
def expect(text, timeout=8.0):
|
def expect(text, timeout=8.0):
|
||||||
@@ -145,6 +221,31 @@ def lflag():
|
|||||||
return termios.tcgetattr(master)[3]
|
return termios.tcgetattr(master)[3]
|
||||||
|
|
||||||
|
|
||||||
|
def reclaimed():
|
||||||
|
"""True once dejima-tty-suspend has taken ^Z back from the container's raw mode.
|
||||||
|
|
||||||
|
Read off the pty rather than inferred from output, and specific enough to
|
||||||
|
mean only the helper. Raw mode has ISIG off; a plain shell has ISIG on with
|
||||||
|
intr at ^C. Only the helper leaves ISIG on, susp at ^Z, and intr undefined
|
||||||
|
- which is the whole of what it does, so this is the feature's precondition
|
||||||
|
stated exactly.
|
||||||
|
"""
|
||||||
|
attrs = termios.tcgetattr(master)
|
||||||
|
cc = attrs[6]
|
||||||
|
return bool(attrs[3] & termios.ISIG) \
|
||||||
|
and cc[termios.VSUSP] == b"\x1a" \
|
||||||
|
and cc[termios.VINTR] == b"\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def await_reclaim(timeout=10.0):
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
if reclaimed():
|
||||||
|
return True
|
||||||
|
pump(0.05)
|
||||||
|
return reclaimed()
|
||||||
|
|
||||||
|
|
||||||
def received():
|
def received():
|
||||||
try:
|
try:
|
||||||
with open(RECEIVED, "rb") as fh:
|
with open(RECEIVED, "rb") as fh:
|
||||||
@@ -166,6 +267,27 @@ try:
|
|||||||
check("the container run attached", expect("CONTAINER-ATTACHED"))
|
check("the container run attached", expect("CONTAINER-ATTACHED"))
|
||||||
|
|
||||||
for cycle in (1, 2):
|
for cycle in (1, 2):
|
||||||
|
# Wait for the helper to hold the terminal before typing at it (#262).
|
||||||
|
#
|
||||||
|
# CONTAINER-ATTACHED is printed by the stand-in immediately after it
|
||||||
|
# sets raw mode, so it marks the START of the window this feature
|
||||||
|
# exists to close, not the end. The helper only acts once it has POLLED
|
||||||
|
# the terminal and seen -isig, so between those two moments ^Z is still
|
||||||
|
# the byte 0x1a. Sending into that window put the byte in the container
|
||||||
|
# and failed `nothing-reached-the-container` - the security assertion -
|
||||||
|
# on a machine where nothing was wrong but the scheduling.
|
||||||
|
#
|
||||||
|
# Waiting for the precondition rather than lengthening the deadline
|
||||||
|
# after it: with the terminal provably converting ^Z to a signal before
|
||||||
|
# anything is typed, that check can no longer fail for timing reasons
|
||||||
|
# at all, which is the point. A longer timeout would only have narrowed
|
||||||
|
# the window.
|
||||||
|
#
|
||||||
|
# Skipped without the helper, where the byte reaching the container is
|
||||||
|
# the expected result and this would never come true.
|
||||||
|
if HELPER_EXPECTED:
|
||||||
|
check("cycle%d-suspend-char-reclaimed" % cycle, await_reclaim(),
|
||||||
|
"dejima-tty-suspend never took ^Z back from raw mode")
|
||||||
before = received()
|
before = received()
|
||||||
forget()
|
forget()
|
||||||
send("\x1a")
|
send("\x1a")
|
||||||
@@ -197,6 +319,18 @@ try:
|
|||||||
pump(0.8)
|
pump(0.8)
|
||||||
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
|
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
|
||||||
repr(received()[-4:]))
|
repr(received()[-4:]))
|
||||||
|
|
||||||
|
# The finding, asserted: the client is stopped by SIGSTOP, which it cannot
|
||||||
|
# observe, and never by SIGTSTP, which it cannot survive.
|
||||||
|
# A suspended session must not leave the prompt with an invisible cursor:
|
||||||
|
# the harness hid it and a suspend is not an exit, so somebody has to.
|
||||||
|
check("the-cursor-is-shown-again-when-suspended",
|
||||||
|
"\033[?25h" in transcript_all,
|
||||||
|
"the prompt inherits a hidden cursor, like a dead ssh session")
|
||||||
|
|
||||||
|
check("the-client-was-started-with-sigtstp-ignored",
|
||||||
|
"CONTAINER-TSTP-IGNORED" in transcript_all,
|
||||||
|
"it would be stopped by the one signal it cannot survive")
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
os.killpg(os.getpgid(pid), 9)
|
os.killpg(os.getpgid(pid), 9)
|
||||||
|
|||||||
+271
-7
@@ -93,7 +93,13 @@ launch() {
|
|||||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||||
mkdir -p "$(dirname "$conf")"
|
mkdir -p "$(dirname "$conf")"
|
||||||
# No default, so a config that launches must select one.
|
# No default, so a config that launches must select one.
|
||||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
# A launch runs dejima-update-check, which would ls-remote this checkout's
|
||||||
|
# own origin. Off here rather than in each fixture's config, because the
|
||||||
|
# launch is what triggers it. Its cache needs nothing further: it lands
|
||||||
|
# under $HOME, which every launch fixture already points somewhere of its
|
||||||
|
# own.
|
||||||
|
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||||
|
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||||
@@ -112,6 +118,35 @@ dockerfile_for() {
|
|||||||
"$LAUNCH_DIR/dockerfile-log" 2>/dev/null
|
"$LAUNCH_DIR/dockerfile-log" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "valid_domain refuses the hostname shapes a resolver would read differently" {
|
||||||
|
# The matcher is a trust boundary: what it accepts becomes a dnsmasq
|
||||||
|
# `server=` line and an ipset entry. The published bypass in this space
|
||||||
|
# came from an egress control accepting a string its resolver then read as
|
||||||
|
# a different host, so the shapes below are the ones worth asserting on
|
||||||
|
# rather than only "does it reject evil.com".
|
||||||
|
#
|
||||||
|
# Run against this matcher during the 2026-08 prior-art sweep: every case
|
||||||
|
# here was already refused except the empty label, which was not.
|
||||||
|
local bad
|
||||||
|
for bad in 'a..b.com' 'github.com.' '.github.com' '-bad.com' 'bad-.com' \
|
||||||
|
'a.-b.com' 'evil.com%00.github.com' 'a_b.com' '' \
|
||||||
|
'evil.com
|
||||||
|
github.com'; do
|
||||||
|
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$bad"
|
||||||
|
[ "$status" -ne 0 ] || { echo "accepted: $(printf %q "$bad")"; return 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# And the shapes that must keep working, including the ones the tightening
|
||||||
|
# could plausibly have broken: an IDNA label starts with a letter and
|
||||||
|
# contains hyphens, and a single label with no dot is a real internal name.
|
||||||
|
local good
|
||||||
|
for good in 'crates.io' 'static.crates.io' 'xn--e1afmkfd.com' 'GitHub.com' \
|
||||||
|
'localhost' 'a' 'llm-backend.example.com'; do
|
||||||
|
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$good"
|
||||||
|
[ "$status" -eq 0 ] || { echo "refused: $good"; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
@test "valid_domain accepts real domains and rejects shell-active input" {
|
@test "valid_domain accepts real domains and rejects shell-active input" {
|
||||||
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
|
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
@@ -506,6 +541,85 @@ EOF
|
|||||||
[ ! -f "$CONFIG_DIR/dependencies" ]
|
[ ! -f "$CONFIG_DIR/dependencies" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Two running sandboxes, each with a working directory, plus a dejima-attach
|
||||||
|
# that lists them in the order its picker numbers them.
|
||||||
|
stub_two_containers() {
|
||||||
|
STUB_DIR="$(mktemp -d)"
|
||||||
|
cat > "$STUB_DIR/docker" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
if [ "$1" = ps ]; then
|
||||||
|
printf 'dejima-run-aaaa\taaaaaaaaaaaa\n'
|
||||||
|
printf 'dejima-run-bbbb\tbbbbbbbbbbbb\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$1" = inspect ]; then
|
||||||
|
case "$*" in
|
||||||
|
*aaaaaaaaaaaa*) echo /home/someone/workspace/alpha ;;
|
||||||
|
*bbbbbbbbbbbb*) echo /home/someone/workspace/beta ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$STUB_DIR/docker"
|
||||||
|
cat > "$STUB_DIR/dejima-attach" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
[ "$1" = --list ] || exit 1
|
||||||
|
printf 'run1\tlabel-b\tbeta\t3m\tattachable\tbbbbbbbbbbbb\n'
|
||||||
|
printf 'run2\tlabel-a\talpha\t9m\tattachable\taaaaaaaaaaaa\n'
|
||||||
|
EOF
|
||||||
|
chmod +x "$STUB_DIR/dejima-attach"
|
||||||
|
PATH="$STUB_DIR:$PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "several running sandboxes are listed by project, not by run id alone" {
|
||||||
|
# The name is a run id, which is exactly what someone with three sessions
|
||||||
|
# open does not know. What they do know is which project each is working
|
||||||
|
# on. Taken from the container's own working directory, so it is there
|
||||||
|
# whether or not remote access is on.
|
||||||
|
stub_two_containers
|
||||||
|
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"Multiple running dejima containers"* ]] || { echo "$output"; return 1; }
|
||||||
|
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
grep -qE 'beta +dejima-run-bbbb' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the listing carries the number the dejima-attach picker gives each one" {
|
||||||
|
# The picker numbers its rows 1..N in the order --list prints them, so a
|
||||||
|
# position there is the number an operator would type. Deliberately not
|
||||||
|
# this tool's own ordering: two lists that number the same sessions
|
||||||
|
# differently is worse than one list without numbers.
|
||||||
|
stub_two_containers
|
||||||
|
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||||
|
# beta is first in the attach listing, so beta is 1 - not alpha, which
|
||||||
|
# docker happens to report first.
|
||||||
|
grep -qE '^ *beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
grep -qE '^ *alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the listing drops the picker number rather than failing without one" {
|
||||||
|
# dejima-attach refuses outright when remote access is off. That is a
|
||||||
|
# column missing, not an error: the project name is the part that makes
|
||||||
|
# this usable and it does not depend on the feature.
|
||||||
|
stub_two_containers
|
||||||
|
cat > "$STUB_DIR/dejima-attach" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
echo "Remote access is not configured." >&2
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
chmod +x "$STUB_DIR/dejima-attach"
|
||||||
|
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" != *"dejima-attach 1"* ]] || { echo "invented a number: $output"; return 1; }
|
||||||
|
[[ "$output" != *"Remote access is not configured"* ]] \
|
||||||
|
|| { echo "leaked the attach refusal: $output"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
@test "dejima-allow --project persists without needing a container" {
|
@test "dejima-allow --project persists without needing a container" {
|
||||||
run "$REPO_ROOT/bin/dejima-allow" --project "$PROJ" crates.io
|
run "$REPO_ROOT/bin/dejima-allow" --project "$PROJ" crates.io
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
@@ -1677,22 +1791,67 @@ EOF
|
|||||||
# The assertion is one-sided on purpose: the victim must never be written. The
|
# The assertion is one-sided on purpose: the victim must never be written. The
|
||||||
# refusal count is only there to prove the swap window was real, so a run where
|
# refusal count is only there to prove the swap window was real, so a run where
|
||||||
# the swapper never landed cannot pass vacuously.
|
# the swapper never landed cannot pass vacuously.
|
||||||
|
#
|
||||||
|
# Each half restores state before swapping again, and that is what keeps the
|
||||||
|
# window open rather than tidiness. declare_entry calls os.makedirs the moment
|
||||||
|
# the directory is missing, so the writer puts a real directory back at the
|
||||||
|
# path while the symlink is meant to be there. A swapper that only tried
|
||||||
|
# unlink() then hit "Is a directory", left `held` stranded, and every later
|
||||||
|
# rename failed with "Directory not empty" - it went through the motions for
|
||||||
|
# the rest of the run without ever planting another symlink. The window closed
|
||||||
|
# in the first few milliseconds, the refusal count stopped rising, and a run
|
||||||
|
# where it closed before the writer's first attempt scored zero and failed a
|
||||||
|
# test whose guarantee had held perfectly. Discarding what the writer put there
|
||||||
|
# is also what a hostile session does: it owns that directory.
|
||||||
swap_loop() {
|
swap_loop() {
|
||||||
python3 - "$1" "$2" <<'PY' &
|
SWAP_HEALTH="$(mktemp)"
|
||||||
import os, sys, time
|
python3 - "$1" "$2" "$SWAP_HEALTH" <<'PY' &
|
||||||
|
import errno, os, shutil, stat, sys, time
|
||||||
|
|
||||||
path, target = sys.argv[1], sys.argv[2]
|
path, target, health_at = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
held = path + ".held"
|
held = path + ".held"
|
||||||
|
|
||||||
|
def clear(p):
|
||||||
|
# Whatever is at the path, gone - a symlink this loop planted, or the
|
||||||
|
# directory the writer recreated underneath it.
|
||||||
|
try:
|
||||||
|
if stat.S_ISDIR(os.lstat(p).st_mode):
|
||||||
|
shutil.rmtree(p)
|
||||||
|
else:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno != errno.ENOENT:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# The longest run of cycles that planted nothing, which is what the test
|
||||||
|
# asserts on. A count of successful plants does not separate the two cases:
|
||||||
|
# the wedge took about a second to happen, so a broken swapper still plants
|
||||||
|
# a hundred times and then never again. Losing a single cycle to the writer
|
||||||
|
# recreating the directory at the wrong moment is normal and self-correcting;
|
||||||
|
# never recovering is the bug.
|
||||||
|
plants = streak = worst = 0
|
||||||
end = time.time() + 60
|
end = time.time() + 60
|
||||||
while time.time() < end:
|
while time.time() < end:
|
||||||
try:
|
try:
|
||||||
os.rename(path, held)
|
if not os.path.lexists(held):
|
||||||
|
os.rename(path, held)
|
||||||
|
clear(path)
|
||||||
os.symlink(target, path)
|
os.symlink(target, path)
|
||||||
|
plants += 1
|
||||||
|
streak = 0
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
streak += 1
|
||||||
|
worst = max(worst, streak)
|
||||||
|
# Written as it goes: this process is killed, not allowed to finish.
|
||||||
|
# Through a rename, because the reader is a shell that may look at any
|
||||||
|
# moment - truncate-then-write hands it an empty file often enough to
|
||||||
|
# matter, which reads as "the swapper never planted anything".
|
||||||
|
with open(health_at + ".tmp", "w") as fh:
|
||||||
|
fh.write("%d %d\n" % (plants, worst))
|
||||||
|
os.replace(health_at + ".tmp", health_at)
|
||||||
time.sleep(0.004)
|
time.sleep(0.004)
|
||||||
try:
|
try:
|
||||||
os.unlink(path)
|
clear(path)
|
||||||
os.rename(held, path)
|
os.rename(held, path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -1701,6 +1860,20 @@ PY
|
|||||||
SWAPPER=$!
|
SWAPPER=$!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plants, and the longest run of cycles that planted nothing.
|
||||||
|
assert_swap_window_stayed_open() {
|
||||||
|
local plants=0 worst=0
|
||||||
|
# || true and the defaults: the file does not exist until the swapper's
|
||||||
|
# first cycle, and an empty read there is "nothing yet", not a value.
|
||||||
|
read -r plants worst < "$SWAP_HEALTH" || true
|
||||||
|
plants="${plants:-0}"; worst="${worst:-0}"
|
||||||
|
rm -f "$SWAP_HEALTH"
|
||||||
|
[ "$plants" -gt 20 ] \
|
||||||
|
|| { echo "the swapper planted only $plants time(s)"; return 1; }
|
||||||
|
[ "$worst" -lt 10 ] \
|
||||||
|
|| { echo "the swapper stalled for $worst consecutive cycles"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
declare_repeatedly() {
|
declare_repeatedly() {
|
||||||
bash -c '
|
bash -c '
|
||||||
source "$1"
|
source "$1"
|
||||||
@@ -1723,6 +1896,10 @@ declare_repeatedly() {
|
|||||||
kill "$SWAPPER" 2>/dev/null || true
|
kill "$SWAPPER" 2>/dev/null || true
|
||||||
[ "$(cat "$victim")" = "original" ]
|
[ "$(cat "$victim")" = "original" ]
|
||||||
[ "$refused" -gt 0 ]
|
[ "$refused" -gt 0 ]
|
||||||
|
# The window stayed open for the whole run rather than closing after the
|
||||||
|
# first collision - the failure that made this test flaky while the guard
|
||||||
|
# it covers was working.
|
||||||
|
assert_swap_window_stayed_open
|
||||||
rm -rf "$(dirname "$victim")"
|
rm -rf "$(dirname "$victim")"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,6 +1915,10 @@ declare_repeatedly() {
|
|||||||
kill "$SWAPPER" 2>/dev/null || true
|
kill "$SWAPPER" 2>/dev/null || true
|
||||||
[ "$(cat "$elsewhere/allowed-domains")" = "original" ]
|
[ "$(cat "$elsewhere/allowed-domains")" = "original" ]
|
||||||
[ "$refused" -gt 0 ]
|
[ "$refused" -gt 0 ]
|
||||||
|
# The window stayed open for the whole run rather than closing after the
|
||||||
|
# first collision - the failure that made this test flaky while the guard
|
||||||
|
# it covers was working.
|
||||||
|
assert_swap_window_stayed_open
|
||||||
rm -rf "$elsewhere"
|
rm -rf "$elsewhere"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1752,3 +1933,86 @@ declare_repeatedly() {
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[[ "$output" == *"Removed apt:ripgrep"* ]]
|
[[ "$output" == *"Removed apt:ripgrep"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "with no terminal the multi-container case stays a list and an exit" {
|
||||||
|
# The picker must never be what a script meets. `run` gives no tty, which
|
||||||
|
# is the same shape as cron, a pipe, or an agent running this: a prompt
|
||||||
|
# there is a hang nobody can answer.
|
||||||
|
stub_two_containers
|
||||||
|
run "$REPO_ROOT/bin/dejima-allow" crates.io
|
||||||
|
[ "$status" -ne 0 ] || { echo "a script got no error: $output"; return 1; }
|
||||||
|
[[ "$output" == *"specify one with --container"* ]] \
|
||||||
|
|| { echo "the flag is not offered: $output"; return 1; }
|
||||||
|
[[ "$output" != *"q to quit"* ]] || { echo "prompted with no tty: $output"; return 1; }
|
||||||
|
# And the line an agent is pointed at stays, since this is the path an
|
||||||
|
# agent's operator reads.
|
||||||
|
[[ "$output" == *"don't let it guess one"* ]] \
|
||||||
|
|| { echo "the guess-one warning is gone: $output"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the picker acts on the row that was picked" {
|
||||||
|
# The number typed indexes the rows as printed. Worth its own test because
|
||||||
|
# the failure is silent: the wrong session is patched, the domain is
|
||||||
|
# allowed, and everything reports success.
|
||||||
|
stub_two_containers
|
||||||
|
local out
|
||||||
|
# beta is docker's second row, so 2 is beta - and 1 must not be.
|
||||||
|
# || true: the stub's reachability probe decides the exit status, and this
|
||||||
|
# is a test about which container was acted on, not about that.
|
||||||
|
out="$(printf '2\n' | TERM=xterm-256color script -qec \
|
||||||
|
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
|
||||||
|
[[ "$out" == *dejima-run-bbbb* ]] \
|
||||||
|
|| { echo "picking 2 did not act on the second row: $out"; return 1; }
|
||||||
|
[[ "$out" != *"inside dejima-run-aaaa"* ]] \
|
||||||
|
|| { echo "picking 2 acted on the first row: $out"; return 1; }
|
||||||
|
out="$(printf '1\n' | TERM=xterm-256color script -qec \
|
||||||
|
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
|
||||||
|
[[ "$out" == *dejima-run-aaaa* ]] \
|
||||||
|
|| { echo "picking 1 did not act on the first row: $out"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "quitting the picker allows nothing" {
|
||||||
|
stub_two_containers
|
||||||
|
local out
|
||||||
|
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
|
||||||
|
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null)"
|
||||||
|
[[ "$out" != *Resolving* ]] || { echo "q went ahead anyway: $out"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the picker's columns are padded to what is in them" {
|
||||||
|
# "dejima-sandbox-run-" plus twelve hex digits is 31 characters, and the
|
||||||
|
# column was a fixed 28 - so every real name overran it and the project
|
||||||
|
# beside it started one space later than the row above. That is the whole
|
||||||
|
# complaint: there is no telling where the name stops.
|
||||||
|
stub_two_containers
|
||||||
|
local out proj_cols
|
||||||
|
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
|
||||||
|
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null \
|
||||||
|
| sed 's/\x1b\[[0-9;]*m//g; s/\r$//')"
|
||||||
|
# The container name starts at the same column on every row, header
|
||||||
|
# included, which is what padding to the widest value buys.
|
||||||
|
proj_cols="$(grep -oE '^ +[0-9]+ +[a-z]+ +dejima-run' <<< "$out" \
|
||||||
|
| awk '{ print index($0, "dejima-run") }' | sort -u | wc -l)"
|
||||||
|
[ "$proj_cols" -eq 1 ] \
|
||||||
|
|| { echo "rows start the name at different columns:"; echo "$out"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "dejima-allow's picker does not wipe the terminal it was run in" {
|
||||||
|
# It shares the banner with dejima-attach and deliberately not the clear.
|
||||||
|
# dejima-attach is a front door, navigated to; this is a prompt inside a
|
||||||
|
# shell the operator is already using, and wiping their scrollback to ask
|
||||||
|
# one question is worse than the interleaving the clear exists to fix.
|
||||||
|
stub_two_containers
|
||||||
|
local out
|
||||||
|
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
|
||||||
|
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
|
||||||
|
[[ "$out" != *$'\033[2J'* ]] \
|
||||||
|
|| { echo "dejima-allow cleared the operator's screen"; return 1; }
|
||||||
|
# It still drew the picker, so this is not passing because nothing ran.
|
||||||
|
[[ "$out" == *"q to quit"* ]] || { echo "no picker at all: $out"; return 1; }
|
||||||
|
rm -rf "$STUB_DIR"
|
||||||
|
}
|
||||||
|
|||||||
+326
-3
@@ -5,6 +5,7 @@
|
|||||||
setup() {
|
setup() {
|
||||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||||
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
|
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||||
|
LIB_PROJECT="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||||
CONFIG_DIR="$(mktemp -d)"
|
CONFIG_DIR="$(mktemp -d)"
|
||||||
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
||||||
}
|
}
|
||||||
@@ -622,7 +623,7 @@ assert_remedy_forges_exist() {
|
|||||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
|
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
|
||||||
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
|
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
|
||||||
[ "$(lib manifest_get workflow branch-push block_merge)" = "off" ]
|
[ "$(lib manifest_get workflow branch-push block_merge)" = "on" ]
|
||||||
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
|
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
|
||||||
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
|
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
|
||||||
# Requires no capability, so it is usable against a forge that offers
|
# Requires no capability, so it is usable against a forge that offers
|
||||||
@@ -770,11 +771,51 @@ assert_remedy_forges_exist() {
|
|||||||
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
|
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "no shipped workflow tells the agent not to merge without blocking it" {
|
||||||
|
# Every shipped briefing says some version of "do not merge". Two of them
|
||||||
|
# said it with block_merge off, which made the sentence an assertion
|
||||||
|
# nothing held: the forge's CLI is installed by the FORGE, so `<pr> merge`
|
||||||
|
# is on PATH whenever a forge is selected, whatever the workflow tells the
|
||||||
|
# agent to do. The manifest that was off carried a reason - "nothing here
|
||||||
|
# ever reaches a forge's PR interface" - and the reason was false.
|
||||||
|
#
|
||||||
|
# Asserted as an invariant over every workflow rather than as three pinned
|
||||||
|
# values, because a fourth workflow gets this wrong the same way. A
|
||||||
|
# workflow that genuinely means to permit merging has to say so in its
|
||||||
|
# briefing and change this test on purpose.
|
||||||
|
local wf name
|
||||||
|
for wf in "$REPO_ROOT"/workflows/*/; do
|
||||||
|
[ -d "$wf" ] || continue
|
||||||
|
name="$(basename "$wf")"
|
||||||
|
[ "$(lib manifest_get workflow "$name" block_merge)" = "on" ] \
|
||||||
|
|| { echo "$name leaves merging unguarded"; return 1; }
|
||||||
|
grep -qi 'merg' "$wf/briefing.md" \
|
||||||
|
|| { echo "$name's briefing says nothing about merging"; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a workflow blocking merges reaches the guard with the forge's command" {
|
||||||
|
# block_merge is only half of it: what actually refuses the command is
|
||||||
|
# DEJIMA_MERGE_BLOCK, resolved from the FORGE's merge_block and passed to
|
||||||
|
# docker/forge-guard.sh. A workflow with the flag on and a forge that
|
||||||
|
# names no command blocks nothing, which is correct - there is nothing to
|
||||||
|
# block - but the pairing is what has to hold.
|
||||||
|
local wf
|
||||||
|
for wf in branch-push local-only worktree-pr; do
|
||||||
|
[ "$(lib manifest_get workflow "$wf" block_merge)" = "on" ] || continue
|
||||||
|
[ -n "$(lib manifest_get forge gitea merge_block)" ] \
|
||||||
|
|| { echo "gitea names no merge command for $wf to block"; return 1; }
|
||||||
|
done
|
||||||
|
grep -q 'DEJIMA_MERGE_BLOCK="$(manifest_get forge "$DEJIMA_FORGE_NAME" merge_block)"' \
|
||||||
|
"$REPO_ROOT/bin/dejima" \
|
||||||
|
|| { echo "the launcher no longer resolves the forge's merge command"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@test "local-only pushes nothing and blocks no branches" {
|
@test "local-only pushes nothing and blocks no branches" {
|
||||||
run lib manifest_validate workflow local-only
|
run lib manifest_validate workflow local-only
|
||||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
[ "$(lib manifest_get workflow local-only push)" = "off" ]
|
[ "$(lib manifest_get workflow local-only push)" = "off" ]
|
||||||
[ "$(lib manifest_get workflow local-only block_merge)" = "off" ]
|
[ "$(lib manifest_get workflow local-only block_merge)" = "on" ]
|
||||||
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
|
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
|
||||||
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
|
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
|
||||||
# blocked_branches is empty BECAUSE push is off - there is no push to
|
# blocked_branches is empty BECAUSE push is off - there is no push to
|
||||||
@@ -789,6 +830,144 @@ assert_remedy_forges_exist() {
|
|||||||
[ -z "$output" ]
|
[ -z "$output" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- optional tools ----------------------------------------------------------
|
||||||
|
|
||||||
|
@test "every shipped tool is pinned by digest, not fetched from a moving ref" {
|
||||||
|
# The bar docs/tools.md sets, asserted rather than described. A tool that
|
||||||
|
# installs from whatever a branch points at today is one nothing has
|
||||||
|
# reviewed, and it would build differently on different days with no
|
||||||
|
# change to this repository.
|
||||||
|
local tool spec
|
||||||
|
for tool in $(lib provider_list tool); do
|
||||||
|
run lib manifest_validate tool "$tool"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
spec="$(lib manifest_get tool "$tool" install)"
|
||||||
|
[[ "$spec" == tarball:* ]] \
|
||||||
|
|| { echo "$tool does not install from a pinned artifact: $spec"; return 1; }
|
||||||
|
# valid_tarball_spec already requires a 64-hex digest and an https
|
||||||
|
# URL; this is the part it cannot check - that the URL names a
|
||||||
|
# version rather than a branch.
|
||||||
|
[[ "$spec" != *"/refs/heads/"* && "$spec" != *"/master/"* && "$spec" != *"/main/"* ]] \
|
||||||
|
|| { echo "$tool installs from a moving ref: $spec"; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a tool cannot declare anything that changes what dejima permits" {
|
||||||
|
# The manifest has no key for a hook, a mount, a domain or a guard. A tool
|
||||||
|
# that needed one would be a change to the boundary rather than a program
|
||||||
|
# on PATH, and adding the key is the moment to argue about it - not the
|
||||||
|
# moment a manifest quietly starts carrying one.
|
||||||
|
local key
|
||||||
|
for key in guards hooks mounts api_domains fixed_hosts install_hook \
|
||||||
|
briefing no_confirm_args settings_args; do
|
||||||
|
run lib manifest_get tool rtk "$key"
|
||||||
|
[ "$status" -ne 0 ] \
|
||||||
|
|| { echo "the tool schema accepts '$key', which it must not"; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "rtk declares the archive shape its release actually has" {
|
||||||
|
# Inspected: `tar tzf rtk-x86_64-unknown-linux-musl.tar.gz` lists one
|
||||||
|
# entry, `rtk`, with no enclosing directory. Declared `prefix`, the layer
|
||||||
|
# would strip that single component, extract nothing, and build cleanly -
|
||||||
|
# a tool the session is told it has and does not.
|
||||||
|
#
|
||||||
|
# Pinned here because the emitter test below exercises both shapes with an
|
||||||
|
# explicit setting and so cannot notice this manifest changing.
|
||||||
|
[ "$(lib manifest_get tool rtk install_layout)" = "binary" ] \
|
||||||
|
|| { echo "rtk's release is a bare binary and the manifest no longer says so"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a tool may only set environment variables that are its own" {
|
||||||
|
# image_env becomes a Dockerfile ENV, which every process in the container
|
||||||
|
# inherits - not just the tool that declared it. rtk shipped with
|
||||||
|
# NO_COLOR=1, reasoning that its output lands in a model's context where an
|
||||||
|
# escape sequence is noise. The reasoning was fine and the scope was wrong:
|
||||||
|
# a launch with the tool enabled lost colour in the harness, in git, in ls,
|
||||||
|
# in everything.
|
||||||
|
#
|
||||||
|
# A harness may set a bare name there, because a harness IS the container's
|
||||||
|
# main process. A tool is one program among many.
|
||||||
|
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/toolenv.XXXXXX")"
|
||||||
|
mkdir -p "$d/tools/probe"
|
||||||
|
local sha; sha="$(printf 'a%.0s' {1..64})"
|
||||||
|
local bad
|
||||||
|
for bad in 'NO_COLOR=1' 'DO_NOT_TRACK=1' 'PATH=/x' 'PROBEISH_X=1'; do
|
||||||
|
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = %s\n' \
|
||||||
|
"$sha" "$bad" > "$d/tools/probe/tool.conf"
|
||||||
|
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||||
|
bash -c 'source "$1"; tool_validate probe' _ "$LIB"
|
||||||
|
[ "$status" -ne 0 ] || { echo "accepted image_env $bad"; return 1; }
|
||||||
|
[[ "$output" == *"not its own"* ]] || { echo "$output"; return 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Namespaced to the tool, which is what a tool's own switch looks like.
|
||||||
|
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = PROBE_TELEMETRY=0 PROBE_QUIET=1\n' \
|
||||||
|
"$sha" > "$d/tools/probe/tool.conf"
|
||||||
|
run env DEJIMA_PROVIDER_ROOT="$d" bash -c 'source "$1"; tool_validate probe' _ "$LIB"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "no shipped tool takes the colour out of the rest of the container" {
|
||||||
|
# The specific regression, pinned by name as well as by the rule above:
|
||||||
|
# these are the bare names most likely to look like a tool's own business
|
||||||
|
# and reach everything.
|
||||||
|
local tool pair
|
||||||
|
for tool in $(lib provider_list tool); do
|
||||||
|
run lib tool_validate "$tool"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
for pair in $(lib manifest_get tool "$tool" image_env); do
|
||||||
|
case "${pair%%=*}" in
|
||||||
|
NO_COLOR|TERM|CLICOLOR|CLICOLOR_FORCE|FORCE_COLOR)
|
||||||
|
echo "$tool sets ${pair%%=*} for the whole container"; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "rtk is declared with its telemetry switched off in the image" {
|
||||||
|
# Two independent offs, because either one can be wrong: the tool's own
|
||||||
|
# switch here, and the firewall, which does not allowlist the endpoint.
|
||||||
|
[[ "$(lib manifest_get tool rtk image_env)" == *"RTK_TELEMETRY_DISABLED=1"* ]] \
|
||||||
|
|| { echo "rtk's telemetry switch is no longer set"; return 1; }
|
||||||
|
# And the endpoint stays out of what core allowlists.
|
||||||
|
run grep -rn 'telemetry.rtk-ai' "$REPO_ROOT/docker" "$REPO_ROOT/bin"
|
||||||
|
[ "$status" -ne 0 ] || { echo "the telemetry endpoint reached the allowlist:"; echo "$output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a tool's archive shape decides how it is unpacked" {
|
||||||
|
# A bare-binary archive stripped of a component extracts NOTHING, and does
|
||||||
|
# it silently: the layer builds, and the tool is not in it.
|
||||||
|
local spec
|
||||||
|
spec="tarball:x@$(printf 'a%.0s' {1..64}):https://e.example/x.tgz"
|
||||||
|
run env DEJIMA_TARBALL_STRIP=binary bash -c \
|
||||||
|
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
|
||||||
|
[[ "$output" == *"tar -C /opt/dejima/x/bin -xaf"* ]] \
|
||||||
|
|| { echo "binary layout still strips a component: $output"; return 1; }
|
||||||
|
run env DEJIMA_TARBALL_STRIP=prefix bash -c \
|
||||||
|
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
|
||||||
|
[[ "$output" == *"--strip-components=1"* ]] \
|
||||||
|
|| { echo "prefix layout stopped stripping: $output"; return 1; }
|
||||||
|
# Both verify before they unpack. A tar extracted first and checked after
|
||||||
|
# has already written whatever it contained.
|
||||||
|
[[ "$output" == *"sha256sum -c -"*"tar -C"* ]] \
|
||||||
|
|| { echo "the digest is no longer checked before unpacking"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the briefing names the tools a launch enabled, and says nothing when there are none" {
|
||||||
|
run env DEJIMA_TOOLS=rtk bash -c \
|
||||||
|
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"Extra tools this session has"* ]] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"rtk git status"* ]] \
|
||||||
|
|| { echo "the tool's own instructions did not reach the briefing"; return 1; }
|
||||||
|
|
||||||
|
run env DEJIMA_TOOLS= bash -c \
|
||||||
|
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
|
||||||
|
[[ "$output" != *"Extra tools this session has"* ]] \
|
||||||
|
|| { echo "a heading with nothing under it"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@test "the github forge pushes with its own token, not the read-only one" {
|
@test "the github forge pushes with its own token, not the read-only one" {
|
||||||
run lib manifest_validate forge github
|
run lib manifest_validate forge github
|
||||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
@@ -803,7 +982,92 @@ assert_remedy_forges_exist() {
|
|||||||
# declared: the entrypoint links the merge shadow only over a command that
|
# declared: the entrypoint links the merge shadow only over a command that
|
||||||
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise.
|
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise.
|
||||||
# This forge shipping nothing of its own is what "system:" says.
|
# This forge shipping nothing of its own is what "system:" says.
|
||||||
[ "$(lib manifest_get forge github install)" = "system:gh" ]
|
# system:gh AND the one CLI this forge ships. install-forge-bins.sh fails
|
||||||
|
# the build in both directions - a declared file that is missing, and a
|
||||||
|
# file under bin/ that no manifest declares - so this is the declaration
|
||||||
|
# an operator reads to know what lands in the image.
|
||||||
|
[ "$(lib manifest_get forge github install)" = "system:gh bin/github-pr" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "each forge declares which of its keys belong to a person" {
|
||||||
|
# The credential paths, which a project may not declare and an identity
|
||||||
|
# may set. One declaration drives both rules, so they cannot disagree
|
||||||
|
# about which key is which.
|
||||||
|
[ "$(lib manifest_get forge gitea identity_keys)" = "token_file" ]
|
||||||
|
[ "$(lib manifest_get forge github identity_keys)" = "push_token_file" ]
|
||||||
|
[ -z "$(lib manifest_get forge none identity_keys)" ]
|
||||||
|
# And every one of them is a key that forge actually has, or the identity
|
||||||
|
# would offer a setting nothing reads.
|
||||||
|
local forge key spec found
|
||||||
|
for forge in $(lib provider_list forge); do
|
||||||
|
for key in $(lib manifest_get forge "$forge" identity_keys); do
|
||||||
|
found=0
|
||||||
|
for spec in $(lib manifest_get forge "$forge" keys); do
|
||||||
|
[ "${spec%%:*}" = "$key" ] && found=1
|
||||||
|
done
|
||||||
|
[ "$found" -eq 1 ] \
|
||||||
|
|| { echo "$forge names identity key '$key' and has no such key"; return 1; }
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "no two forges claim the same identity key name" {
|
||||||
|
# An identity's keys are unqualified, so one name resolving to two forges'
|
||||||
|
# types would make the validation depend on which forge was read first.
|
||||||
|
# Refused here rather than guessed at in _identity_key_specs.
|
||||||
|
local forge key seen=""
|
||||||
|
for forge in $(lib provider_list forge); do
|
||||||
|
for key in $(lib manifest_get forge "$forge" identity_keys); do
|
||||||
|
case " $seen " in
|
||||||
|
*" $key "*) echo "two forges declare identity key '$key'"; return 1 ;;
|
||||||
|
esac
|
||||||
|
seen="$seen $key"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the identity key set is core's two plus whatever the forges declare" {
|
||||||
|
# ssh_key and the git name are core's, because every forge needs the same
|
||||||
|
# answer to "who is pushing". The credentials are not, and are typed by
|
||||||
|
# the forge that owns them rather than by a second copy here.
|
||||||
|
local specs
|
||||||
|
specs="$(lib _identity_key_specs)"
|
||||||
|
grep -qP '^ssh_key\t\?path$' <<< "$specs" || { echo "$specs"; return 1; }
|
||||||
|
grep -qP '^user_name\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
|
||||||
|
grep -qP '^user_email\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
|
||||||
|
grep -qP '^token_file\t\?path$' <<< "$specs" \
|
||||||
|
|| { echo "token_file did not inherit gitea's type: $specs"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the github forge declares how its CLI can be aimed at another repo" {
|
||||||
|
# gh takes --repo on nearly every subcommand and reads GH_REPO, so the
|
||||||
|
# ordinary path can act on a repository the session has nothing to do
|
||||||
|
# with (#187). The guard is generic; the manifest is what makes it fire.
|
||||||
|
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
|
||||||
|
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
|
||||||
|
# The gitea CLIs resolve the repo from origin and refuse another host, so
|
||||||
|
# there is nothing to declare and nothing for the guard to compare. A
|
||||||
|
# forge is not required to be aimable.
|
||||||
|
[ -z "$(lib manifest_get forge gitea repo_flags)" ]
|
||||||
|
[ -z "$(lib manifest_get forge gitea repo_env)" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a repo flag has to look like a flag" {
|
||||||
|
# It is compared against argv. A bare word would be indistinguishable from
|
||||||
|
# a subcommand or a value, and would refuse commands at random.
|
||||||
|
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/flagtype.XXXXXX")"
|
||||||
|
mkdir -p "$d/forges/probe"
|
||||||
|
for bad in "repo" "--" "-" "--repo=x"; do
|
||||||
|
printf 'repo_flags = %s\n' "$bad" > "$d/forges/probe/forge.conf"
|
||||||
|
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||||
|
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
|
||||||
|
[ "$status" -ne 0 ] || { echo "accepted repo_flags = $bad"; return 1; }
|
||||||
|
done
|
||||||
|
# Space-separated, like every other manifest list.
|
||||||
|
printf 'repo_flags = --repo -R\n' > "$d/forges/probe/forge.conf"
|
||||||
|
run env DEJIMA_PROVIDER_ROOT="$d" \
|
||||||
|
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "the briefing does not call GitHub read-only when the forge can push to it" {
|
@test "the briefing does not call GitHub read-only when the forge can push to it" {
|
||||||
@@ -999,3 +1263,62 @@ assert_remedy_forges_exist() {
|
|||||||
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
|
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "a workflow that omits block_merge blocks merges anyway" {
|
||||||
|
# The default decides what happens to a workflow that forgets the key -
|
||||||
|
# a third-party one, or a fourth shipped one added in a hurry. `off` means
|
||||||
|
# a missing key silently permits merging from inside a session, which is
|
||||||
|
# the direction a guard should never fail in. Every shipped workflow sets
|
||||||
|
# it explicitly, so the invariant test covering those would pass either
|
||||||
|
# way; this is about the ones nobody has written yet.
|
||||||
|
FAKE_ROOT="$(mktemp -d)"
|
||||||
|
mkdir -p "$FAKE_ROOT/workflows/forgetful"
|
||||||
|
printf 'requires =\npush = off\n' > "$FAKE_ROOT/workflows/forgetful/workflow.conf"
|
||||||
|
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" DEJIMA_CONFIG_DIR="$CONFIG_DIR" \
|
||||||
|
bash -c 'source "$1"; manifest_get workflow forgetful block_merge' _ "$LIB"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[ "$output" = "on" ] \
|
||||||
|
|| { echo "a workflow that names no block_merge got '$output'"; return 1; }
|
||||||
|
rm -rf "$FAKE_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a forge that aims its CLI also gets that CLI shadowed" {
|
||||||
|
# entrypoint.sh installs the PATH shadow from merge_block and disclose
|
||||||
|
# only (_shadow_clis). repo_flags and repo_env are exported to the
|
||||||
|
# container regardless - so a forge declaring repo_flags without either of
|
||||||
|
# the other two hands the guard its configuration with no guard on PATH to
|
||||||
|
# read it, and the aiming check silently does not exist.
|
||||||
|
#
|
||||||
|
# Both shipped forges declare disclose, so nothing is broken today. This
|
||||||
|
# is the manifest contract stated as an invariant, for the third forge
|
||||||
|
# someone writes (#244).
|
||||||
|
local forge named shadowed cli entry
|
||||||
|
for dir in "$REPO_ROOT"/forges/*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
forge="$(basename "$dir")"
|
||||||
|
[ -n "$(lib manifest_get forge "$forge" repo_flags)$(lib manifest_get forge "$forge" repo_env)" ] \
|
||||||
|
|| continue
|
||||||
|
|
||||||
|
# The CLI is the first word of each command the forge names.
|
||||||
|
named=""
|
||||||
|
for entry in pr_command issue_command; do
|
||||||
|
cli="$(lib manifest_get forge "$forge" "$entry")"
|
||||||
|
[ -n "$cli" ] && named="$named ${cli%% *}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# What entrypoint.sh would actually shadow: merge_block's first word,
|
||||||
|
# and the CLI in each disclose record ("<kind>:<cli> <subcommand>").
|
||||||
|
shadowed=" $(lib manifest_get forge "$forge" merge_block | cut -d' ' -f1) "
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
entry="${entry#"${entry%%[![:space:]]*}"}"
|
||||||
|
[ -n "$entry" ] || continue
|
||||||
|
cli="${entry#*:}"
|
||||||
|
shadowed="$shadowed ${cli%% *} "
|
||||||
|
done < <(lib manifest_get forge "$forge" disclose | tr ',' '\n')
|
||||||
|
|
||||||
|
for cli in $named; do
|
||||||
|
[[ "$shadowed" == *" $cli "* ]] \
|
||||||
|
|| { echo "forge $forge aims '$cli' but nothing shadows it: declare it in merge_block or disclose"; return 1; }
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|||||||
+47
-5
@@ -12,7 +12,10 @@ set -uo pipefail
|
|||||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
base="${1:-main}"
|
base="${1:-main}"
|
||||||
head="${2:-HEAD}"
|
head="${2:-HEAD}"
|
||||||
cd "$root"
|
# Guarded, because everything below is relative to it: a cd that failed
|
||||||
|
# silently would have this reporting on some other directory's suites and
|
||||||
|
# diff, which for a release gate is the worst way to be wrong.
|
||||||
|
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
-h|--help)
|
-h|--help)
|
||||||
@@ -77,9 +80,13 @@ fi
|
|||||||
|
|
||||||
touches() { grep -qE "$1" <<< "$changed"; }
|
touches() { grep -qE "$1" <<< "$changed"; }
|
||||||
|
|
||||||
note "Manual checks this release calls for"
|
# Not a checklist to complete: an inventory of what nothing has checked. The
|
||||||
|
# gate for a release here is the security review plus the maintainer running
|
||||||
|
# the build - see docs/release.md. Printed anyway, because a release whose
|
||||||
|
# unverified paths are written down is one somebody can debug later.
|
||||||
|
note "Nothing has verified these, and this release touches them"
|
||||||
printed=0
|
printed=0
|
||||||
manual() { printf ' [ ] %s\n' "$1"; printed=1; }
|
manual() { printf ' - %s\n' "$1"; printed=1; }
|
||||||
|
|
||||||
touches '^(bin/dejima$|docker/entrypoint|docker/Dockerfile|docker/docker-compose)' &&
|
touches '^(bin/dejima$|docker/entrypoint|docker/Dockerfile|docker/docker-compose)' &&
|
||||||
manual "Launch a real session. Nothing else exercises the mounts, the
|
manual "Launch a real session. Nothing else exercises the mounts, the
|
||||||
@@ -103,11 +110,46 @@ touches '^\.dejima/allowed-domains$' &&
|
|||||||
future CI job on that runner. Say in the PR what it is for."
|
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)"
|
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
|
||||||
|
|
||||||
note "Reviews"
|
note "Required before this merges"
|
||||||
echo " [ ] Security review of the whole $base...$head diff, against this"
|
echo " [ ] Security review of the whole $base...$head diff, against this"
|
||||||
echo " project's own invariants - see docs/release.md."
|
echo " project's own invariants - see docs/release.md. This is the gate."
|
||||||
echo " [ ] Documentation review: every changed behaviour has its doc changed,"
|
echo " [ ] Documentation review: every changed behaviour has its doc changed,"
|
||||||
echo " and no doc describes something that no longer exists."
|
echo " and no doc describes something that no longer exists."
|
||||||
|
echo " [ ] The maintainer runs the build: normal usage for a while, and a"
|
||||||
|
echo " launch on the other configured harness to confirm it starts."
|
||||||
|
|
||||||
|
# A config key added in a release makes the PREVIOUS main refuse to start once
|
||||||
|
# an operator has adopted it, because an unknown key fails validation. That
|
||||||
|
# turns the obvious rollback into a second outage, so it is worth knowing
|
||||||
|
# before rather than after.
|
||||||
|
note "Config keys this release adds (a rollback refuses a config using them)"
|
||||||
|
added="$(comm -13 \
|
||||||
|
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
|
||||||
|
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
|
||||||
|
# Providers too, and this is the sharper half: a [harness.<name>] section for
|
||||||
|
# a harness the older dejima does not ship is an unknown SECTION, which fails
|
||||||
|
# validation exactly as an unknown key does. So adopting a new provider is
|
||||||
|
# itself the thing that makes the rollback refuse.
|
||||||
|
# The singular is spelled out rather than derived: "harnesses" does not lose
|
||||||
|
# an s to become "harness", and a manifest is named for the singular.
|
||||||
|
new_providers=""
|
||||||
|
for pair in harnesses:harness forges:forge workflows:workflow; do
|
||||||
|
axis="${pair%%:*}"
|
||||||
|
one="${pair#*:}"
|
||||||
|
[ -d "$axis" ] || continue
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[ -n "$name" ] || continue
|
||||||
|
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
|
||||||
|
new_providers="$new_providers $one.$name"
|
||||||
|
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$added" ] || [ -n "$new_providers" ]; then
|
||||||
|
[ -z "$added" ] || printf ' %s\n' $added
|
||||||
|
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
|
||||||
|
else
|
||||||
|
echo " (none - a rollback accepts any config this release accepts)"
|
||||||
|
fi
|
||||||
|
|
||||||
note "Then"
|
note "Then"
|
||||||
echo " Say in the release PR what was run and what was not. A privileged job"
|
echo " Say in the release PR what was run and what was not. A privileged job"
|
||||||
|
|||||||
+151
-26
@@ -178,7 +178,13 @@ launch() {
|
|||||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||||
mkdir -p "$(dirname "$conf")"
|
mkdir -p "$(dirname "$conf")"
|
||||||
# No default, so a config that launches must select one.
|
# No default, so a config that launches must select one.
|
||||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
# A launch runs dejima-update-check, which would ls-remote this checkout's
|
||||||
|
# own origin. Off here rather than in each fixture's config, because the
|
||||||
|
# launch is what triggers it. Its cache needs nothing further: it lands
|
||||||
|
# under $HOME, which every launch fixture already points somewhere of its
|
||||||
|
# own.
|
||||||
|
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||||
|
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||||
@@ -757,26 +763,28 @@ start_tmux() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Provenance, pinned offline. Upstream's color.table is the authority, but a
|
# Provenance, pinned offline. Upstream's color.table is the authority, but a
|
||||||
# test cannot go and fetch it, so the name->hex half is transcribed from it
|
# The terminal palette carries no hexes at all any more, and that is the
|
||||||
# here. The half that catches a typo is checked rather than transcribed: every
|
# property worth pinning. It used to hardcode gruvbox in 24-bit, which is the
|
||||||
# escape is decoded back to a hex and compared against the one written beside
|
# brand but assumes a background dejima does not own: measured against a light
|
||||||
# it, so a value edited in one place and not the other fails here instead of
|
# one, light1 came out at 1.21:1 and bright yellow at 1.49:1. Indexed colours
|
||||||
# shipping under the name "gruvbox".
|
# cannot have that problem, because the theme's author picked them against
|
||||||
@test "the picker's palette is gruvbox, by upstream's own values" {
|
# their own background - and on a gruvbox terminal they ARE those hexes.
|
||||||
local -A gruvbox=(
|
@test "the terminal palette is the terminal's own, not hardcoded" {
|
||||||
[bright_orange]=fe8019 [light1]=ebdbb2 [gray_245]=928374
|
local lib="$REPO_ROOT/bin/dejima-color-lib.sh"
|
||||||
[dark4]=7c6f64 [dark2]=504945 [bright_green]=b8bb26
|
run grep -n '38;2;' "$lib" "$REPO_ROOT/bin/dejima-tui-lib.sh"
|
||||||
[bright_yellow]=fabd2f [bright_red]=fb4934 [bright_blue]=83a598
|
[ "$status" -ne 0 ] \
|
||||||
)
|
|| { echo "24-bit colour is back in a terminal palette:"; echo "$output"; return 1; }
|
||||||
local seen=0 r g b name hex
|
# And it is one palette: a second definition is how C_OK and C_WARN came
|
||||||
while read -r r g b name hex; do
|
# to mean two different things depending on which file a caller sourced.
|
||||||
seen=$((seen + 1))
|
run grep -c '^\s*C_OK=' "$REPO_ROOT/bin/dejima-tui-lib.sh"
|
||||||
[ "${gruvbox[$name]:-unknown}" = "$hex" ]
|
[ "$output" -eq 0 ] \
|
||||||
[ "$(printf '%02x%02x%02x' "$r" "$g" "$b")" = "$hex" ]
|
|| { echo "dejima-tui-lib.sh defines its own palette again"; return 1; }
|
||||||
done < <(sed -n \
|
# The colours it does use are the semantic slots, so a gruvbox terminal
|
||||||
"s/.*38;2;\([0-9]*\);\([0-9]*\);\([0-9]*\)m'.*# \([a-z_0-9]*\) *#\([0-9a-f]*\).*/\1 \2 \3 \4 \5/p" \
|
# fills them with gruvbox.
|
||||||
"$REPO_ROOT/bin/dejima-attach")
|
local slot
|
||||||
[ "$seen" -eq "${#gruvbox[@]}" ]
|
for slot in '92m' '93m' '91m' '94m' '90m'; do
|
||||||
|
grep -q "$slot" "$lib" || { echo "the palette lost slot $slot"; return 1; }
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
|
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
|
||||||
@@ -1314,7 +1322,6 @@ EOF
|
|||||||
stub_launch
|
stub_launch
|
||||||
write_remote_config
|
write_remote_config
|
||||||
launch "$PROJ"
|
launch "$PROJ"
|
||||||
grep -q 'dejima-notify.sh waiting' "$LAUNCH_DIR/settings-copy.json"
|
|
||||||
grep -q 'dejima-notify.sh done' "$LAUNCH_DIR/settings-copy.json"
|
grep -q 'dejima-notify.sh done' "$LAUNCH_DIR/settings-copy.json"
|
||||||
# The layer is a throwaway, so the operator's real settings.json - which
|
# The layer is a throwaway, so the operator's real settings.json - which
|
||||||
# nothing registers this hook into - is left byte-for-byte alone.
|
# nothing registers this hook into - is left byte-for-byte alone.
|
||||||
@@ -1329,8 +1336,8 @@ EOF
|
|||||||
# Copying them would run each of them twice. Verified against Claude Code
|
# Copying them would run each of them twice. Verified against Claude Code
|
||||||
# 2.1.220: a SessionStart hook in a --settings file and one in
|
# 2.1.220: a SessionStart hook in a --settings file and one in
|
||||||
# ~/.claude/settings.json both fire.
|
# ~/.claude/settings.json both fire.
|
||||||
[ "$(jq '.hooks.Notification | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
|
|
||||||
[ "$(jq '.hooks.Stop | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
|
[ "$(jq '.hooks.Stop | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
|
||||||
|
[ "$(jq '.hooks.Notification // [] | length' "$LAUNCH_DIR/settings-copy.json")" = "0" ]
|
||||||
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
||||||
[[ "$output" != *operator-desktop-notify.sh* ]]
|
[[ "$output" != *operator-desktop-notify.sh* ]]
|
||||||
[[ "$output" != *operator-tts.sh* ]]
|
[[ "$output" != *operator-tts.sh* ]]
|
||||||
@@ -1943,6 +1950,13 @@ expect_ttyd_argv() {
|
|||||||
[ -z "${2:-}" ] || printf '%s\n' -t "fontSize=$2"
|
[ -z "${2:-}" ] || printf '%s\n' -t "fontSize=$2"
|
||||||
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
|
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
|
||||||
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
|
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
|
||||||
|
[ -z "${5:-}" ] || printf '%s\n' -t "buttonColors=$5"
|
||||||
|
# Always sent, unlike the four above: a browser has no theme to inherit,
|
||||||
|
# so this is not an option an operator turns on. Read out of the script
|
||||||
|
# rather than transcribed - this file asserts that ttyd is given a theme
|
||||||
|
# and that its values match the page (static.bats), not what they are.
|
||||||
|
printf '%s\n' -t "$(sed -n "/^theme_opt=(-t '/,/^}')$/p" \
|
||||||
|
"$REPO_ROOT/bin/dejima-ttyd" | sed "1s/^theme_opt=(-t '//; \$s/')$//")"
|
||||||
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
|
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
|
||||||
}
|
}
|
||||||
# Sentinel on both sides: both are command substitutions, which strip trailing
|
# Sentinel on both sides: both are command substitutions, which strip trailing
|
||||||
@@ -2168,7 +2182,10 @@ PY
|
|||||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
assert_ttyd_argv 12345
|
assert_ttyd_argv 12345
|
||||||
run grep -cx -- '-t' "$STUB_DIR/ttyd-argv"
|
# No fontSize reaches ttyd, rather than no -t at all: the theme is sent on
|
||||||
|
# every launch (a browser has no palette to inherit), so counting -t stopped
|
||||||
|
# measuring what this is about, which is that an unset option sends nothing.
|
||||||
|
run grep -c 'fontSize' "$STUB_DIR/ttyd-argv"
|
||||||
[ "$output" = "0" ]
|
[ "$output" = "0" ]
|
||||||
|
|
||||||
# Set. Checked through the exact vector, not a substring: "fontSize=20
|
# Set. Checked through the exact vector, not a substring: "fontSize=20
|
||||||
@@ -2232,6 +2249,36 @@ PY
|
|||||||
assert_ttyd_argv 12345 20 C-Space
|
assert_ttyd_argv 12345 20 C-Space
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "ttyd_button_colors reaches ttyd, and unset sends nothing so the page decides" {
|
||||||
|
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||||
|
|
||||||
|
# Unset is not "off": the page colours the toolbar unless told not to, and
|
||||||
|
# a host-wide "off" written here would be a different thing from silence.
|
||||||
|
write_remote_config 'ttyd_port = 12345'
|
||||||
|
stub_ttyd
|
||||||
|
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
|
||||||
|
[ "$output" = "0" ]
|
||||||
|
|
||||||
|
local value
|
||||||
|
for value in on off; do
|
||||||
|
: > "$STUB_DIR/ttyd-argv"
|
||||||
|
write_remote_config 'ttyd_port = 12345' "ttyd_button_colors = $value"
|
||||||
|
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
assert_ttyd_argv 12345 '' '' '' "$value"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Alongside every other -t knob, since they share one splice.
|
||||||
|
: > "$STUB_DIR/ttyd-argv"
|
||||||
|
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20' \
|
||||||
|
'ttyd_tmux_prefix = C-a' 'ttyd_focus_on_tap = on' 'ttyd_button_colors = off'
|
||||||
|
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
assert_ttyd_argv 12345 20 C-a on off
|
||||||
|
}
|
||||||
|
|
||||||
@test "ttyd_focus_on_tap reaches ttyd, and unset sends nothing so the page decides" {
|
@test "ttyd_focus_on_tap reaches ttyd, and unset sends nothing so the page decides" {
|
||||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||||
|
|
||||||
@@ -2667,7 +2714,7 @@ nginx_blocks() {
|
|||||||
|
|
||||||
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
|
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
|
||||||
# and ~/.claude/hooks is what gets mounted read-only into the container.
|
# and ~/.claude/hooks is what gets mounted read-only into the container.
|
||||||
# Without this link every Notification and Stop is an exit-127.
|
# Without this link every notification hook is an exit-127.
|
||||||
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
|
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
|
||||||
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
|
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
|
||||||
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
|
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
|
||||||
@@ -2887,7 +2934,7 @@ remote_doc() {
|
|||||||
# rather than derived so this still catches the doc losing one of them.
|
# rather than derived so this still catches the doc losing one of them.
|
||||||
local keys key
|
local keys key
|
||||||
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix
|
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix
|
||||||
ttyd_focus_on_tap'
|
ttyd_focus_on_tap ttyd_button_colors'
|
||||||
for key in $keys; do has -F "\`$key\`"; done
|
for key in $keys; do has -F "\`$key\`"; done
|
||||||
|
|
||||||
# The port default, taken from the library so the two cannot drift.
|
# The port default, taken from the library so the two cannot drift.
|
||||||
@@ -3015,3 +3062,81 @@ remote_doc() {
|
|||||||
[ "$status" -ne 0 ]
|
[ "$status" -ne 0 ]
|
||||||
[ -z "$output" ]
|
[ -z "$output" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "one session-finished push per stop, not a second one a minute later" {
|
||||||
|
# Claude Code raises Notification when it needs permission and when the
|
||||||
|
# prompt has sat idle. A dejima session runs with permission prompts off,
|
||||||
|
# so only the timer is left, and it expires shortly after the Stop it
|
||||||
|
# follows - two pushes on a phone for one event, the second carrying
|
||||||
|
# nothing the first had not. Debouncing cannot merge them: the watcher
|
||||||
|
# keys on run id AND event, deliberately, so waiting and done are separate
|
||||||
|
# signals whenever a harness can actually tell them apart.
|
||||||
|
stub_launch
|
||||||
|
write_remote_config
|
||||||
|
launch "$PROJ"
|
||||||
|
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
|
||||||
|
[[ "$output" == *'dejima-notify.sh done'* ]] \
|
||||||
|
|| { echo "the stop notification is gone entirely: $output"; return 1; }
|
||||||
|
[[ "$output" != *'dejima-notify.sh waiting'* ]] \
|
||||||
|
|| { echo "the idle timer is registered again: $output"; return 1; }
|
||||||
|
rm -rf "$PROJ"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the watcher still consumes a waiting marker no hook registers" {
|
||||||
|
# Nothing writes one by default now, and that changes nothing here: the
|
||||||
|
# marker directory is bind-mounted read-write, so a session creates
|
||||||
|
# whatever names it likes. The enum is the boundary, not the registration,
|
||||||
|
# and dropping `waiting` from it would leave a marker that accumulates
|
||||||
|
# rather than one that is refused.
|
||||||
|
conf_with_ntfy
|
||||||
|
stub_curl
|
||||||
|
write_entry 0123456789abcdef '%1' 'work:2'
|
||||||
|
marker 0123456789abcdef waiting
|
||||||
|
run "$REPO_ROOT/bin/dejima-notify-watch" --once
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ] \
|
||||||
|
|| { echo "the marker was left to accumulate"; return 1; }
|
||||||
|
assert_argv 'myproject needs input' 'Session is waiting for you.' \
|
||||||
|
'https://dejima.example.invalid/?arg=0123456789abcdef'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the session picker starts on a clean screen, scrollback included" {
|
||||||
|
# Reopening it from the web toolbar is a full page load, and it drew on
|
||||||
|
# top of whatever the previous session had left on screen - rows
|
||||||
|
# interleaved with the tail of the session just left, which reads as a
|
||||||
|
# rendering fault rather than a new screen (#213). Invisible over SSH,
|
||||||
|
# where a fresh connection to the forced command starts on a blank one.
|
||||||
|
write_remote_config
|
||||||
|
stub_docker_run_id 0123456789abcdef
|
||||||
|
start_tmux
|
||||||
|
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
|
||||||
|
|
||||||
|
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *$'\033[2J'* ]] || { echo "the screen is not cleared"; return 1; }
|
||||||
|
# And the scrollback with it: without 3J a swipe up on a phone lands in
|
||||||
|
# the middle of the previous session's output.
|
||||||
|
[[ "$output" == *$'\033[3J'* ]] || { echo "the scrollback is left behind"; return 1; }
|
||||||
|
|
||||||
|
# Not on the machine interface. ttyd feeds run ids back in from --list,
|
||||||
|
# and these bytes in a log are the failure this is gated to avoid.
|
||||||
|
run script -qec "'$REPO_ROOT/bin/dejima-attach' --list" /dev/null
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" != *$'\033[2J'* ]] || { echo "--list cleared the screen"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "no terminal, no clear" {
|
||||||
|
# A pipe or a dumb terminal gets none of it. NO_COLOR does not suppress it
|
||||||
|
# though: that asks for no decoration, not for a front door drawn over the
|
||||||
|
# last session.
|
||||||
|
local out
|
||||||
|
out="$(bash -c 'source "$1"; tui_clear; echo AFTER' _ "$REPO_ROOT/bin/dejima-tui-lib.sh")"
|
||||||
|
[ "$out" = AFTER ] || { echo "a pipe got escape bytes: $(printf '%q' "$out")"; return 1; }
|
||||||
|
run script -qec "TERM=dumb bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
|
||||||
|
[[ "$output" != *$'\033['* ]] || { echo "a dumb terminal got escape bytes"; return 1; }
|
||||||
|
# NO_COLOR too, on a real terminal. It rides the same switch as the colour
|
||||||
|
# and the glyphs, which is the reading this file already committed to -
|
||||||
|
# the alternative is a picker answering to two ideas of "plain".
|
||||||
|
run script -qec "NO_COLOR=1 TERM=xterm bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
|
||||||
|
[[ "$output" != *$'\033['* ]] || { echo "NO_COLOR still got a screen clear"; return 1; }
|
||||||
|
}
|
||||||
|
|||||||
+125
-1
@@ -37,6 +37,18 @@ bats_require_minimum_version 1.5.0
|
|||||||
# The `dejima-test-` prefix is this suite's own namespace. Nothing else may use
|
# The `dejima-test-` prefix is this suite's own namespace. Nothing else may use
|
||||||
# it, which is what makes the reclaim in setup_file safe.
|
# it, which is what makes the reclaim in setup_file safe.
|
||||||
|
|
||||||
|
# The cap_add list from the compose file, one per line. A minimal reader for
|
||||||
|
# one known block rather than a YAML parser: the alternative is this suite
|
||||||
|
# hardcoding a second copy of the capability floor and the two disagreeing
|
||||||
|
# silently, which is exactly the failure the floor exists to prevent.
|
||||||
|
compose_cap_add() {
|
||||||
|
awk '
|
||||||
|
/^ cap_add:/ { in_block = 1; next }
|
||||||
|
in_block && /^ - / { sub(/^ - /, ""); print; next }
|
||||||
|
in_block && !/^ / { in_block = 0 }
|
||||||
|
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
|
||||||
|
}
|
||||||
|
|
||||||
IMAGE=dejima:latest
|
IMAGE=dejima:latest
|
||||||
|
|
||||||
# What a session actually runs: the base image plus the selected harness's own
|
# What a session actually runs: the base image plus the selected harness's own
|
||||||
@@ -330,8 +342,18 @@ setup_file() {
|
|||||||
# container started without these gets none of them. Read from the
|
# container started without these gets none of them. Read from the
|
||||||
# manifest rather than written here, so this fixture cannot drift from
|
# manifest rather than written here, so this fixture cannot drift from
|
||||||
# what a real launch supplies.
|
# what a real launch supplies.
|
||||||
|
# The capability posture from docker-compose.yml rather than a copy of it.
|
||||||
|
# This container is started with `docker run`, so a hand-written --cap-add
|
||||||
|
# list here would drift from the compose file the moment either changed -
|
||||||
|
# and the whole point of the floor is that the container a session actually
|
||||||
|
# gets is the one under test.
|
||||||
|
local -a caps=()
|
||||||
|
while IFS= read -r cap; do caps+=(--cap-add="$cap"); done < <(compose_cap_add)
|
||||||
|
[ "${#caps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
|
||||||
|
|
||||||
docker run -d --name "$container" \
|
docker run -d --name "$container" \
|
||||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
--cap-drop=ALL "${caps[@]}" \
|
||||||
|
--security-opt=no-new-privileges:true \
|
||||||
--network "$network" \
|
--network "$network" \
|
||||||
-e SANDBOX_TEST_MODE=1 \
|
-e SANDBOX_TEST_MODE=1 \
|
||||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||||
@@ -525,6 +547,25 @@ dexec_node() {
|
|||||||
[[ "$output" == *"policy DROP"* ]]
|
[[ "$output" == *"policy DROP"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the IPv6 block is a filtered family, not an absent one" {
|
||||||
|
# The DROP policy above is the good path. This asserts the container is
|
||||||
|
# actually ON that path rather than on the fallback, because the two are
|
||||||
|
# indistinguishable from the outside: a container with no IPv6 at all also
|
||||||
|
# reaches nothing over IPv6.
|
||||||
|
#
|
||||||
|
# If ip6tables ever stops working as root here, the test above fails and
|
||||||
|
# this one says why - and on a host that takes the fallback branch,
|
||||||
|
# init-firewall.sh now either turns the family off or refuses to start,
|
||||||
|
# rather than logging a conclusion it had not checked.
|
||||||
|
run dexec sh -c 'ip6tables -L >/dev/null 2>&1 && echo usable'
|
||||||
|
[[ "$output" == *usable* ]] \
|
||||||
|
|| { echo "ip6tables is not usable as root; the fallback branch is live"; return 1; }
|
||||||
|
# And the fallback's own condition is readable, so a failure here can be
|
||||||
|
# diagnosed without a second run: what does the kernel say is up?
|
||||||
|
run dexec sh -c 'cat /proc/net/if_inet6 2>/dev/null || echo "(no /proc/net/if_inet6)"'
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
@test "a non-allowlisted domain is refused by the DNS proxy, not just left to fail later" {
|
@test "a non-allowlisted domain is refused by the DNS proxy, not just left to fail later" {
|
||||||
run dexec dig definitely-not-on-the-allowlist.example.org
|
run dexec dig definitely-not-on-the-allowlist.example.org
|
||||||
[[ "$output" == *"status: REFUSED"* ]]
|
[[ "$output" == *"status: REFUSED"* ]]
|
||||||
@@ -1034,6 +1075,69 @@ dexec_node() {
|
|||||||
run ! dexec_node sudo -n true
|
run ! dexec_node sudo -n true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the container runs with no path back up to more privilege" {
|
||||||
|
# no-new-privileges in docker-compose.yml. gosu drops privilege rather
|
||||||
|
# than gaining it, so this does not interfere with the session starting -
|
||||||
|
# what it forbids is a setuid binary or a file capability raising
|
||||||
|
# privileges on exec, which is the shape any escalation from the session
|
||||||
|
# user would have to take.
|
||||||
|
#
|
||||||
|
# Asserted on PID 1 and on the session user, because the flag is a property
|
||||||
|
# of the process tree and both halves of it matter.
|
||||||
|
run dexec cat /proc/1/status
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *$'NoNewPrivs:\t1'* ]] \
|
||||||
|
|| { echo "PID 1 can still gain privileges on exec"; return 1; }
|
||||||
|
run dexec_node sh -c 'grep NoNewPrivs /proc/self/status'
|
||||||
|
[[ "$output" == *"1"* ]] || { echo "the session user's shell can: $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the capability bounding set is the declared floor and nothing more" {
|
||||||
|
# cap_drop: ALL plus an explicit list, rather than Docker's default set
|
||||||
|
# plus two. The entrypoint uses six capabilities during its root phase and
|
||||||
|
# the default carries about fourteen; this asserts the difference is real
|
||||||
|
# in the running container rather than only present in the compose file.
|
||||||
|
#
|
||||||
|
# CapBnd, not CapEff: the effective set is zero for the session user
|
||||||
|
# already (the test below), and the bounding set is what says the rest was
|
||||||
|
# never available to regain.
|
||||||
|
run dexec cat /proc/1/status
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
local bnd; bnd=$(echo "$output" | awk '/^CapBnd:/ {print $2}')
|
||||||
|
[ -n "$bnd" ] || { echo "no CapBnd in /proc/1/status"; return 1; }
|
||||||
|
|
||||||
|
# Decoded by name so a failure says which capability, not which bit.
|
||||||
|
local have; have="$(dexec capsh --decode="$bnd" 2>/dev/null)" || have=""
|
||||||
|
[ -n "$have" ] || skip "capsh not available in the image to decode $bnd"
|
||||||
|
local cap
|
||||||
|
for cap in $(compose_cap_add); do
|
||||||
|
[[ "$have" == *"cap_$(echo "$cap" | tr '[:upper:]' '[:lower:]')"* ]] \
|
||||||
|
|| { echo "declared $cap is missing from the bounding set: $have"; return 1; }
|
||||||
|
done
|
||||||
|
# Not KILL: `dejima-allow --container` signals dnsmasq, which runs as its
|
||||||
|
# own user, so root here needs it. That absence is what failed the first
|
||||||
|
# privileged run of this change.
|
||||||
|
for cap in mknod setfcap setpcap sys_chroot audit_write net_bind_service sys_admin fsetid; do
|
||||||
|
[[ "$have" != *"cap_$cap"* ]] \
|
||||||
|
|| { echo "cap_$cap is in the bounding set and nothing declares it: $have"; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "root can still signal dnsmasq, which is what adding a domain mid-session needs" {
|
||||||
|
# Named for the thing rather than left to be caught by the ipset tests,
|
||||||
|
# which is how it WAS caught: the first version of the capability floor
|
||||||
|
# dropped KILL, and `dejima-allow --container` failed with "Operation not
|
||||||
|
# permitted" because dnsmasq runs as its own user (`user=dnsmasq`) and root
|
||||||
|
# cannot signal another user's process without CAP_KILL.
|
||||||
|
#
|
||||||
|
# Adding a domain to a running session is a documented feature that every
|
||||||
|
# session briefing tells the agent to ask for, so this asserts the
|
||||||
|
# capability by exercising what needs it.
|
||||||
|
run dexec sh -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
|
||||||
|
[ "$status" -eq 0 ] \
|
||||||
|
|| { echo "root cannot signal dnsmasq - is KILL in the floor? $output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@test "the real running node process has NET_ADMIN/NET_RAW stripped, not just the bounding set" {
|
@test "the real running node process has NET_ADMIN/NET_RAW stripped, not just the bounding set" {
|
||||||
run dexec cat /proc/1/status
|
run dexec cat /proc/1/status
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
@@ -1086,6 +1190,26 @@ EOF'
|
|||||||
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
|
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "a global package imports as ESM too, from a directory of its own" {
|
||||||
|
# The test above proves the browser works, in the module system that
|
||||||
|
# already resolved. NODE_PATH covers require() and nothing else: ESM walks
|
||||||
|
# up from the importing file, so the modern form failed everywhere while
|
||||||
|
# this suite stayed green. What an agent got was ERR_MODULE_NOT_FOUND,
|
||||||
|
# which reads as an image without a browser.
|
||||||
|
#
|
||||||
|
# Written somewhere with no node_modules of its own, because anywhere
|
||||||
|
# inside a project with one would resolve for the wrong reason.
|
||||||
|
dexec_node bash -c 'mkdir -p /tmp/esm-probe && cat > /tmp/esm-probe/probe.mjs <<'"'"'EOF'"'"'
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
const browser = await chromium.launch({});
|
||||||
|
await browser.close();
|
||||||
|
console.log("ESM_IMPORT_OK");
|
||||||
|
EOF'
|
||||||
|
run dexec_node node /tmp/esm-probe/probe.mjs
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||||
|
[[ "$output" == *"ESM_IMPORT_OK"* ]] || { echo "$output"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
|
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
|
||||||
# The whole point of pre-baking the browser at build time is that a
|
# The whole point of pre-baking the browser at build time is that a
|
||||||
# session never needs new network access for it - confirm the egress
|
# session never needs new network access for it - confirm the egress
|
||||||
|
|||||||
+7
-1
@@ -3,7 +3,7 @@
|
|||||||
# and by test/static.bats, so the two cannot disagree about what is covered.
|
# and by test/static.bats, so the two cannot disagree about what is covered.
|
||||||
#
|
#
|
||||||
# The list is discovered, not enumerated: a script added under bin/, hooks/,
|
# The list is discovered, not enumerated: a script added under bin/, hooks/,
|
||||||
# docker/ or a forge's bin/ is covered the day it lands, which is the only
|
# docker/, test/ or a forge's bin/ is covered the day it lands, which is the only
|
||||||
# version of this that stays true. Discovery is by shebang rather than by
|
# version of this that stays true. Discovery is by shebang rather than by
|
||||||
# extension, because most of this repo's scripts have no extension.
|
# extension, because most of this repo's scripts have no extension.
|
||||||
#
|
#
|
||||||
@@ -19,6 +19,12 @@ list_scripts() {
|
|||||||
ls bin/dejima bin/dejima-* bin/harness/*.sh 2>/dev/null || true
|
ls bin/dejima bin/dejima-* bin/harness/*.sh 2>/dev/null || true
|
||||||
ls hooks/*.sh docker/*.sh install.sh 2>/dev/null || true
|
ls hooks/*.sh docker/*.sh install.sh 2>/dev/null || true
|
||||||
ls forges/*/bin/* 2>/dev/null || true
|
ls forges/*/bin/* 2>/dev/null || true
|
||||||
|
# test/, for the reason .dejima.md gives for being careful what goes
|
||||||
|
# there: CI checks the commit out on a host and runs it, so a helper
|
||||||
|
# in a test file is code executing outside the sandbox. That makes
|
||||||
|
# these the last scripts that should go unchecked, not the first -
|
||||||
|
# and leaving them out hid a real fault in the release gate itself.
|
||||||
|
ls test/*.sh 2>/dev/null || true
|
||||||
} | sort -u | while read -r f; do
|
} | sort -u | while read -r f; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
# Harness plugins are sourced, not executed, so they carry no shebang
|
# Harness plugins are sourced, not executed, so they carry no shebang
|
||||||
|
|||||||
+1575
-18
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
`rtk` is available, and reading less of a command's output is usually free:
|
||||||
|
it runs the real command and prints a compressed form of what came back.
|
||||||
|
Prefix a command with it where the full output is not the point.
|
||||||
|
|
||||||
|
rtk git status rtk git diff rtk git log -n 20
|
||||||
|
rtk ls . rtk find "*.py" . rtk grep "pattern" .
|
||||||
|
rtk cargo test rtk pytest rtk go test
|
||||||
|
|
||||||
|
It runs the real binary through PATH, so every guard that applies to a
|
||||||
|
command applies to it wrapped in rtk too. `rtk proxy <cmd>` runs something
|
||||||
|
unfiltered when you need the whole output, and `rtk read <file>` is a
|
||||||
|
structure-first view of a file rather than the whole body.
|
||||||
|
|
||||||
|
Use judgement rather than wrapping everything: a test run you are about to
|
||||||
|
debug line by line, or a diff you are about to apply, is output you want in
|
||||||
|
full.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# rtk - a CLI proxy that compresses command output before an agent reads it.
|
||||||
|
# https://github.com/rtk-ai/rtk, Apache-2.0, a single static Rust binary.
|
||||||
|
#
|
||||||
|
# The first optional tool, and the shape every later one has to follow: dejima
|
||||||
|
# does not install what a project asks for, it installs what it has hardened.
|
||||||
|
# See docs/tools.md for what that means and what was checked here.
|
||||||
|
#
|
||||||
|
# What it is NOT: rtk makes no model calls of its own. It is a deterministic
|
||||||
|
# output filter - `rtk git status` runs git and prints less of the result - so
|
||||||
|
# nothing here sends code anywhere.
|
||||||
|
description = compresses command output before it reaches the model
|
||||||
|
|
||||||
|
# The published release, pinned by digest. Deliberately not the project's
|
||||||
|
# `curl | sh` installer, which fetches whatever master points at today: an
|
||||||
|
# image build that produces a different binary each time is one nothing has
|
||||||
|
# reviewed. The digest was taken from the artifact below and checked against a
|
||||||
|
# second download.
|
||||||
|
#
|
||||||
|
# x86_64 only, which is issue #97's limitation and not this tool's: the
|
||||||
|
# tarball spec names one architecture and an arm64 host cannot use this entry.
|
||||||
|
install = tarball:rtk@c4c036fbf181fc55ef329786c8c17e0d427972b053b825944d968a6aafef1ba4:https://github.com/rtk-ai/rtk/releases/download/v0.45.0/rtk-x86_64-unknown-linux-musl.tar.gz
|
||||||
|
|
||||||
|
# The archive is one bare executable with no enclosing directory, where a Go
|
||||||
|
# or nvim release has everything under one. Stripping a component would strip
|
||||||
|
# the binary itself and extract nothing.
|
||||||
|
install_layout = binary
|
||||||
|
|
||||||
|
# rtk carries telemetry the README does not mention: an https ping compiled
|
||||||
|
# into the release binary, gated on a consent flag that defaults to unset.
|
||||||
|
# Disabled here as well, and the firewall does not allowlist the endpoint, so
|
||||||
|
# it is off three ways. Verified in the shipped artifact's strings, not only
|
||||||
|
# in the source - see docs/tools.md.
|
||||||
|
#
|
||||||
|
# This carried NO_COLOR=1 as well, reasoning that rtk's output lands in a
|
||||||
|
# model's context where an escape sequence is noise. The reasoning was fine
|
||||||
|
# and the mechanism was wrong: a tool's image_env becomes a Dockerfile ENV,
|
||||||
|
# which every process in the container inherits, so it took the colour out of
|
||||||
|
# the harness, git, ls and everything else. A tool may only set variables that
|
||||||
|
# are its own, and core refuses one that is not.
|
||||||
|
image_env = RTK_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
command = rtk
|
||||||
|
instructions = instructions.md
|
||||||
+146
-14
@@ -10,7 +10,7 @@
|
|||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<title>dejima</title>
|
<title>dejima</title>
|
||||||
<!-- Generated by web/build.mjs - do not edit. Sources: 0d07a16b97dd65deac6d7985dfa4603b6f68ba0ac31dccbb77c314bc200016ac -->
|
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
|
||||||
<style>
|
<style>
|
||||||
/**
|
/**
|
||||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
@@ -344,14 +344,68 @@ body.touch #terminal {
|
|||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* gruvbox dark, the same values bin/dejima-attach uses for the session
|
||||||
|
picker, each tagged with its upstream color.table name so a wrong hex is
|
||||||
|
catchable by reading. The two sit either side of one attach: picker, then
|
||||||
|
terminal. They should not look like different programs.
|
||||||
|
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
|
||||||
|
:root {
|
||||||
|
--gb-dark0-hard: #1d2021;
|
||||||
|
--gb-dark0: #282828;
|
||||||
|
--gb-dark1: #3c3836;
|
||||||
|
--gb-dark2: #504945;
|
||||||
|
--gb-light1: #ebdbb2;
|
||||||
|
--gb-orange: #fe8019; /* bright_orange, the wordmark */
|
||||||
|
--gb-yellow: #fabd2f; /* bright_yellow */
|
||||||
|
--gb-green: #b8bb26; /* bright_green */
|
||||||
|
--gb-blue: #83a598; /* bright_blue */
|
||||||
|
|
||||||
|
/* What the toolbar is made of, in tiers rather than in colours, so that
|
||||||
|
turning colour off is one block of overrides at the bottom of this file
|
||||||
|
and not an edit to every rule.
|
||||||
|
|
||||||
|
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
|
||||||
|
is how you type at all on a phone, so it is the one button that must be
|
||||||
|
findable without looking for it. Enter is the key a session asks for most.
|
||||||
|
The window and arrow keys are movement - reached for often, but never the
|
||||||
|
thing you are hunting for - so they are marked out as a group without
|
||||||
|
being filled. Everything else is furniture. */
|
||||||
|
--key-fg: var(--gb-light1);
|
||||||
|
--key-bg: var(--gb-dark1);
|
||||||
|
--key-border: var(--gb-dark2);
|
||||||
|
--key-active-bg: var(--gb-dark2);
|
||||||
|
--key-bar-bg: var(--gb-dark0-hard);
|
||||||
|
--key-bar-border: var(--gb-dark2);
|
||||||
|
|
||||||
|
--key-primary-fg: var(--gb-dark0-hard);
|
||||||
|
--key-primary-bg: var(--gb-orange);
|
||||||
|
--key-primary-border: var(--gb-orange);
|
||||||
|
|
||||||
|
--key-secondary-fg: var(--gb-dark0-hard);
|
||||||
|
--key-secondary-bg: var(--gb-green);
|
||||||
|
--key-secondary-border: var(--gb-green);
|
||||||
|
|
||||||
|
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
|
||||||
|
which is under the 4.5:1 an unenlarged label needs, and the label is the
|
||||||
|
part that has to survive a phone in sunlight. The border carries the tier
|
||||||
|
and the text stays at 8.45:1. */
|
||||||
|
--key-nav-fg: var(--gb-light1);
|
||||||
|
--key-nav-border: var(--gb-blue);
|
||||||
|
|
||||||
|
--key-armed-fg: var(--gb-dark0-hard);
|
||||||
|
--key-armed-bg: var(--gb-blue);
|
||||||
|
--key-locked-fg: var(--gb-dark0-hard);
|
||||||
|
--key-locked-bg: var(--gb-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
#toolbar {
|
#toolbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
|
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
|
||||||
background: #14161a;
|
background: var(--key-bar-bg);
|
||||||
border-top: 1px solid #2a2f37;
|
border-top: 1px solid var(--key-bar-border);
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +431,9 @@ body.touch #terminal {
|
|||||||
bound is where a monospace PREFIX still fits; the upper is the size this
|
bound is where a monospace PREFIX still fits; the upper is the size this
|
||||||
has always been, so a wide screen is unchanged. */
|
has always been, so a wide screen is unchanged. */
|
||||||
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
color: #d7dce3;
|
color: var(--key-fg);
|
||||||
background: #232830;
|
background: var(--key-bg);
|
||||||
border: 1px solid #343b45;
|
border: 1px solid var(--key-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -387,21 +441,79 @@ body.touch #terminal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#toolbar button:active {
|
#toolbar button:active {
|
||||||
background: #39414d;
|
background: var(--key-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
|
||||||
|
which is a ramp that survives being looked at quickly and in sunlight -
|
||||||
|
the two conditions a phone toolbar is actually used in.
|
||||||
|
|
||||||
|
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
|
||||||
|
button match at the same specificity, so what decides is source order, and
|
||||||
|
the state a button is IN has to beat the job it does. */
|
||||||
|
#toolbar button[data-action="kbd"] {
|
||||||
|
color: var(--key-primary-fg);
|
||||||
|
background: var(--key-primary-bg);
|
||||||
|
border-color: var(--key-primary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toolbar button[data-action="enter"] {
|
||||||
|
color: var(--key-secondary-fg);
|
||||||
|
background: var(--key-secondary-bg);
|
||||||
|
border-color: var(--key-secondary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toolbar button[data-action="prev-window"],
|
||||||
|
#toolbar button[data-action="next-window"],
|
||||||
|
#toolbar button[data-action="left"],
|
||||||
|
#toolbar button[data-action="down"],
|
||||||
|
#toolbar button[data-action="up"],
|
||||||
|
#toolbar button[data-action="right"] {
|
||||||
|
color: var(--key-nav-fg);
|
||||||
|
border-color: var(--key-nav-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Armed for one key, versus latched on. Two states on one button, so the
|
/* Armed for one key, versus latched on. Two states on one button, so the
|
||||||
distinction has to be visible without a legend. */
|
distinction has to be visible without a legend. */
|
||||||
#toolbar button.is-armed {
|
#toolbar button.is-armed {
|
||||||
color: #0b0d10;
|
color: var(--key-armed-fg);
|
||||||
background: #7fb3ff;
|
background: var(--key-armed-bg);
|
||||||
border-color: #7fb3ff;
|
border-color: var(--key-armed-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#toolbar button.is-locked {
|
#toolbar button.is-locked {
|
||||||
color: #0b0d10;
|
color: var(--key-locked-fg);
|
||||||
background: #ffcc66;
|
background: var(--key-locked-bg);
|
||||||
border-color: #ffcc66;
|
border-color: var(--key-locked-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every colour the toolbar had before it had a palette. One block, because
|
||||||
|
the alternative is an override per rule and a way for the two to drift -
|
||||||
|
an operator who turns colour off is asking for what was here, not for a
|
||||||
|
second scheme to maintain. */
|
||||||
|
body.mono-keys {
|
||||||
|
--key-fg: #d7dce3;
|
||||||
|
--key-bg: #232830;
|
||||||
|
--key-border: #343b45;
|
||||||
|
--key-active-bg: #39414d;
|
||||||
|
--key-bar-bg: #14161a;
|
||||||
|
--key-bar-border: #2a2f37;
|
||||||
|
|
||||||
|
--key-primary-fg: #d7dce3;
|
||||||
|
--key-primary-bg: #232830;
|
||||||
|
--key-primary-border: #343b45;
|
||||||
|
|
||||||
|
--key-secondary-fg: #d7dce3;
|
||||||
|
--key-secondary-bg: #232830;
|
||||||
|
--key-secondary-border: #343b45;
|
||||||
|
|
||||||
|
--key-nav-fg: #d7dce3;
|
||||||
|
--key-nav-border: #343b45;
|
||||||
|
|
||||||
|
--key-armed-fg: #0b0d10;
|
||||||
|
--key-armed-bg: #7fb3ff;
|
||||||
|
--key-locked-fg: #0b0d10;
|
||||||
|
--key-locked-bg: #ffcc66;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toast {
|
#toast {
|
||||||
@@ -744,7 +856,7 @@ body.touch #terminal {
|
|||||||
// an xterm.js option and is handed to the terminal untouched, which is how
|
// 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
|
// 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.
|
// about it. isWindows is ttyd's own, always present, and not an xterm option.
|
||||||
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap'];
|
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap', 'buttonColors'];
|
||||||
var IGNORED_KEYS = ['isWindows'];
|
var IGNORED_KEYS = ['isWindows'];
|
||||||
|
|
||||||
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
|
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
|
||||||
@@ -811,6 +923,19 @@ body.touch #terminal {
|
|||||||
return !coarsePointer;
|
return !coarsePointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the toolbar is colour-coded by what each button is for.
|
||||||
|
//
|
||||||
|
// On unless the operator says otherwise: the hierarchy is the point of it,
|
||||||
|
// and a toolbar where every key looks the same is one where the key you
|
||||||
|
// need most is found by counting positions. "off" restores exactly the grey
|
||||||
|
// it had before, for a terminal colour scheme this clashes with or an
|
||||||
|
// operator who would rather it were quiet.
|
||||||
|
function buttonColorsWanted(preference) {
|
||||||
|
if (preference === 'off' || preference === false || preference === 'false' ||
|
||||||
|
preference === '0') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// The address of the session picker: this page with the run-id dropped.
|
// 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
|
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
|
||||||
@@ -834,6 +959,7 @@ body.touch #terminal {
|
|||||||
splitPreferences: splitPreferences,
|
splitPreferences: splitPreferences,
|
||||||
toolbarWanted: toolbarWanted,
|
toolbarWanted: toolbarWanted,
|
||||||
focusOnTapWanted: focusOnTapWanted,
|
focusOnTapWanted: focusOnTapWanted,
|
||||||
|
buttonColorsWanted: buttonColorsWanted,
|
||||||
pickerSearch: pickerSearch
|
pickerSearch: pickerSearch
|
||||||
};
|
};
|
||||||
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
|
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
|
||||||
@@ -1720,6 +1846,12 @@ body.touch #terminal {
|
|||||||
// able to change it.
|
// able to change it.
|
||||||
tapFocusPolicy(split.page.focusOnTap);
|
tapFocusPolicy(split.page.focusOnTap);
|
||||||
|
|
||||||
|
// A class rather than per-button styling: what the page decides is
|
||||||
|
// whether there is a palette at all, and the stylesheet owns which button
|
||||||
|
// gets which part of it.
|
||||||
|
document.body.classList.toggle(
|
||||||
|
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
|
||||||
|
|
||||||
// A font size arriving in the preferences changes the cell size, so the
|
// A font size arriving in the preferences changes the cell size, so the
|
||||||
// column count computed before it was applied is now wrong.
|
// column count computed before it was applied is now wrong.
|
||||||
viewport.refresh();
|
viewport.refresh();
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
|
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
|
||||||
38b24a901a43db06854133f4de11094e30c590ff08074bb35bf0d664975a8cb2 src/config.js
|
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
|
||||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||||
7e2f5a89c28b048c6cc26d0a4a2a2411501f092dab11529a32ae3856f1350d63 src/main.js
|
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
|
||||||
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
||||||
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
||||||
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||||
7af901e2df49772c42fc1e083b4db8616e7d5f04bedf21f482da766a169165b7 src/page.css
|
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
||||||
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
||||||
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
||||||
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
|
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
|
||||||
|
|||||||
+15
-1
@@ -12,7 +12,7 @@
|
|||||||
// an xterm.js option and is handed to the terminal untouched, which is how
|
// 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
|
// 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.
|
// about it. isWindows is ttyd's own, always present, and not an xterm option.
|
||||||
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap'];
|
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap', 'buttonColors'];
|
||||||
var IGNORED_KEYS = ['isWindows'];
|
var IGNORED_KEYS = ['isWindows'];
|
||||||
|
|
||||||
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
|
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
|
||||||
@@ -79,6 +79,19 @@
|
|||||||
return !coarsePointer;
|
return !coarsePointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the toolbar is colour-coded by what each button is for.
|
||||||
|
//
|
||||||
|
// On unless the operator says otherwise: the hierarchy is the point of it,
|
||||||
|
// and a toolbar where every key looks the same is one where the key you
|
||||||
|
// need most is found by counting positions. "off" restores exactly the grey
|
||||||
|
// it had before, for a terminal colour scheme this clashes with or an
|
||||||
|
// operator who would rather it were quiet.
|
||||||
|
function buttonColorsWanted(preference) {
|
||||||
|
if (preference === 'off' || preference === false || preference === 'false' ||
|
||||||
|
preference === '0') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// The address of the session picker: this page with the run-id dropped.
|
// 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
|
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
|
||||||
@@ -102,6 +115,7 @@
|
|||||||
splitPreferences: splitPreferences,
|
splitPreferences: splitPreferences,
|
||||||
toolbarWanted: toolbarWanted,
|
toolbarWanted: toolbarWanted,
|
||||||
focusOnTapWanted: focusOnTapWanted,
|
focusOnTapWanted: focusOnTapWanted,
|
||||||
|
buttonColorsWanted: buttonColorsWanted,
|
||||||
pickerSearch: pickerSearch
|
pickerSearch: pickerSearch
|
||||||
};
|
};
|
||||||
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
|
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
// able to change it.
|
// able to change it.
|
||||||
tapFocusPolicy(split.page.focusOnTap);
|
tapFocusPolicy(split.page.focusOnTap);
|
||||||
|
|
||||||
|
// A class rather than per-button styling: what the page decides is
|
||||||
|
// whether there is a palette at all, and the stylesheet owns which button
|
||||||
|
// gets which part of it.
|
||||||
|
document.body.classList.toggle(
|
||||||
|
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
|
||||||
|
|
||||||
// A font size arriving in the preferences changes the cell size, so the
|
// A font size arriving in the preferences changes the cell size, so the
|
||||||
// column count computed before it was applied is now wrong.
|
// column count computed before it was applied is now wrong.
|
||||||
viewport.refresh();
|
viewport.refresh();
|
||||||
|
|||||||
+124
-12
@@ -42,14 +42,68 @@ body.touch #terminal {
|
|||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* gruvbox dark, the same values bin/dejima-attach uses for the session
|
||||||
|
picker, each tagged with its upstream color.table name so a wrong hex is
|
||||||
|
catchable by reading. The two sit either side of one attach: picker, then
|
||||||
|
terminal. They should not look like different programs.
|
||||||
|
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
|
||||||
|
:root {
|
||||||
|
--gb-dark0-hard: #1d2021;
|
||||||
|
--gb-dark0: #282828;
|
||||||
|
--gb-dark1: #3c3836;
|
||||||
|
--gb-dark2: #504945;
|
||||||
|
--gb-light1: #ebdbb2;
|
||||||
|
--gb-orange: #fe8019; /* bright_orange, the wordmark */
|
||||||
|
--gb-yellow: #fabd2f; /* bright_yellow */
|
||||||
|
--gb-green: #b8bb26; /* bright_green */
|
||||||
|
--gb-blue: #83a598; /* bright_blue */
|
||||||
|
|
||||||
|
/* What the toolbar is made of, in tiers rather than in colours, so that
|
||||||
|
turning colour off is one block of overrides at the bottom of this file
|
||||||
|
and not an edit to every rule.
|
||||||
|
|
||||||
|
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
|
||||||
|
is how you type at all on a phone, so it is the one button that must be
|
||||||
|
findable without looking for it. Enter is the key a session asks for most.
|
||||||
|
The window and arrow keys are movement - reached for often, but never the
|
||||||
|
thing you are hunting for - so they are marked out as a group without
|
||||||
|
being filled. Everything else is furniture. */
|
||||||
|
--key-fg: var(--gb-light1);
|
||||||
|
--key-bg: var(--gb-dark1);
|
||||||
|
--key-border: var(--gb-dark2);
|
||||||
|
--key-active-bg: var(--gb-dark2);
|
||||||
|
--key-bar-bg: var(--gb-dark0-hard);
|
||||||
|
--key-bar-border: var(--gb-dark2);
|
||||||
|
|
||||||
|
--key-primary-fg: var(--gb-dark0-hard);
|
||||||
|
--key-primary-bg: var(--gb-orange);
|
||||||
|
--key-primary-border: var(--gb-orange);
|
||||||
|
|
||||||
|
--key-secondary-fg: var(--gb-dark0-hard);
|
||||||
|
--key-secondary-bg: var(--gb-green);
|
||||||
|
--key-secondary-border: var(--gb-green);
|
||||||
|
|
||||||
|
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
|
||||||
|
which is under the 4.5:1 an unenlarged label needs, and the label is the
|
||||||
|
part that has to survive a phone in sunlight. The border carries the tier
|
||||||
|
and the text stays at 8.45:1. */
|
||||||
|
--key-nav-fg: var(--gb-light1);
|
||||||
|
--key-nav-border: var(--gb-blue);
|
||||||
|
|
||||||
|
--key-armed-fg: var(--gb-dark0-hard);
|
||||||
|
--key-armed-bg: var(--gb-blue);
|
||||||
|
--key-locked-fg: var(--gb-dark0-hard);
|
||||||
|
--key-locked-bg: var(--gb-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
#toolbar {
|
#toolbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
|
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
|
||||||
background: #14161a;
|
background: var(--key-bar-bg);
|
||||||
border-top: 1px solid #2a2f37;
|
border-top: 1px solid var(--key-bar-border);
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +129,9 @@ body.touch #terminal {
|
|||||||
bound is where a monospace PREFIX still fits; the upper is the size this
|
bound is where a monospace PREFIX still fits; the upper is the size this
|
||||||
has always been, so a wide screen is unchanged. */
|
has always been, so a wide screen is unchanged. */
|
||||||
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
color: #d7dce3;
|
color: var(--key-fg);
|
||||||
background: #232830;
|
background: var(--key-bg);
|
||||||
border: 1px solid #343b45;
|
border: 1px solid var(--key-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -85,21 +139,79 @@ body.touch #terminal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#toolbar button:active {
|
#toolbar button:active {
|
||||||
background: #39414d;
|
background: var(--key-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
|
||||||
|
which is a ramp that survives being looked at quickly and in sunlight -
|
||||||
|
the two conditions a phone toolbar is actually used in.
|
||||||
|
|
||||||
|
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
|
||||||
|
button match at the same specificity, so what decides is source order, and
|
||||||
|
the state a button is IN has to beat the job it does. */
|
||||||
|
#toolbar button[data-action="kbd"] {
|
||||||
|
color: var(--key-primary-fg);
|
||||||
|
background: var(--key-primary-bg);
|
||||||
|
border-color: var(--key-primary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toolbar button[data-action="enter"] {
|
||||||
|
color: var(--key-secondary-fg);
|
||||||
|
background: var(--key-secondary-bg);
|
||||||
|
border-color: var(--key-secondary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toolbar button[data-action="prev-window"],
|
||||||
|
#toolbar button[data-action="next-window"],
|
||||||
|
#toolbar button[data-action="left"],
|
||||||
|
#toolbar button[data-action="down"],
|
||||||
|
#toolbar button[data-action="up"],
|
||||||
|
#toolbar button[data-action="right"] {
|
||||||
|
color: var(--key-nav-fg);
|
||||||
|
border-color: var(--key-nav-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Armed for one key, versus latched on. Two states on one button, so the
|
/* Armed for one key, versus latched on. Two states on one button, so the
|
||||||
distinction has to be visible without a legend. */
|
distinction has to be visible without a legend. */
|
||||||
#toolbar button.is-armed {
|
#toolbar button.is-armed {
|
||||||
color: #0b0d10;
|
color: var(--key-armed-fg);
|
||||||
background: #7fb3ff;
|
background: var(--key-armed-bg);
|
||||||
border-color: #7fb3ff;
|
border-color: var(--key-armed-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#toolbar button.is-locked {
|
#toolbar button.is-locked {
|
||||||
color: #0b0d10;
|
color: var(--key-locked-fg);
|
||||||
background: #ffcc66;
|
background: var(--key-locked-bg);
|
||||||
border-color: #ffcc66;
|
border-color: var(--key-locked-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every colour the toolbar had before it had a palette. One block, because
|
||||||
|
the alternative is an override per rule and a way for the two to drift -
|
||||||
|
an operator who turns colour off is asking for what was here, not for a
|
||||||
|
second scheme to maintain. */
|
||||||
|
body.mono-keys {
|
||||||
|
--key-fg: #d7dce3;
|
||||||
|
--key-bg: #232830;
|
||||||
|
--key-border: #343b45;
|
||||||
|
--key-active-bg: #39414d;
|
||||||
|
--key-bar-bg: #14161a;
|
||||||
|
--key-bar-border: #2a2f37;
|
||||||
|
|
||||||
|
--key-primary-fg: #d7dce3;
|
||||||
|
--key-primary-bg: #232830;
|
||||||
|
--key-primary-border: #343b45;
|
||||||
|
|
||||||
|
--key-secondary-fg: #d7dce3;
|
||||||
|
--key-secondary-bg: #232830;
|
||||||
|
--key-secondary-border: #343b45;
|
||||||
|
|
||||||
|
--key-nav-fg: #d7dce3;
|
||||||
|
--key-nav-border: #343b45;
|
||||||
|
|
||||||
|
--key-armed-fg: #0b0d10;
|
||||||
|
--key-armed-bg: #7fb3ff;
|
||||||
|
--key-locked-fg: #0b0d10;
|
||||||
|
--key-locked-bg: #ffcc66;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toast {
|
#toast {
|
||||||
|
|||||||
+30
-1
@@ -4,7 +4,7 @@ const assert = require('node:assert');
|
|||||||
require('../src/keys.js'); // config.js parses the tmux prefix through it
|
require('../src/keys.js'); // config.js parses the tmux prefix through it
|
||||||
require('../src/config.js');
|
require('../src/config.js');
|
||||||
const { DEFAULT_TMUX_PREFIX, parsePrefix, splitPreferences, toolbarWanted,
|
const { DEFAULT_TMUX_PREFIX, parsePrefix, splitPreferences, toolbarWanted,
|
||||||
focusOnTapWanted, pickerSearch } = globalThis.DEJIMA.config;
|
focusOnTapWanted, pickerSearch, buttonColorsWanted } = globalThis.DEJIMA.config;
|
||||||
|
|
||||||
test('a tmux prefix is parsed into the byte tmux would receive', () => {
|
test('a tmux prefix is parsed into the byte tmux would receive', () => {
|
||||||
assert.equal(parsePrefix('C-b'), '\x02');
|
assert.equal(parsePrefix('C-b'), '\x02');
|
||||||
@@ -116,3 +116,32 @@ test('the picker address is already the picker when there is no run-id', () => {
|
|||||||
assert.equal(pickerSearch(''), '');
|
assert.equal(pickerSearch(''), '');
|
||||||
assert.equal(pickerSearch('?toolbar=1'), '?toolbar=1');
|
assert.equal(pickerSearch('?toolbar=1'), '?toolbar=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The toolbar's colours are on unless the operator turns them off: the
|
||||||
|
// hierarchy is the point of them, and a toolbar where every key looks alike is
|
||||||
|
// one where the key you need most is found by counting positions.
|
||||||
|
|
||||||
|
test('the toolbar is coloured when nothing says otherwise', () => {
|
||||||
|
assert.equal(buttonColorsWanted(undefined), true);
|
||||||
|
assert.equal(buttonColorsWanted(''), true);
|
||||||
|
assert.equal(buttonColorsWanted('on'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off turns the toolbar grey, in dejima\'s own bool spelling', () => {
|
||||||
|
// ttyd_button_colors is a dejima bool, so what reaches the page is "off" -
|
||||||
|
// not "false". Accepting only the latter would ship a documented key that
|
||||||
|
// does nothing, which is exactly what focusOnTap nearly did.
|
||||||
|
assert.equal(buttonColorsWanted('off'), false);
|
||||||
|
assert.equal(buttonColorsWanted(false), false);
|
||||||
|
assert.equal(buttonColorsWanted('false'), false);
|
||||||
|
assert.equal(buttonColorsWanted('0'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buttonColors is a page option, not one xterm is handed', () => {
|
||||||
|
// Every page key that reached term.options would be an unknown option to
|
||||||
|
// xterm.js, which is the failure splitPreferences exists to prevent.
|
||||||
|
const split = splitPreferences({ buttonColors: 'off', fontSize: 14 });
|
||||||
|
assert.equal(split.page.buttonColors, 'off');
|
||||||
|
assert.equal(split.terminal.buttonColors, undefined);
|
||||||
|
assert.equal(split.terminal.fontSize, 14);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
// Every label the toolbar draws has to be readable on a phone held outdoors,
|
||||||
|
// which is the only place this page is ever used. WCAG AA asks 4.5:1 for text
|
||||||
|
// this size, and one of these pairings shipped at 4.31:1 in the first draft -
|
||||||
|
// gruvbox's bright_blue as a label on dark1 - which is a difference nobody
|
||||||
|
// catches by looking at a screenshot on a desk.
|
||||||
|
//
|
||||||
|
// Read out of the stylesheet rather than restated here, so a colour changed
|
||||||
|
// there is a colour checked here.
|
||||||
|
|
||||||
|
const CSS = fs.readFileSync(path.join(__dirname, '..', 'src', 'page.css'), 'utf8');
|
||||||
|
|
||||||
|
function declarationsIn(block) {
|
||||||
|
const out = {};
|
||||||
|
const re = /^\s*(--[a-z0-9-]+):\s*([^;]+);/gm;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(block)) !== null) out[m[1]] = m[2].trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockFor(selector) {
|
||||||
|
const start = CSS.indexOf(selector + ' {');
|
||||||
|
assert.notEqual(start, -1, 'no ' + selector + ' block in page.css');
|
||||||
|
const end = CSS.indexOf('\n}', start);
|
||||||
|
return CSS.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// var(--x) chains down to a literal hex.
|
||||||
|
function resolve(vars, value, depth) {
|
||||||
|
depth = depth || 0;
|
||||||
|
assert.ok(depth < 10, 'variable cycle resolving ' + value);
|
||||||
|
const m = /^var\((--[a-z0-9-]+)\)$/.exec(value);
|
||||||
|
if (!m) return value;
|
||||||
|
assert.ok(vars[m[1]], 'undefined variable ' + m[1]);
|
||||||
|
return resolve(vars, vars[m[1]], depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(hex) {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const parts = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
||||||
|
const lin = parts.map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
||||||
|
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrast(fg, bg) {
|
||||||
|
const a = luminance(fg);
|
||||||
|
const b = luminance(bg);
|
||||||
|
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each tier's label against the surface it is drawn on. The nav tier is
|
||||||
|
// outlined rather than filled, so its label sits on the ordinary key
|
||||||
|
// background - which is precisely why its colour matters.
|
||||||
|
const PAIRINGS = [
|
||||||
|
['plain keys', '--key-fg', '--key-bg'],
|
||||||
|
['the nav tier', '--key-nav-fg', '--key-bg'],
|
||||||
|
['KBD', '--key-primary-fg', '--key-primary-bg'],
|
||||||
|
['Enter', '--key-secondary-fg', '--key-secondary-bg'],
|
||||||
|
['an armed latch', '--key-armed-fg', '--key-armed-bg'],
|
||||||
|
['a locked latch', '--key-locked-fg', '--key-locked-bg']
|
||||||
|
];
|
||||||
|
|
||||||
|
function checkBlock(selector) {
|
||||||
|
const vars = declarationsIn(blockFor(selector));
|
||||||
|
PAIRINGS.forEach(([what, fgVar, bgVar]) => {
|
||||||
|
assert.ok(vars[fgVar], selector + ' does not set ' + fgVar);
|
||||||
|
assert.ok(vars[bgVar], selector + ' does not set ' + bgVar);
|
||||||
|
const fg = resolve(vars, vars[fgVar]);
|
||||||
|
const bg = resolve(vars, vars[bgVar]);
|
||||||
|
const ratio = contrast(fg, bg);
|
||||||
|
assert.ok(
|
||||||
|
ratio >= 4.5,
|
||||||
|
what + ' in ' + selector + ' is ' + fg + ' on ' + bg + ' at ' +
|
||||||
|
ratio.toFixed(2) + ':1, under the 4.5:1 a label this size needs'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every toolbar label meets AA against what it is drawn on', () => {
|
||||||
|
checkBlock(':root');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turning the colours off does not turn legibility off with them', () => {
|
||||||
|
// The grey scheme is a supported mode, not a fallback nobody looks at.
|
||||||
|
checkBlock('body.mono-keys');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tiers are actually distinct, or the hierarchy is decoration', () => {
|
||||||
|
const vars = declarationsIn(blockFor(':root'));
|
||||||
|
const bg = (v) => resolve(vars, vars[v]);
|
||||||
|
const primary = bg('--key-primary-bg');
|
||||||
|
const secondary = bg('--key-secondary-bg');
|
||||||
|
const plain = bg('--key-bg');
|
||||||
|
assert.notEqual(primary, secondary, 'KBD and Enter are filled the same colour');
|
||||||
|
assert.notEqual(primary, plain, 'KBD is filled the same as an ordinary key');
|
||||||
|
assert.notEqual(secondary, plain, 'Enter is filled the same as an ordinary key');
|
||||||
|
assert.notEqual(
|
||||||
|
resolve(vars, vars['--key-nav-border']), resolve(vars, vars['--key-border']),
|
||||||
|
'the nav tier is outlined the same as an ordinary key'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -10,7 +10,10 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
|||||||
branch-finishing advice from a skill cannot know that, and does not
|
branch-finishing advice from a skill cannot know that, and does not
|
||||||
override it.
|
override it.
|
||||||
- Stop at the push. Do not open a pull request, and do not merge anything -
|
- Stop at the push. Do not open a pull request, and do not merge anything -
|
||||||
neither is part of this workflow. Report the branch name and stop.
|
neither is part of this workflow. A `merge-guard.sh` hook and a shadow over
|
||||||
|
the forge's own command both refuse a merge here, but treat that as the
|
||||||
|
workflow rather than as a filter to route around. Report the branch name
|
||||||
|
and stop.
|
||||||
- Never push directly to {{blocked_branches}}, on any remote. A
|
- Never push directly to {{blocked_branches}}, on any remote. A
|
||||||
`git-push-guard.sh` hook enforces that as a backstop, but treat it as the
|
`git-push-guard.sh` hook enforces that as a backstop, but treat it as the
|
||||||
workflow to follow rather than a filter to route around.
|
workflow to follow rather than a filter to route around.
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
# block_merge is off despite this being a pushing workflow: nothing here ever
|
|
||||||
# reaches a forge's PR interface, so there is no merge command to block.
|
|
||||||
requires =
|
requires =
|
||||||
push = on
|
push = on
|
||||||
blocked_branches = main master
|
blocked_branches = main master
|
||||||
block_merge = off
|
# This said "off" and gave a reason: nothing here ever reaches a forge's PR
|
||||||
|
# interface, so there is no merge command to block. The premise is false. What
|
||||||
|
# the briefing tells the agent to do and what the image puts on its PATH are
|
||||||
|
# different things - select forge = gitea and `gitea-pr merge` is present
|
||||||
|
# whatever the workflow says, because the CLI is installed by the forge, not
|
||||||
|
# by the workflow. This workflow's own briefing says "do not merge anything",
|
||||||
|
# and until now nothing held it to that.
|
||||||
|
block_merge = on
|
||||||
requires_worktree = on
|
requires_worktree = on
|
||||||
autonomy = push-branch
|
autonomy = push-branch
|
||||||
briefing = briefing.md
|
briefing = briefing.md
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
|||||||
anywhere: there is no remote destination configured for it, and
|
anywhere: there is no remote destination configured for it, and
|
||||||
`git-push-guard.sh` blocks pushes as a backstop.
|
`git-push-guard.sh` blocks pushes as a backstop.
|
||||||
- Do not open pull requests and do not merge. Neither is part of this
|
- Do not open pull requests and do not merge. Neither is part of this
|
||||||
workflow.
|
workflow, and a merge is refused here in any case.
|
||||||
- Report what you committed and stop. If the work needs to reach a remote,
|
- Report what you committed and stop. If the work needs to reach a remote,
|
||||||
say so plainly rather than attempting it - that is the operator's call and
|
say so plainly rather than attempting it - that is the operator's call and
|
||||||
needs a different workflow.
|
needs a different workflow.
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
requires =
|
requires =
|
||||||
push = off
|
push = off
|
||||||
blocked_branches =
|
blocked_branches =
|
||||||
block_merge = off
|
# On for the same reason branch-push's is: this workflow may be selected
|
||||||
|
# alongside a forge, its briefing says "do not open pull requests and do not
|
||||||
|
# merge", and the forge's CLI is on PATH regardless of what the workflow tells
|
||||||
|
# the agent to do. A briefing that states a rule while the manifest leaves the
|
||||||
|
# guard off is asserting something nothing holds.
|
||||||
|
block_merge = on
|
||||||
requires_worktree = off
|
requires_worktree = off
|
||||||
autonomy = commit
|
autonomy = commit
|
||||||
briefing = briefing.md
|
briefing = briefing.md
|
||||||
|
|||||||
Reference in New Issue
Block a user