Skip to content

Make the outbound dir writable by non-root consumers (0.3.0:5) - #11

Merged
MattDHill merged 2 commits into
Start9-Community:masterfrom
lundog:fix-owner
Jul 29, 2026
Merged

Make the outbound dir writable by non-root consumers (0.3.0:5)#11
MattDHill merged 2 commits into
Start9-Community:masterfrom
lundog:fix-owner

Conversation

@lundog

@lundog lundog commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

This container runs as root, so .simplex/outbound was created root-owned with 0755. A consumer stages outgoing files as its own uid, so a non-root consumer can't write there and every send fails with EACCES.

This became live rather than theoretical when openclaw-startos switched OpenClaw to run as node (uid 1000) — necessary on their side, because OpenClaw's plugin loader only accepts plugin files owned by the gateway's own uid or root. On updating to openclaw 2026.7.1:3 the agent could still receive files but could no longer send them.

Receiving was unaffected: inbound is mounted read-only and simplex-chat writes received files world-readable, so a non-root consumer reads them fine under the root-owned dir.

Fix

Create .simplex/outbound with mode 1777 — world-writable plus the sticky bit, as on /tmp — applied from main.ts on every start.

Why mode and not ownership:

  • The bridge can't know a consumer's uid. node happens to be OpenClaw's; chowning to node:node would just relocate the breakage for the next consumer.
  • More than one consumer can mount the same dir. Any single owner is wrong for the others.
  • Sticky preserves isolation. Each consumer can delete only the files it staged — which matters, since the openclaw-simplex plugin runs a TTL reaper over its own staged files.
  • Mode is uid-mapping agnostic, unlike a chown, so it holds however StartOS maps uids into containers.
  • Re-applied every start, so affected installs heal on upgrade with no migration.

Reach is unchanged: only packages that declare this dependency and mount the subpath can see the dir at all, and the contract already limits consumer write access to outbound.

Docs

The file-exchange contract specified paths and read/write access but was silent on uid and ownership — which is why this surfaced at runtime on a consumer rather than during review. Now documented in the README contract, the mainMounts doc comment, and the AI-consumer YAML block.

Version

0.3.0:40.3.0:5 (v0.3.0_4 is tagged), with a release-note bullet in all five locales.

Verification

  • Reproduced on hardware: with outbound root-owned, the agent could receive but not send. Hand-chowning it to node:node restored sending, confirming ownership/mode was the only blocker.
  • Then verified the code path itself: chowned outbound back to root:root, started this build, and StartOS corrected the mode on its own. Sent a file end to end and confirmed the expected owner and mode on the staged file in outbound.
  • tsc --noEmit clean; ncc build startos/index.ts succeeds.

lundog and others added 2 commits July 27, 2026 17:05
This container runs as root, so `.simplex/outbound` was created root-owned with
0755. A consumer stages outgoing files as its own uid, though — the openclaw
package now runs OpenClaw as `node` (uid 1000) — so every send failed with
EACCES. Receiving was unaffected: inbound is mounted read-only and simplex-chat
writes received files world-readable.

The bridge can't know a consumer's uid, and more than one consumer may mount the
same dir, so chowning to a guess (node:node) would just move the breakage.
Widen the mode instead: create `.simplex/outbound` 1777 — world-writable with
the sticky bit, as on /tmp — so any uid can stage files while each consumer can
only remove the files it staged. Mode-only, so it holds regardless of how uids
are mapped into containers, and re-applied on every start so existing installs
heal on upgrade with no migration.

Reach is unchanged: only packages that declare this dependency and mount the
subpath can see the dir at all.

Documents the permission model in the file-exchange contract (README + the
mainMounts contract comment) — it was previously silent on uid/ownership, which
is why the consumer side hit this at runtime rather than at review.
…source

mkdir's mode is umask-masked, and on a fresh install this is now the code that
creates `.simplex` — so it was landing at 0777 & ~umask on the dir holding the
profile database and keys. Pin it to 0700. Only root inside this container ever
traverses it: a dependency mount is resolved by StartOS when it sets the mount
up, and the consumer then reaches the subpath through a path in its own
namespace. An existing `.simplex` is left alone — recursive mkdir doesn't
re-mode a dir that's already there.

The rationale for the mode was written out three times in the source, with
main.ts and the OUTBOUND_MODE doc comment both narrating the openclaw incident
that the commit message and PR body already carry — and that the next consumer
will make wrong. Keep the mechanism in main.ts, the contract on OUTBOUND_MODE,
and the reasoning in the README.

