Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40d7de6b7f | ||
|
|
15878fd3b7 |
+55
-37
@@ -68,13 +68,19 @@ Missing directories are created. Existing DSH home must be user-owned and privat
|
|||||||
`chmod 700 ~/.dsh` (or your actual `DSH_HOME`); existing credentials must be mode
|
`chmod 700 ~/.dsh` (or your actual `DSH_HOME`); existing credentials must be mode
|
||||||
600 as DSH requires. The launcher never reads the credentials into Nix, copies
|
600 as DSH requires. The launcher never reads the credentials into Nix, copies
|
||||||
them to the store, or replaces your settings/skills with generated content.
|
them to the store, or replaces your settings/skills with generated content.
|
||||||
|
Before starting the Web UI, the guest installs **only `dsh-context`** into the
|
||||||
|
shared `web` profile if missing (details below). DSH/pnpm manages that profile's
|
||||||
|
manifest, lockfile and dependencies; existing settings and other profiles stay intact.
|
||||||
The one bundled `playwright-firefox` skill is seeded into the actual shared skills
|
The one bundled `playwright-firefox` skill is seeded into the actual shared skills
|
||||||
directory only if its `SKILL.md` is absent. It is a normal writable file (0600),
|
directory only if its `SKILL.md` is absent. It is a normal writable file (0600),
|
||||||
not a store symlink; existing files and user edits are never overwritten.
|
not a store symlink; existing files and user edits are never overwritten.
|
||||||
|
|
||||||
Root inside the guest is **not host root**. QEMU/9p writes as the launching host
|
Root inside the guest is **not host root**. Bubblewrap maps the launching user's
|
||||||
user, so newly created project files belong to you. The whole DSH directory is
|
UID/GID to namespace `0:0`, so 9p ownership matches guest root and pnpm's atomic
|
||||||
mounted instead of individual credential files so atomic rename-based saves work.
|
saves can preserve it. All capabilities remain dropped. QEMU/9p still writes as
|
||||||
|
the launching host user, so newly created project files belong to you. The whole
|
||||||
|
DSH directory is mounted instead of individual credential files so atomic
|
||||||
|
rename-based saves work.
|
||||||
DSH retains each project's real cwd identity rather than confusing every project's
|
DSH retains each project's real cwd identity rather than confusing every project's
|
||||||
sessions with a single `/workspace` identity. Do not run host DSH and several
|
sessions with a single `/workspace` identity. Do not run host DSH and several
|
||||||
VMs concurrently against the same mutable profile: package-fallback links and
|
VMs concurrently against the same mutable profile: package-fallback links and
|
||||||
@@ -82,7 +88,7 @@ profile changes can race. Separate `DSH_HOME` values provide independent profile
|
|||||||
|
|
||||||
The runner retains a private per-cwd directory under
|
The runner retains a private per-cwd directory under
|
||||||
`${XDG_STATE_HOME:-~/.local/state}/agent-vm/`: dedicated SSH keys, console log,
|
`${XDG_STATE_HOME:-~/.local/state}/agent-vm/`: dedicated SSH keys, console log,
|
||||||
control socket, and a 4 GiB sparse npm-cache disk. Guest root, guest Nix writes,
|
control socket, and a 4 GiB sparse npm/pnpm-cache disk. Guest root, guest Nix writes,
|
||||||
processes and other unshared state are otherwise ephemeral. This is **not** a
|
processes and other unshared state are otherwise ephemeral. This is **not** a
|
||||||
push/pull/snapshot workflow; edits immediately affect the mounted host files.
|
push/pull/snapshot workflow; edits immediately affect the mounted host files.
|
||||||
|
|
||||||
@@ -268,8 +274,10 @@ loopback publication default does not hide guest SSH from other routed peers.
|
|||||||
for this mutable part. `latest` can itself be a release candidate. A two-line
|
for this mutable part. `latest` can itself be a release candidate. A two-line
|
||||||
Node shim supplies `--expose-internals`, required by the current Cordis HMR
|
Node shim supplies `--expose-internals`, required by the current Cordis HMR
|
||||||
dependency but missing from the published CLI's shebang.
|
dependency but missing from the published CLI's shebang.
|
||||||
- **Plugins:** explicitly opt in, then update with DSH/pnpm. No unreviewed community
|
- **Plugin:** the guest installs `dsh-context@latest` into the `web` profile once.
|
||||||
plugins are automatically installed or upgraded by this repository.
|
Normal restarts keep the installed version; updates are explicit and targeted
|
||||||
|
to `dsh-context` below. This is mutable DSH/pnpm state, not a Nix-pinned package.
|
||||||
|
No other third-party plugin is added or automatically upgraded.
|
||||||
- **Workstation:** the root `/etc/nix` flake now also follows `nixos-unstable` and
|
- **Workstation:** the root `/etc/nix` flake now also follows `nixos-unstable` and
|
||||||
Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily
|
Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily
|
||||||
updater refreshes those inputs and stages the tested result for next boot.
|
updater refreshes those inputs and stages the tested result for next boot.
|
||||||
@@ -277,43 +285,43 @@ loopback publication default does not hide guest SSH from other routed peers.
|
|||||||
The existing separate Neovim-dotfile revision and manually packaged Element
|
The existing separate Neovim-dotfile revision and manually packaged Element
|
||||||
binary are unchanged; their documented update boundaries still apply.
|
binary are unchanged; their documented update boundaries still apply.
|
||||||
|
|
||||||
## Plugins worth considering
|
## Included context plugin
|
||||||
|
|
||||||
DSH is still a developer preview. Popularity is not a security audit or a promise
|
[**dsh-context**](https://github.com/bowenliang123/dsh-context) is the only
|
||||||
of compatibility with tomorrow's `latest`. These are actual DSH plugins, not
|
third-party plugin added by this setup. It provides the **Context** tab and
|
||||||
OpenCode plugins relabeled as DeepSeek plugins. GitHub stars checked 2026-09-06:
|
**`/context`** command for context composition, token use and history. The guest's
|
||||||
|
`agent.service` runs `dsh plugin --profile web add dsh-context@latest` before its
|
||||||
|
first Web startup; no manual install or host package install is needed. Update
|
||||||
|
the helper input and stop/restart existing VMs to pick up this setup change.
|
||||||
|
|
||||||
| Plugin | Why consider it | Approx. repository stars |
|
The installed plugin and bundle registration live in the writable
|
||||||
| --- | --- | --- |
|
`$DSH_HOME/profiles/web`, alongside the profile's pnpm lockfile. Already-installed
|
||||||
| [DSH better sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | File explorer/editor, Git and terminal alongside the conversation | 3.4k |
|
versions are left alone. An incomplete install (missing package or bundle
|
||||||
| [dsh-market](https://github.com/dsh-market/dsh-market) | Discover/manage plugins in the DSH settings UI | 3.3k |
|
registration) is retried with the profile's existing dependency spec, if any.
|
||||||
| [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams) | Multi-agent delegation; add only when you need it, as it can multiply API cost | 1.4k |
|
Removing the required plugin causes it to be added again at the next service
|
||||||
| [dsh-context](https://github.com/bowenliang123/dsh-context) | Inspect context/token use and manage context | 1.3k |
|
start. Existing user-installed plugins are **not removed**, and no settings,
|
||||||
|
credentials, profile patches or other profiles are replaced. Use a separate
|
||||||
|
`DSH_HOME` for an independent profile; don't run concurrent writers on one profile.
|
||||||
|
|
||||||
Start with **dsh-context**, then optionally the sidebar. A marketplace and a
|
First installation needs registry access. Failure blocks Web startup rather than
|
||||||
multi-agent orchestrator are not necessary to make the core harness useful.
|
silently omitting the plugin; inspect `journalctl -u agent -b` inside the VM.
|
||||||
Review their source first; native-addon build prerequisites vary. Example:
|
The DSH wrapper puts pnpm's store on `/var/cache/dsh/pnpm`: its SQLite index needs
|
||||||
|
the guest's local cache disk, not the shared 9p filesystem. Installed packages and
|
||||||
|
profile files remain shared. No build-script approval policy is relaxed.
|
||||||
|
To explicitly update **only** this plugin, inside the guest:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix run .#agent -- ssh
|
nix run .#agent -- ssh
|
||||||
# Now INSIDE the VM, with your real DSH home mounted RW:
|
systemctl stop agent
|
||||||
dsh plugin --profile web add dsh-context@latest
|
dsh plugin --profile web update dsh-context@latest
|
||||||
# Optional, separately reviewed:
|
systemctl start agent
|
||||||
dsh plugin --profile web add dshmarket@latest
|
|
||||||
systemctl restart agent
|
|
||||||
exit
|
exit
|
||||||
nix run .#agent -- url # restart creates a fresh browser launch token
|
nix run .#agent -- url # restart creates a fresh browser launch token
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `dsh plugin --profile web update --latest` when you explicitly want to update
|
DSH and this plugin remain developer-preview code, not an audited combination or
|
||||||
installed plugins. Review pnpm's build-script approval requests rather than
|
a compatibility guarantee for tomorrow's `latest`. The Firefox CLI + skill needs
|
||||||
blanket-approving everything. The built-in MCP bridge can also connect to popular
|
no additional plugin or external MCP server.
|
||||||
projects such as [Context7](https://github.com/upstash/context7) and
|
|
||||||
[Playwright MCP](https://github.com/microsoft/playwright-mcp); those are separate
|
|
||||||
MCP services, not evidence that a particular DSH adapter is widely deployed.
|
|
||||||
The bundled Firefox **CLI + skill** above does not need Playwright MCP; use the
|
|
||||||
separate server only if you explicitly need that interface. No host browser
|
|
||||||
sessions, external MCP credentials or arbitrary host skill symlink targets are imported.
|
|
||||||
|
|
||||||
## Sandbox boundary and limitations
|
## Sandbox boundary and limitations
|
||||||
|
|
||||||
@@ -349,6 +357,7 @@ sessions, external MCP credentials or arbitrary host skill symlink targets are i
|
|||||||
nix flake check path:/etc/nix/agent-vm --no-build
|
nix flake check path:/etc/nix/agent-vm --no-build
|
||||||
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.config \
|
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.config \
|
||||||
path:/etc/nix/agent-vm#checks.x86_64-linux.shell \
|
path:/etc/nix/agent-vm#checks.x86_64-linux.shell \
|
||||||
|
path:/etc/nix/agent-vm#checks.x86_64-linux.context \
|
||||||
path:/etc/nix/agent-vm#checks.x86_64-linux.playwright --no-link
|
path:/etc/nix/agent-vm#checks.x86_64-linux.playwright --no-link
|
||||||
# Actual offline microVM boot/mount/SSH/browser test, with a fake harness (no API calls):
|
# Actual offline microVM boot/mount/SSH/browser test, with a fake harness (no API calls):
|
||||||
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link
|
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link
|
||||||
@@ -357,6 +366,11 @@ nix build path:/etc/nix/agent-vm#checks.x86_64-linux.ports --no-link
|
|||||||
```
|
```
|
||||||
|
|
||||||
The boot and port tests require KVM and nested user namespaces in the Nix build sandbox.
|
The boot and port tests require KVM and nested user namespaces in the Nix build sandbox.
|
||||||
|
The context check uses a strict offline DSH fixture to check context-only install,
|
||||||
|
restart idempotence, preservation of existing profile data/version selections,
|
||||||
|
incomplete-install repair, failure/retry and malformed-profile rejection. The boot
|
||||||
|
check also verifies setup before Web startup and writable host-owned plugin state
|
||||||
|
that survives a service restart without reinstalling.
|
||||||
The Playwright check launches two real Firefox instances concurrently against a
|
The Playwright check launches two real Firefox instances concurrently against a
|
||||||
local HTTP fixture, checks headless/default browser selection, separate cookies,
|
local HTTP fixture, checks headless/default browser selection, separate cookies,
|
||||||
DOM and localStorage, screenshots, close-one/keep-one behavior and profile expiry.
|
DOM and localStorage, screenshots, close-one/keep-one behavior and profile expiry.
|
||||||
@@ -370,10 +384,14 @@ Validated here: template/host/helper flake evaluation, ShellCheck, the real
|
|||||||
microVM offline boot/mount/ownership/isolation test, and a separate live official
|
microVM offline boot/mount/ownership/isolation test, and a separate live official
|
||||||
DSH Web launch with disposable config. The live check returned **401** without
|
DSH Web launch with disposable config. The live check returned **401** without
|
||||||
a cookie, **200** after the token exchange, and **403** for an untrusted Host;
|
a cookie, **200** after the token exchange, and **403** for an untrusted Host;
|
||||||
DSH stayed running without restarts. No real API credentials, model requests,
|
DSH stayed running without restarts. That core-only check used no real API
|
||||||
community plugins, TAP network provisioning, or host activation were involved.
|
credentials, model requests, TAP network provisioning or host activation.
|
||||||
The Firefox two-session regression also passed natively and in the real headless
|
The Firefox two-session regression also passed natively and in the real headless
|
||||||
microVM, including screenshot generation and writable, non-clobbering skill seeding.
|
microVM, including screenshot generation and writable, non-clobbering skill seeding.
|
||||||
|
A separate live VM check with a disposable DSH home installed only `dsh-context`,
|
||||||
|
verified its composed bundle and authenticated HTTP **200**, then confirmed a
|
||||||
|
service restart left the manifest/lockfile unchanged without reinstalling. No
|
||||||
|
real credentials or model calls were used; the test VM and profile were removed.
|
||||||
|
|
||||||
Research used the **new official wiki**, plus upstream sources:
|
Research used the **new official wiki**, plus upstream sources:
|
||||||
|
|
||||||
@@ -392,5 +410,5 @@ Research used the **new official wiki**, plus upstream sources:
|
|||||||
- [Official Playwright CLI](https://github.com/microsoft/playwright-cli), its
|
- [Official Playwright CLI](https://github.com/microsoft/playwright-cli), its
|
||||||
[session-management reference](https://github.com/microsoft/playwright-cli/blob/main/skills/playwright-cli/references/session-management.md),
|
[session-management reference](https://github.com/microsoft/playwright-cli/blob/main/skills/playwright-cli/references/session-management.md),
|
||||||
and the CLI/core implementation shipped in the locked Nixpkgs Playwright package.
|
and the CLI/core implementation shipped in the locked Nixpkgs Playwright package.
|
||||||
- [Awesome DSH plugins](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
- [dsh-context](https://github.com/bowenliang123/dsh-context) — upstream install,
|
||||||
plus the individual repositories above; no third-party plugin was installed.
|
targeted update and context UI documentation.
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ project and your standard DSH home/shared skills mounted **read-write**. Its
|
|||||||
host Web listener defaults to localhost on the first free port in **3080–3100**;
|
host Web listener defaults to localhost on the first free port in **3080–3100**;
|
||||||
RAM, vCPUs, IPs and optional TAP networking are configured through Nix modules. The VM is headless by default and
|
RAM, vCPUs, IPs and optional TAP networking are configured through Nix modules. The VM is headless by default and
|
||||||
includes **Playwright CLI + matching Firefox**, with a writable, once-seeded
|
includes **Playwright CLI + matching Firefox**, with a writable, once-seeded
|
||||||
`playwright-firefox` skill for isolated named sessions across subagents. No host
|
`playwright-firefox` skill for isolated named sessions across subagents.
|
||||||
|
**`dsh-context` is the only added third-party plugin**: installed into the shared
|
||||||
|
Web profile on first startup, retained on restarts and updated explicitly. No host
|
||||||
service is activated.
|
service is activated.
|
||||||
|
|
||||||
### Local Tor client
|
### Local Tor client
|
||||||
|
|||||||
+11
-1
@@ -47,14 +47,24 @@ fi
|
|||||||
[[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]]
|
[[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]]
|
||||||
[[ $(stat -c %u changed) == "$(id -u)" ]]
|
[[ $(stat -c %u changed) == "$(id -u)" ]]
|
||||||
"$launcher" ssh 'command -v rg python3 git playwright-cli; findmnt /workspace; findmnt /root/.dsh'
|
"$launcher" ssh 'command -v rg python3 git playwright-cli; findmnt /workspace; findmnt /root/.dsh'
|
||||||
|
# pnpm's atomic saves preserve ownership; these chowns must work over 9p.
|
||||||
|
"$launcher" ssh 'chown 0:0 /root/.dsh/config-test; chown --reference=/root/.dsh/config-test /root/.dsh/credentials-test'
|
||||||
"$launcher" ssh 'systemctl start agent.service; test ! -e /tmp/.X11-unix/X0'
|
"$launcher" ssh 'systemctl start agent.service; test ! -e /tmp/.X11-unix/X0'
|
||||||
skill="$DSH_AGENTS_HOME/skills/playwright-firefox/SKILL.md"
|
skill="$DSH_AGENTS_HOME/skills/playwright-firefox/SKILL.md"
|
||||||
grep -q '^name: playwright-firefox$' "$skill"
|
grep -q '^name: playwright-firefox$' "$skill"
|
||||||
[[ ! -L $skill && -w $skill && $(stat -c %a "$skill") == 600 ]]
|
[[ ! -L $skill && -w $skill && $(stat -c %a "$skill") == 600 ]]
|
||||||
[[ $(stat -c %u "$skill") == "$(id -u)" ]]
|
[[ $(stat -c %u "$skill") == "$(id -u)" ]]
|
||||||
printf '\nuser customization\n' >> "$skill"
|
printf '\nuser customization\n' >> "$skill"
|
||||||
|
manifest="$DSH_HOME/profiles/web/package.json"
|
||||||
|
[[ $(< "$DSH_HOME/plugin-calls") == 'plugin --profile web add dsh-context@latest' ]]
|
||||||
|
[[ -f $DSH_HOME/profiles/web/node_modules/dsh-context/package.json ]]
|
||||||
|
[[ ! -L $manifest && -w $manifest && $(stat -c %a "$manifest") == 600 ]]
|
||||||
|
[[ $(stat -c %u "$manifest") == "$(id -u)" ]]
|
||||||
|
cp "$manifest" "$tmp/installed.json"
|
||||||
"$launcher" ssh 'systemctl restart agent.service'
|
"$launcher" ssh 'systemctl restart agent.service'
|
||||||
grep -q '^user customization$' "$skill"
|
grep -q '^user customization$' "$skill"
|
||||||
|
[[ $(wc -l < "$DSH_HOME/plugin-calls") == 1 ]]
|
||||||
|
cmp "$manifest" "$tmp/installed.json"
|
||||||
if [[ -n $browser_test ]]; then
|
if [[ -n $browser_test ]]; then
|
||||||
cp "$browser_test" ./playwright-test.sh
|
cp "$browser_test" ./playwright-test.sh
|
||||||
"$launcher" ssh 'bash ./playwright-test.sh'
|
"$launcher" ssh 'bash ./playwright-test.sh'
|
||||||
@@ -65,4 +75,4 @@ if "$launcher" run >/dev/null 2>&1; then echo 'Duplicate launch succeeded' >&2;
|
|||||||
wait "$pid"
|
wait "$pid"
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, writable non-clobbering skill seed, headless browser CLI, host ownership, symlink isolation, duplicate lock, shutdown'
|
echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, writable non-clobbering skill seed, context-only plugin setup preserved on restart, headless browser CLI, host ownership, symlink isolation, duplicate lock, shutdown'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Exercise the real setup script against an offline, strict DSH CLI fixture.
|
||||||
|
set -euo pipefail
|
||||||
|
setup=$1
|
||||||
|
dsh=$2
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
export HOME="$tmp/home" DSH_HOME="$tmp/dsh home"
|
||||||
|
mkdir -p "$HOME" "$DSH_HOME"
|
||||||
|
profile="$DSH_HOME/profiles/web"
|
||||||
|
manifest="$profile/package.json"
|
||||||
|
run() { bash "$setup" "$dsh"; }
|
||||||
|
calls() { wc -l < "$DSH_HOME/plugin-calls"; }
|
||||||
|
|
||||||
|
# Fresh home: upstream initializes Web, and only the requested plugin is added.
|
||||||
|
run
|
||||||
|
[[ $(< "$DSH_HOME/plugin-calls") == 'plugin --profile web add dsh-context@latest' ]]
|
||||||
|
jq -e '.dependencies | keys == ["dsh-context"]' "$manifest"
|
||||||
|
cp "$manifest" "$tmp/installed.json"
|
||||||
|
run
|
||||||
|
[[ $(calls) == 1 ]]
|
||||||
|
cmp "$manifest" "$tmp/installed.json"
|
||||||
|
|
||||||
|
# Existing home: preserve settings, credentials, patches, other profiles/plugins.
|
||||||
|
printf 'user settings\n' > "$DSH_HOME/settings.yaml"
|
||||||
|
printf 'fixture credentials, not real\n' > "$DSH_HOME/.credentials.yaml"
|
||||||
|
printf 'user patch\n' > "$profile/cordis.patch.yml"
|
||||||
|
mkdir -p "$DSH_HOME/profiles/headless"
|
||||||
|
printf '{"private":true}\n' > "$DSH_HOME/profiles/headless/package.json"
|
||||||
|
jq 'del(.dependencies["dsh-context"]) |
|
||||||
|
.dependencies["user-plugin"] = "1.2.3" |
|
||||||
|
.dsh.profile.bundles = ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "user-plugin"] |
|
||||||
|
.custom = {"keep":true}' "$manifest" > "$tmp/existing.json"
|
||||||
|
cp "$tmp/existing.json" "$manifest"
|
||||||
|
run
|
||||||
|
[[ $(calls) == 2 ]]
|
||||||
|
jq 'del(.dependencies["dsh-context"]) | .dsh.profile.bundles -= ["dsh-context"]' "$manifest" > "$tmp/preserved.json"
|
||||||
|
cmp "$tmp/existing.json" "$tmp/preserved.json"
|
||||||
|
[[ $(< "$DSH_HOME/settings.yaml") == 'user settings' ]]
|
||||||
|
[[ $(< "$DSH_HOME/.credentials.yaml") == 'fixture credentials, not real' ]]
|
||||||
|
[[ $(< "$profile/cordis.patch.yml") == 'user patch' ]]
|
||||||
|
[[ $(< "$DSH_HOME/profiles/headless/package.json") == '{"private":true}' ]]
|
||||||
|
|
||||||
|
# A selected version is not upgraded on restart, even when installation needs repair.
|
||||||
|
jq '.dependencies["dsh-context"] = "0.40.0"' "$manifest" > "$tmp/pinned.json"
|
||||||
|
cp "$tmp/pinned.json" "$manifest"
|
||||||
|
run
|
||||||
|
[[ $(calls) == 2 ]]
|
||||||
|
cmp "$manifest" "$tmp/pinned.json"
|
||||||
|
rm -rf "$profile/node_modules/dsh-context"
|
||||||
|
run
|
||||||
|
[[ $(calls) == 3 && $(tail -1 "$DSH_HOME/plugin-calls") == 'plugin --profile web add dsh-context@0.40.0' ]]
|
||||||
|
cmp "$manifest" "$tmp/pinned.json"
|
||||||
|
jq '.dsh.profile.bundles -= ["dsh-context"]' "$manifest" > "$tmp/unregistered.json"
|
||||||
|
cp "$tmp/unregistered.json" "$manifest"
|
||||||
|
run
|
||||||
|
[[ $(calls) == 4 ]]
|
||||||
|
cmp "$manifest" "$tmp/pinned.json"
|
||||||
|
|
||||||
|
# Partial failure must fail startup and be retried, not hidden behind a stamp.
|
||||||
|
rm -rf "$profile/node_modules/dsh-context"
|
||||||
|
touch "$DSH_HOME/fail-plugin-install"
|
||||||
|
if run; then echo 'Accepted a failed plugin install' >&2; exit 1; fi
|
||||||
|
[[ $(calls) == 5 && ! -e $profile/node_modules/dsh-context/package.json ]]
|
||||||
|
rm "$DSH_HOME/fail-plugin-install"
|
||||||
|
run
|
||||||
|
[[ $(calls) == 6 ]]
|
||||||
|
cmp "$manifest" "$tmp/pinned.json"
|
||||||
|
run
|
||||||
|
[[ $(calls) == 6 ]]
|
||||||
|
|
||||||
|
# Corrupt user data is an error, never permission to reset the profile.
|
||||||
|
printf 'not JSON\n' > "$manifest"
|
||||||
|
if run; then echo 'Accepted a malformed profile' >&2; exit 1; fi
|
||||||
|
[[ $(calls) == 6 && $(< "$manifest") == 'not JSON' ]]
|
||||||
|
echo 'PASS: context-only install, idempotence, profile preservation, selected version, incomplete install repair, failure/retry, malformed profile rejection'
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Required Web plugin, installed only inside the guest's live shared DSH home.
|
||||||
|
set -euo pipefail
|
||||||
|
profile="${DSH_HOME:?DSH_HOME must name the shared guest profile home}/profiles/web"
|
||||||
|
manifest="$profile/package.json"
|
||||||
|
spec=latest
|
||||||
|
if [[ -e $manifest ]]; then
|
||||||
|
# Fail on malformed JSON instead of replacing a user's profile. Keep an
|
||||||
|
# existing version/path spec when repairing an incomplete installation.
|
||||||
|
configured=$(jq -r '.dependencies["dsh-context"] // empty' "$manifest")
|
||||||
|
if [[ -n $configured ]]; then spec=$configured; fi
|
||||||
|
fi
|
||||||
|
installed() {
|
||||||
|
[[ -f $manifest && -f $profile/node_modules/dsh-context/package.json ]] &&
|
||||||
|
jq -e '.dependencies["dsh-context"] != null and
|
||||||
|
((.dsh.profile.bundles // []) | index("dsh-context") != null)' "$manifest" >/dev/null
|
||||||
|
}
|
||||||
|
if installed; then exit 0; fi
|
||||||
|
# Let upstream initialize/reconcile the profile; never generate its manifests,
|
||||||
|
# settings or credentials ourselves. No other plugin is added or updated here.
|
||||||
|
echo 'Installing dsh-context in the DSH web profile.' >&2
|
||||||
|
"$1" plugin --profile web add "dsh-context@$spec"
|
||||||
|
if ! installed; then
|
||||||
|
echo 'dsh-context installation did not produce an installed Web bundle.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Offline CLI fixture only: no npm, network installs, credentials or model calls.
|
||||||
|
set -euo pipefail
|
||||||
|
profile="$DSH_HOME/profiles/web"
|
||||||
|
manifest="$profile/package.json"
|
||||||
|
if [[ ${1:-} == plugin ]]; then
|
||||||
|
[[ $# == 5 && $2 == --profile && $3 == web && $4 == add && $5 == dsh-context@* ]]
|
||||||
|
printf '%s\n' "$*" >> "$DSH_HOME/plugin-calls"
|
||||||
|
mkdir -p "$profile"
|
||||||
|
if [[ ! -e $manifest ]]; then
|
||||||
|
printf '%s\n' '{"dsh":{"profile":{"bundles":["@deepseek-ai/dsh-base","@deepseek-ai/dsh-web-app"]}}}' > "$manifest"
|
||||||
|
fi
|
||||||
|
jq --arg spec "${5#dsh-context@}" '.dependencies["dsh-context"] = $spec' "$manifest" > "$manifest.tmp"
|
||||||
|
mv "$manifest.tmp" "$manifest"
|
||||||
|
# Simulate a failed install after pnpm has already recorded the dependency.
|
||||||
|
[[ ! -e $DSH_HOME/fail-plugin-install ]] || exit 42
|
||||||
|
mkdir -p "$profile/node_modules/dsh-context"
|
||||||
|
printf '%s\n' '{"name":"dsh-context","version":"0.0.0"}' > "$profile/node_modules/dsh-context/package.json"
|
||||||
|
jq '.dsh.profile.bundles |= (. + ["dsh-context"] | unique)' "$manifest" > "$manifest.tmp"
|
||||||
|
mv "$manifest.tmp" "$manifest"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[[ ${1:-} == web ]]
|
||||||
|
# The service must finish plugin setup before starting the Web listener.
|
||||||
|
[[ -f $profile/node_modules/dsh-context/package.json ]]
|
||||||
|
jq -e '.dsh.profile.bundles | index("dsh-context") != null' "$manifest" >/dev/null
|
||||||
|
exec node -e '
|
||||||
|
require("node:http").createServer((req, res) => res.end("agent-vm-test"))
|
||||||
|
.listen(3080, "127.0.0.1", () => console.log("http://127.0.0.1:3080/?token=offline-test"));
|
||||||
|
'
|
||||||
+4
-2
@@ -90,8 +90,10 @@ trap 'exit 143' TERM
|
|||||||
# Network is intentionally inherited for API access (not an egress firewall).
|
# Network is intentionally inherited for API access (not an egress firewall).
|
||||||
devices=()
|
devices=()
|
||||||
[[ $AGENT_NETWORK != tap ]] || devices=(--dev-bind /dev/net/tun /dev/net/tun)
|
[[ $AGENT_NETWORK != tap ]] || devices=(--dev-bind /dev/net/tun /dev/net/tun)
|
||||||
bwrap "${devices[@]}" --die-with-parent --new-session --unshare-user --unshare-pid --unshare-ipc \
|
# Map the caller to namespace uid/gid 0 so 9p ownership matches guest root.
|
||||||
--unshare-uts --unshare-cgroup-try --cap-drop ALL --clearenv \
|
# Host writes still belong to the caller; no host-root identity/capability is gained.
|
||||||
|
bwrap "${devices[@]}" --die-with-parent --new-session --unshare-user --uid 0 --gid 0 \
|
||||||
|
--unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup-try --cap-drop ALL --clearenv \
|
||||||
--setenv HOME /tmp --setenv PATH /no-host-path --setenv LANG C.UTF-8 \
|
--setenv HOME /tmp --setenv PATH /no-host-path --setenv LANG C.UTF-8 \
|
||||||
--ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \
|
--ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \
|
||||||
--tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \
|
--tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ let
|
|||||||
];
|
];
|
||||||
text = ''
|
text = ''
|
||||||
export npm_config_cache=/var/cache/dsh/npm
|
export npm_config_cache=/var/cache/dsh/npm
|
||||||
|
# pnpm's SQLite index needs a local filesystem, not the shared 9p mount.
|
||||||
|
export pnpm_config_store_dir=/var/cache/dsh/pnpm
|
||||||
# Explicitly rolling upstream, not a pretend-reproducible Nix derivation.
|
# Explicitly rolling upstream, not a pretend-reproducible Nix derivation.
|
||||||
exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@"
|
exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@"
|
||||||
'';
|
'';
|
||||||
@@ -356,6 +358,8 @@ in
|
|||||||
cp --update=none --no-preserve=mode \
|
cp --update=none --no-preserve=mode \
|
||||||
${./skills/playwright-firefox/SKILL.md} \
|
${./skills/playwright-firefox/SKILL.md} \
|
||||||
/root/.agents/skills/playwright-firefox/SKILL.md
|
/root/.agents/skills/playwright-firefox/SKILL.md
|
||||||
|
# Required plugin setup uses the mounted profile, never the host or store.
|
||||||
|
${pkgs.bash}/bin/bash ${./context.sh} ${cfg.package}/bin/dsh
|
||||||
'';
|
'';
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
User = "root";
|
User = "root";
|
||||||
@@ -387,6 +391,8 @@ in
|
|||||||
) (map (port: "${net.hostAddress}:${toString port}") (lib.range net.webPort net.webPortEnd))
|
) (map (port: "${net.hostAddress}:${toString port}") (lib.range net.webPort net.webPortEnd))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
# First-time DSH/plugin downloads run in ExecStartPre, not at Nix build time.
|
||||||
|
TimeoutStartSec = "10min";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 3;
|
RestartSec = 3;
|
||||||
UMask = "0077";
|
UMask = "0077";
|
||||||
|
|||||||
+22
-7
@@ -5,6 +5,7 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
c = example.nixos.config;
|
c = example.nixos.config;
|
||||||
|
fakeDsh = pkgs.writeShellScriptBin "dsh" (builtins.readFile ./fake-dsh.sh);
|
||||||
testVM = inputs.self.lib.mkAgentVM {
|
testVM = inputs.self.lib.mkAgentVM {
|
||||||
system = pkgs.stdenv.hostPlatform.system;
|
system = pkgs.stdenv.hostPlatform.system;
|
||||||
project = {
|
project = {
|
||||||
@@ -15,12 +16,7 @@ let
|
|||||||
{
|
{
|
||||||
# Offline infrastructure test. A real DSH startup is tested separately;
|
# Offline infrastructure test. A real DSH startup is tested separately;
|
||||||
# @latest needs the network and is intentionally outside Nix reproducibility.
|
# @latest needs the network and is intentionally outside Nix reproducibility.
|
||||||
agentVM.package = pkgs.writeShellScriptBin "dsh" ''
|
agentVM.package = fakeDsh;
|
||||||
exec ${pkgs.nodejs}/bin/node -e '
|
|
||||||
require("node:http").createServer((req, res) => res.end("agent-vm-test"))
|
|
||||||
.listen(3080, "127.0.0.1", () => console.log("http://127.0.0.1:3080/?token=offline-test"));
|
|
||||||
'
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -74,6 +70,10 @@ in
|
|||||||
assert c.microvm.storeOnDisk;
|
assert c.microvm.storeOnDisk;
|
||||||
assert c.systemd.services.agent.serviceConfig.User == "root";
|
assert c.systemd.services.agent.serviceConfig.User == "root";
|
||||||
assert c.systemd.services.agent.serviceConfig.WorkingDirectory == "/workspace";
|
assert c.systemd.services.agent.serviceConfig.WorkingDirectory == "/workspace";
|
||||||
|
assert pkgs.lib.hasInfix
|
||||||
|
(builtins.unsafeDiscardStringContext "${./context.sh} ${c.agentVM.package}/bin/dsh")
|
||||||
|
c.systemd.services.agent.preStart;
|
||||||
|
assert c.systemd.services.agent.serviceConfig.TimeoutStartSec == "10min";
|
||||||
assert c.services.openssh.settings.PasswordAuthentication == false;
|
assert c.services.openssh.settings.PasswordAuthentication == false;
|
||||||
assert c.services.openssh.settings.AllowAgentForwarding == false;
|
assert c.services.openssh.settings.AllowAgentForwarding == false;
|
||||||
assert !c.services.xserver.enable;
|
assert !c.services.xserver.enable;
|
||||||
@@ -99,11 +99,26 @@ in
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
''
|
''
|
||||||
shellcheck -s bash ${./launch.sh} ${./boot-test.sh} ${./playwright-test.sh} ${./port-test.sh}
|
shellcheck -s bash ${./launch.sh} ${./boot-test.sh} ${./playwright-test.sh} ${./port-test.sh} \
|
||||||
|
${./context.sh} ${./context-test.sh} ${./fake-dsh.sh}
|
||||||
bash -n ${./launch.sh}
|
bash -n ${./launch.sh}
|
||||||
touch "$out"
|
touch "$out"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
context =
|
||||||
|
pkgs.runCommand "agent-dsh-context-check"
|
||||||
|
{
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.bash
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.jq
|
||||||
|
];
|
||||||
|
}
|
||||||
|
''
|
||||||
|
bash ${./context-test.sh} ${./context.sh} ${fakeDsh}/bin/dsh
|
||||||
|
touch "$out"
|
||||||
|
'';
|
||||||
|
|
||||||
playwright =
|
playwright =
|
||||||
pkgs.runCommand "agent-playwright-firefox-check"
|
pkgs.runCommand "agent-playwright-firefox-check"
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ unique `-s=NAME` on every command, its own `.playwright-cli/NAME/` artifacts, an
|
|||||||
close only its own session—never `close-all` or `kill-all`. No host browser
|
close only its own session—never `close-all` or `kill-all`. No host browser
|
||||||
profiles or display sockets are imported.
|
profiles or display sockets are imported.
|
||||||
|
|
||||||
|
The guest installs **only `dsh-context`** into the shared `web` profile before the
|
||||||
|
first Web startup, providing the **Context** tab and **`/context`** command.
|
||||||
|
Existing versions/settings and other user-installed plugins are preserved;
|
||||||
|
restarts do not upgrade it. Installation needs registry access; failures appear
|
||||||
|
in `journalctl -u agent -b` and block Web startup. To update it explicitly, use
|
||||||
|
`dsh plugin --profile web update dsh-context@latest` inside the guest while
|
||||||
|
`agent.service` is stopped, then start the service and request a fresh login URL.
|
||||||
|
|
||||||
DSH resolves `@deepseek-ai/dsh@latest` inside the VM on startup. Nix packages,
|
DSH resolves `@deepseek-ai/dsh@latest` inside the VM on startup. Nix packages,
|
||||||
including the CLI and its matching Firefox, follow the rolling Nixpkgs input:
|
including the CLI and its matching Firefox, follow the rolling Nixpkgs input:
|
||||||
`nix flake update`, then restart the VM. Merge changes to an already-seeded skill
|
`nix flake update`, then restart the VM. Merge changes to an already-seeded skill
|
||||||
|
|||||||
Reference in New Issue
Block a user