Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/linux-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,12 @@ sudo tailscale serve --https=8444 off # stop exposing
WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/$(id -u) wlr-randr # outputs the tools see
```

Restarting the daemon drops every grant (registry is in memory); the runtime notices the socket
close and the agent's `instance_status` says nothing is attached until you `POST /attach` again.
Grants survive a daemon restart (#12): live grants are kept in
`~/.local/state/oab-instance-mcp/grants.json` (mode 600; override with `MCP_GRANTS_FILE`, or
`MCP_GRANTS_FILE=off` to disable), and on start every grant still inside its deadline is re-dialled
under its original id — `journalctl --user -u oab-instance-mcp` shows `grants: resuming <id>`. The
file holds the attach secrets, at the same trust level as the bearer token file. A grant whose
runtime has forgotten it (pod replaced) ends with `handshake_rejected_401`; lend again.

## Headless server notes (Ubuntu 24.04 on `black`)

Expand Down
8 changes: 8 additions & 0 deletions poc/reverse-attach-linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ next call re-initializes and still lists 37. Lent to `kiro-1040` session `mac` (
`browser_snapshot` → `heading "Example Domain"`, `link "Learn more"`. The Chromium window is
on rpi1's desktop, so `screenshot` / Connect's Screens pane show what the agent is doing.

## Grant persistence (#12)

Live grants are written to `MCP_GRANTS_FILE` (default `$XDG_STATE_HOME/oab-instance-mcp/grants.json`,
`off` disables), mode 600 in a 0700 directory, replaced atomically on every create / replace /
`DELETE` / terminal end. On start, grants still inside their deadline are re-dialled under the same
id. Smoke covers `kill -9` → restart → redial, same id, secret absent from `GET /attach`, and a
revoked grant not resumed.

## Not yet (vs the Swift implementation)

- `/mcp` has no real session table (an `Mcp-Session-Id` is issued but not checked) and no SSE stream.
Expand Down
36 changes: 35 additions & 1 deletion poc/reverse-attach-linux/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ check(){ if eval "$2"; then ok "$1"; else bad "$1 :: $2"; fi; }

pkill -x reverse-attach 2>/dev/null; pkill -f mock_runtime.py 2>/dev/null; sleep 0.3
rm -f "$LOG" "$RA_LOG"
# Never touch the real ~/.local/state grants file from a test run.
GRANTS_DIR=$(mktemp -d)
export MCP_GRANTS_FILE="$GRANTS_DIR/grants.json"

# 1000 → redial, then 4010 → stop(revoked). Third attach (if any) would be 4010 again.
PORT=18090 ADMIN=admin-secret CLOSES=1000,4010 SECRETS=pre=preminted-xyz LOG=$LOG \
Expand All @@ -21,7 +24,7 @@ MOCK=$!
BIND=127.0.0.1:8790 MCP_INSECURE_LOCAL=1 MCP_UPSTREAM=browser=http://127.0.0.1:1/mcp $BIN > "$RA_LOG" 2>&1 &
RA=$!
sleep 0.5
trap 'kill $MOCK $RA 2>/dev/null' EXIT
trap 'kill $MOCK $RA 2>/dev/null; rm -rf "$GRANTS_DIR"' EXIT

C="curl -s -m 5"
state_of() { python3 -c 'import sys,json;print([g["state"] for g in json.load(sys.stdin)["grants"] if g["session"]==sys.argv[1]][0])' "$1"; }
Expand Down Expand Up @@ -129,6 +132,37 @@ sleep 5.5
st=$($C 127.0.0.1:8790/attach)
check "unreachable runtime ends at deadline" "[[ \$(echo '$st' | state_of dead) == ended && \$(echo '$st' | ended_of dead) == deadline ]]"


echo "== grants survive a restart (#12) =="
kill $RA $MOCK 2>/dev/null; wait $RA $MOCK 2>/dev/null
rm -f "$MCP_GRANTS_FILE" "$LOG"
# CLOSES=1000: the mock closes after each scripted turn and the node redials, so the
# grant stays live and every (re)attach shows up as a new "attach" event.
PORT=18090 ADMIN=admin-secret CLOSES=1000 SECRETS=keep=k1 LOG=$LOG \
python3 mock_runtime.py > /tmp/mock-runtime.out 2>&1 &
MOCK=$!
start_ra() { BIND=127.0.0.1:8790 MCP_INSECURE_LOCAL=1 $BIN >> "$RA_LOG" 2>&1 & RA=$!; sleep 0.5; }
attaches() { grep -c '"ev": "attach", "session": "keep", "status": 101' $LOG 2>/dev/null || echo 0; }
start_ra
r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"keep","profile":"sandbox","ttl_secs":120,"secret":"k1"}')
GID=$(echo "$r" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
for i in $(seq 1 20); do [[ $(attaches) -ge 1 ]] && break; sleep 0.3; done
check "grants file written with mode 600" "[[ \$(stat -c %a \"$MCP_GRANTS_FILE\" 2>/dev/null || stat -f %Lp \"$MCP_GRANTS_FILE\") == 600 ]]"
check "grants file holds the grant" "grep -q \"$GID\" \"$MCP_GRANTS_FILE\""
check "secret never appears in GET /attach" "! $C 127.0.0.1:8790/attach | grep -q k1"
before=$(attaches)
kill -9 $RA 2>/dev/null; wait $RA 2>/dev/null
start_ra
for i in $(seq 1 30); do [[ $(attaches) -gt $before ]] && break; sleep 0.3; done
check "after kill -9 + restart the node redialled on its own" "[[ \$(attaches) -gt $before ]]"
check "resumed under the same grant id" "$C 127.0.0.1:8790/attach | grep -q \"$GID\""
check "restart logged the resume" "grep -q \"grants: resuming $GID\" $RA_LOG"
$C -o /dev/null -X DELETE 127.0.0.1:8790/attach/$GID
check "DELETE removes it from the file" "! grep -q \"$GID\" \"$MCP_GRANTS_FILE\""
kill $RA 2>/dev/null; wait $RA 2>/dev/null
start_ra
check "a revoked grant is not resumed" "[[ \$($C 127.0.0.1:8790/attach | python3 -c 'import sys,json;print(len(json.load(sys.stdin)[\"grants\"]))') == 0 ]]"

echo
echo "reverse-attach stderr:"; sed 's/^/ /' $RA_LOG
echo "RESULT: $pass passed, $fail failed"
Expand Down
6 changes: 6 additions & 0 deletions poc/reverse-attach-linux/src/attach/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};

pub mod client;
pub mod store;

// Shared grant registry
// ---------------------------------------------------------------------------
Expand All @@ -23,6 +24,9 @@ pub(crate) struct GrantInfo {
pub(crate) ended: Option<String>,
pub(crate) expires_at_epoch_secs: u64,
pub(crate) cancelled: Arc<AtomicBool>,
/// The attach secret this grant dials with. Held so the grant can be
/// persisted and resumed after a restart (#12); never in `grant_json`.
pub(crate) secret: String,
}

pub(crate) type Registry = Arc<Mutex<HashMap<String, GrantInfo>>>;
Expand Down Expand Up @@ -57,6 +61,8 @@ pub(crate) fn set_ended(registry: &Registry, grant_id: &str, reason: &str) {
g.ended = Some(reason.to_string());
}
}
// A terminal grant must not be resumed after a restart.
store::save(registry);
}

/// Exact Swift `MacGrant` shape consumed by OpenAB Connect/Remote. Fields not
Expand Down
Loading
Loading