README/AGENTS: fold the reach statement into the Security paragraph, and note
that 1777 is not isolation between consumers — sticky stops them deleting each
other's staged files, not reading them or adding their own; per-consumer
subdirectories are the shape if that's ever needed. Say the mode is re-asserted
on this package's start rather than continuously, since a consumer restart
doesn't re-assert it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@helix-nine

Copy link
Copy Markdown
Contributor

Reviewed this — the diagnosis is right, and it holds up from the consumer side: openclaw-startos mounts .simplex/outbound at /media/simplex/outbound, outside /data, so its own chown -R node:node /data oneshot never reaches it. There's no fix available on the consumer side, which is what makes this the bridge's problem. Mode-over-chown is the right call for the reasons you give.

One follow-up, plus a smoke test. I couldn't push to your branch (see the last section), so it's on a branch here instead:

git remote add upstream https://github.com/Start9-Community/simplex-websocket-bridge-startos.git
git fetch upstream pr-11-review-fixups
git merge --ff-only upstream/pr-11-review-fixups
git push origin fix-owner

It fast-forwards cleanly — it's your f633ae6 plus one commit.

What's in it

.simplex is now created 0700. mkdir's mode is umask-masked, and on a fresh install main.ts is now the code that creates .simplex — so its mode was being left to the ambient umask, on the directory holding the profile database and keys. I checked what that actually does: at umask 022 it lands 755, at umask 0 it lands 777. Pinning it costs one argument. Existing installs are untouched, since recursive mkdir doesn't re-mode a directory that's already there.

This matters more than I first credited it: simplex-chat writes simplex_v1_agent.db and simplex_v1_chat.db as -rw-r--r--, so the parent directory's mode is the only thing standing between those and any non-root uid in the container. Nothing but root runs in there today, so there's no live exposure either way — but it's the difference between defence-in-depth and none. It costs consumers nothing: a dependency mount is resolved by StartOS when it sets the mount up, and the consumer then reaches the subpath through a path in its own namespace that never crosses .simplex.

Rationale moved out of the source into the README. The comment in main.ts and the OUTBOUND_MODE doc comment both narrated the openclaw incident that your commit message and PR description already carry. House convention here is that the reasoning behind a change lives in the commit and the PR rather than the source — and that copy is the one most likely to rot, since openclaw's uid isn't really the bridge's business and the next consumer won't be openclaw. So: main.ts keeps the mechanism, OUTBOUND_MODE keeps the contract, the README keeps the reasoning.

README / AGENTS. Folded the "only packages that declare this dependency" reach statement into the Security paragraph, where the rest of that argument already lives, and noted that 1777 is not isolation between consumers — sticky stops them deleting each other's staged files, not reading them or adding their own. Per-consumer subdirectories of outbound are the shape if that ever matters, and your change is what makes them possible. Also made "re-applied each start" precise: it's re-asserted on this package's start, so an outbound recreated underneath a running bridge keeps its creation mode until the bridge next restarts.

Smoke test

Fresh install on an x86_64 dev box with no prior simplex or openclaw present:

700  root:root  /data/.simplex
1777 root:root  /data/.simplex/outbound
755  root:root  /data/.simplex/files

The service came up clean under the tightened parent — profile created, both DBs written, client settings synced over the WebSocket. tsc --noEmit clean, prettier clean on the touched files.

I did not exercise the non-root send path, since openclaw isn't installed on that box — taking your hardware verification for that.

Why I couldn't push to your branch — worth fixing for next time

"Allow edits by maintainers" is enabled on this PR, but GitHub only provisions the actual push grant when the head repo is a true fork of the base repo. lundog/simplex-websocket-bridge-startos reports fork: false, parent: null — it's a standalone repo that happens to share history with this one. So the API reports maintainer_can_modify: true (your setting) while no grant was ever created, and both of our admin accounts get a 403.

Your lundog/openclaw-startos is a real fork of Start9-Community/openclaw-startos, so maintainer edits already work there — this is just the one repo. To get the same behaviour here:

  1. Rename your existing lundog/simplex-websocket-bridge-startos — GitHub won't create a fork while you own a repo of that name.
  2. Fork this one: gh repo fork Start9-Community/simplex-websocket-bridge-startos --clone=false
  3. Point your local clone's origin at the new fork and push branches there.

After that, "Allow edits by maintainers" actually grants us push, and we can put review fixups straight onto your PRs instead of this branch-and-merge dance. Nothing to change on this PR for it — it only affects future ones.

Thanks for the thorough writeup on the original commit, by the way — the reproduction and the "why mode, not ownership" reasoning made this quick to verify.

@lundog

lundog commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Cool, thanks! After this PR closes, I will delete my original repo, which will make the Start9-Community repo the root and any future PRs from me will be editable.

@MattDHill
MattDHill merged commit de71041 into Start9-Community:master Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants