feat: make Astrid local admin standalone - #1473
Conversation
There was a problem hiding this comment.
Pull request overview
Makes Astrid’s local administrative transport runtime-owned, removing the boot dependency on distribution uplink capsules.
Changes:
- Adds an authenticated native uplink server with routing and lifecycle handling.
- Starts it during daemon boot and updates CLI/runtime documentation.
- Adds capsule-free lifecycle smoke coverage.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
scripts/e2e/runtime-harness.sh |
Runs standalone admin smoke coverage. |
scripts/e2e/runtime-cli-smoke.sh |
Tests capsule-free start/status/stop/restart. |
docs/astrid-native-uplink.md |
Documents the native transport boundary. |
crates/astrid-uplink/src/socket_client.rs |
Exposes handshake helper for crate tests. |
crates/astrid-uplink/src/native/routing.rs |
Adds topic policy and response demultiplexing. |
crates/astrid-uplink/src/native/mod.rs |
Implements the native socket server. |
crates/astrid-uplink/src/native/handshake.rs |
Implements server-side authentication. |
crates/astrid-uplink/src/lib.rs |
Exports and documents native uplink support. |
crates/astrid-uplink/Cargo.toml |
Adds native server dependencies. |
crates/astrid-kernel/src/lib.rs |
Assigns canonical listener ownership. |
crates/astrid-integration-tests/tests/gateway_e2e.rs |
Updates gateway test scope documentation. |
crates/astrid-integration-tests/tests/daemon_wedge_e2e.rs |
Removes obsolete capsule dependency note. |
crates/astrid-daemon/src/lib.rs |
Starts and shuts down the native uplink. |
crates/astrid-daemon/README.md |
Documents the revised boot sequence. |
crates/astrid-daemon/Cargo.toml |
Adds the uplink dependency. |
crates/astrid-cli/src/principal.rs |
Documents handshake-bound identity. |
crates/astrid-cli/src/main.rs |
Updates principal-resolution commentary. |
crates/astrid-cli/src/commands/mcp/watch.rs |
Updates watcher identity documentation. |
crates/astrid-cli/src/cli.rs |
Updates principal option documentation. |
crates/astrid-cli/src/admin_client.rs |
Updates admin identity documentation. |
crates/astrid-capsule/src/readiness.rs |
Documents capsule-free boot behavior. |
CHANGELOG.md |
Records standalone administration support. |
Cargo.lock |
Locks new dependency relationships. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
b809097 to
5e71629
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/astrid-uplink/src/native/handshake.rs:71
- The CLI has no
daemonsubcommand; restart is a top-level command. This recovery instruction therefore fails if copied. Useastrid restart, consistent with the CLI definition and the rest of the repository.
"Protocol version mismatch (client={}, server={}). Restart the daemon with `astrid daemon restart`.",
crates/astrid-uplink/src/native/routing.rs:44
- The delta and terminal topics are subscribed to separately, and
spawn_egress_hubdrains each subscription in an independent task. That can reorder a final response ahead of an earlier delta, causing the CLI to stop on the final frame while the late delta remains queued (and also invalidatingreconcile_stream's prefix assumption). Use oneagent.v1.*routed subscription; the existing egress allowlist will still discard unrelated agent topics while preserving per-principal FIFO order between deltas and the terminal response.
CHAT_DELTA_TOPIC,
CHAT_RESPONSE_TOPIC,
5e71629 to
6571c2a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
crates/astrid-uplink/src/native/routing.rs:58
- This treats
astrid.v1.onboarding.requiredas a global broadcast, but its producer runs with a per-principalCapsuleContextand currently publishes without stampingctx.principal(crates/astrid-capsule/src/engine/mod.rs:265-275). During dynamic loads, one principal's capsule ID and onboarding field definitions are therefore delivered to every connected principal. Stamp the producer with its context principal and remove this topic from the principal-less broadcast allowlist.
"astrid.v1.onboarding.required",
6571c2a to
7d22bfe
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 29 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/astrid-uplink/src/native/mod.rs:157
subscribe_topic_routedis lossy before this hub can apply its principal demultiplexing: routed subscriptions share a 1 MiB budget across all principals, evict oldest queued heads under pressure, and reject a single event whenpayload bytes + topic lengthexceeds that budget (astrid-events/src/route/entry.rs:17-21, 212-233). Thus an otherwise valid 1 MiB IPC payload is silently undeliverable, and a burst for one principal can evict another principal's response, leaving that client to time out with no lag signal. Use an egress queue whose bound covers the full allowed frame and whose loss/backpressure accounting is isolated per principal (or propagate route eviction so affected connections are closed/reconciled).
let mut receiver = event_bus.subscribe_topic_routed(
uuid::Uuid::new_v4(),
*pattern,
EVENT_SOURCE,
EVENT_SOURCE,
);
7d22bfe to
012bd4c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 29 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/astrid-uplink/src/native/mod.rs:24
- The hard eight-connection cap can make the canonical admin transport unavailable under normal use. Each
astrid mcp serveholds three long-lived socket connections (the primary client incommands/mcp/mod.rs:132, session guard insession_guard.rs:40, and watcher inwatch.rs:84), so three MCP shims can occupy all permits indefinitely. Subsequentastrid status/stopconnections then remain unaccepted and time out. Reserve capacity for administrative connections or separate the handshake limit from a suitably configurable established-connection limit.
const MAX_CONNECTIONS: usize = 8;
crates/astrid-integration-tests/tests/gateway_e2e.rs:31
- This overstates the new smoke coverage:
run_standalone_admin_smokeonly executes CLI start/status/stop cycles and never starts the HTTP gateway or calls/api/auth/redeem. Please distinguish native-socket lifecycle coverage from the mocked router coverage of the redeem endpoint.
//! * **`/api/auth/redeem` over the real socket.** This in-process fixture does
//! not spawn the daemon-owned accept loop. Covered by the standalone admin
//! lifecycle smoke test and by the
012bd4c to
1ce63d9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 29 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/astrid-uplink/src/native/routing.rs:22
- The ingress allowlist omits
sage.v1.hook.*, but the co-installedastrid-emitconnects throughSocketClientand publishes those topics (crates/astrid-emit/src/lib.rs:168-197). The native server silently drops the frame after the client write succeeds, so hook invocations report success while no event reaches the bus. Preserve this existing ingress family and add a native-server regression.
const ALLOWED_INGRESS_PREFIXES: &[&str] = &[
"astrid.v1.request.",
"astrid.v1.admin.",
"astrid.v1.elicit.response.",
"astrid.v1.approval.response.",
crates/astrid-uplink/src/native/mod.rs:265
- This adds a second trusted
LocalSocketstamping boundary, but the security-critical policy documentation incrates/astrid-capsule/src/engine/wasm/host/consent_egress.rs:311-319still says only the uplink capsule can stamp this origin and that its capability grant is the enforcement boundary. Update that trust assumption to include the native server's signed-handshake verification so future changes audit the actual boundary.
trusted.origin = if identity.is_principal_verified() {
MessageOrigin::LocalSocket
} else {
MessageOrigin::System
};
1ce63d9 to
be01191
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/astrid-uplink/src/native/routing.rs:96
- Egress is filtered only by principal, so every authenticated device for that principal receives every response, regardless of the device scope used to authorize the request. This bypasses the fail-closed device attenuation enforced in
kernel_router/device_scope.rs:8-54; for example, a use-only device can passively receive thePairTokenreturned when a full-scope device callsauth.pair.issue(pair_device_handlers.rs:121-150). Route correlated responses only to the requesting connection, or stamp responses with the authenticatingdevice_key_idand require it to match here.
match message.principal.as_deref() {
Some(target) if target != principal => return false,
None if !SYSTEM_BROADCAST_EXACT.contains(&message.topic.as_str()) => return false,
Some(_) | None => {},
}
match outbound_session(message) {
Some(target) => session == Some(target),
None => true,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/astrid-uplink/src/native/routing.rs:149
RawJsonstream deltas use the established{"session_id": ..., "delta": ...}shape (seecrates/astrid-gateway/src/routes/agent/tests.rs:114-126), but this extractor only looks fortext. Such deltas are forwarded but never added to the accumulator, so a subsequent full terminal response cannot have the streamed prefix removed and the CLI can print the answer twice. Recognizedeltafor raw delta payloads (or make extraction topic-aware).
IpcPayload::RawJson(value) => value.get("text").and_then(|value| value.as_str()),
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 34 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/astrid-uplink/src/native/egress.rs:87
- Completing a turn removes only the admission guard; the owning
ClientQueue.sessionremains set. If another connection starts a later turn with the same principal/session, the old connection still satisfiesshould_deliverand receives that turn's deltas and terminal response as well. Associate the turn with its connection and clear that connection's session after its terminal event has been enqueued.
registry
.active_turns
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&(principal.to_owned(), session.to_owned()));
crates/astrid-uplink/src/native/mod.rs:329
- The TUI's
CancelTurnpath sends a seconduser.v1.promptfor the active session withcontext.action == "cancel_turn"(crates/astrid-cli/src/tui/mod.rs:544-557). This unconditionalbegin_turnrejects that frame because the original turn is still active, so cancellation is silently dropped by the native uplink. Recognize the cancellation control payload before new-turn admission and forward it only for the connection's current active session.
if message.topic.as_str() == routing::CHAT_REQUEST_TOPIC {
let session = routing::payload_session_id(&message.payload)
.ok_or("chat request is missing a session ID")?;
if !receiver.begin_turn(session) {
return Err("a turn is already active for this principal and session");
}
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/astrid-uplink/src/native/egress.rs:278
- When a chat connection disappears before a final
agent.v1.responseis published, dropping its subscription removes only the client queue; the(principal, session)entry remains inactive_turns. Every retry for that session is then rejected until daemon restart, and repeated abandoned sessions grow this set without bound (the session ID itself can be payload-sized). Release or expire the owned turn on disconnect while ensuring a still-running old turn cannot overlap a retry.
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.overflowed {
crates/astrid-uplink/src/native/routing.rs:63
- This admits every
astrid.v1.admin.*operation into the eight reserved slots, not just liveness/shutdown. Eight slow admin operations can therefore occupy the entire reserve for up to a minute, making thestatus/stoprequests this reserve is intended to preserve fail admission. Restrict reserved admission to the validatedGetStatusandShutdownvariants.
pub(super) fn reserved_admin_response_topic(request_topic: &str) -> Option<String> {
request_topic
.strip_prefix("astrid.v1.admin.")
.filter(|suffix| !suffix.is_empty() && !suffix.starts_with("response."))
.map(|suffix| format!("astrid.v1.admin.response.{suffix}"))
crates/astrid-uplink/src/native/mod.rs:313
- For reserved kernel requests,
request_idisNone, so this treats the first same-topic frame as completion. The kernel emitsKernelResponse::Workingon that exact topic after five seconds, whileKernelClientintentionally skips it and waits for a terminal response; a slow status request will therefore receive one keepalive and then see the reserved connection close. ExcludeWorkingframes from completion matching.
return false;
};
message.topic.as_str() == expected.topic
&& expected.request_id.as_deref().is_none_or(|request_id| {
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/astrid-uplink/src/native/mod.rs:281
- This branch admits every
astrid.v1.admin.*operation into the eight reserved lanes based only on arequest_id; it does not deserialize the request or restrict it to liveness operations. Eight arbitrary or unhandled admin requests can therefore occupy the lanes for up to one minute and make the realstatus/shutdownrequests below unavailable—the exact pressure the reserve is intended to survive. Restrict reserved admission to the validated status/shutdown variants (or give those operations a separate reserve).
.topic
.as_str()
.strip_prefix("astrid.v1.request.")?
.split_once('.')?;
if correlation.is_empty() || correlation.contains('.') {
crates/astrid-uplink/src/native/egress.rs:141
- Clearing the subscription's session immediately after enqueueing the final chat event races with delivery.
forward_outboundre-runsshould_deliverusing the now-cleared session (native/mod.rs:509), so the terminal response—and any queued deltas ahead of it—can be discarded when an agent publishes faster than the socket writer. Treat publication-time filtering as authoritative for queued events, or defer clearing the session until the accepted terminal frame has been forwarded.
*client
.session
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/astrid-uplink/src/native/routing.rs:117
- Fail closed when a chat frame has no usable
session_id. As written,outbound_sessionreturnsNonefor a malformed/rawagent.v1.responseoragent.v1.stream.delta, and this fallback delivers it to every connection for the same principal/device, bypassing the advertised chat-session isolation. Require these two chat topics to match an active session; reserve the unconditional path for non-chat events.
match outbound_session(message) {
Some(target) => session == Some(target),
None => true,
}
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
Linked Issue
Closes #1471
Summary
Makes Astrid's baseline local administrative transport part of Astrid itself, so a clean runtime can start and be managed without installing an AOS or other distribution-provided uplink capsule.
The kernel remains tool- and business-logic-blind: the new native uplink is a typed, authenticated transport boundary owned by the Astrid runtime. Distribution frontends remain optional compositions and cannot compete for the canonical listener or become boot dependencies.
Changes
CHANGELOG.md[Unreleased].Verification
cargo test -p astrid-daemon— 8 tests passed.cargo test -p astrid-kernel— 297 tests passed.cargo test -p astrid-uplink --lib— 43 tests passed, including stalled-handshake isolation, established-connection capacity release, full-frame ordered egress, explicit lag propagation, bounded stream reconciliation, principal isolation, and exact wire-payload regressions.cargo test -p astrid-capsule engine::wasm::host::ipc::audit_scope_tests::publish_inherits_device_key_id_from_caller_context -- --exactpassed.cargo test -p astrid-capsule engine::wasm::host::ipc::audit_scope_tests::self_triggered_publish_stamps_no_device_key_id -- --exactpassed.cargo clippy --workspace --all-targets --all-features -- -D warningspassed../scripts/check-wasm-portability.shpassed for all kernel-semantics crates.AI / Tool Assistance
Assisted-by: Codex:GPT-5.6
Codex assisted with repository analysis, implementation across the daemon, uplink, kernel, and CLI boundaries, regression tests, documentation, review remediation, and local validation. Joshua reviewed the complete change, its architecture, risks, and validation before publication.
Checklist
[Unreleased]— or[Unreleased]rolled into a version section for a release PR; not applicable to docs/CI-only changes)Signed-off-bytrailer.