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
36 changes: 31 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,22 @@ ci-smoke: kernel
@echo ""
@echo "[1/3] Build verified: $(BUILD_DIR)/kernel.elf exists"
@test -f $(BUILD_DIR)/kernel.elf || (echo "ERROR: Kernel not built" && exit 1)
@echo "[2/3] Running QEMU boot test (14 second timeout, shell session piped into the console)..."
@echo "[2/3] Running QEMU boot test (26 second timeout, shell session piped into the console)..."
@# The SECOND `ghost` (after the run legs) gates qsh's first-match send_msg
@# invariant (epic #175): qsh must still hold EXACTLY ONE IPC WRITE cap
@# (->ghostd) after spawning children — a blanket spawn-channel mint (or any
@# future change giving qsh a second IPC cap in a lower first-fit slot) would
@# route this ghost_req to a dead child instead of ghostd and time out. The
@# ghost-before-run leg alone never exercises that window.
@( printf 'help\nps\nfree\nuptime\ndate\nghost\nqrand\nls\ncat /docs/hello.txt\nrun /bin/hello\nrun /bin/args alpha quantumos\nrun /bin/libqtest\nrun /bin/consciousnessd\nrun /bin/qtop\nrun /bin/life\nghost\nimprint the cat sat on the mat\nimprint pure quantum wave dynamics\nimprint hello little world\nrecall the cxt sxt on thx mxt\nfieldtest\nwrite /data/note ramfs-works\nls /data\nrm /data/note\nsync\nexit\n'; sleep 20 ) | \
timeout 14s qemu-system-x86_64 -kernel $(BUILD_DIR)/kernel.elf32 \
@# The THIRD `ghost` is a SEPARATE delayed write (ADR-0023): it must reach
@# the REBORN shell, so it cannot ride the main burst — qsh reads input in
@# 32-byte cons_read chunks and `exit` terminates mid-batch, so a ghost\n in
@# the same chunk dies with the old shell. Delivered after the old shell has
@# processed `exit` (bytes landing in the death->rebirth gap wait in the
@# kernel RX ring for the reborn reader). Gated on the post-reborn log slice
@# below.
@( printf 'help\nps\nfree\nuptime\ndate\nghost\nqrand\nls\ncat /docs/hello.txt\nrun /bin/hello\nrun /bin/args alpha quantumos\nrun /bin/libqtest\nrun /bin/consciousnessd\nrun /bin/qtop\nrun /bin/life\nghost\nimprint the cat sat on the mat\nimprint pure quantum wave dynamics\nimprint hello little world\nrecall the cxt sxt on thx mxt\nfieldtest\nwrite /data/note ramfs-works\nls /data\nrm /data/note\nsync\nexit\n'; sleep 15; printf 'ghost\n'; sleep 10 ) | \
timeout 26s qemu-system-x86_64 -kernel $(BUILD_DIR)/kernel.elf32 \
-append agentdemo \
-serial stdio -m 128M -display none -no-reboot 2>&1 | tee /tmp/qemu-boot.log || true
@echo ""
Expand Down Expand Up @@ -557,8 +564,8 @@ ci-smoke: kernel
@# so hand-wired pairs and colliding-resource_id caps survive (the self-test
@# asserts all three survivors; an over-broad or mis-keyed revoker panics the
@# boot before this line).
@if ! grep -q "CAPUNLINK: spawn-channel caps die with their target" /tmp/qemu-boot.log 2>/dev/null; then \
echo "ERROR: spawn-channel unlink gate missing (CAPUNLINK)"; \
@if ! grep -q "CAPUNLINK: IPC caps die with their target" /tmp/qemu-boot.log 2>/dev/null; then \
echo "ERROR: dead-target IPC unlink gate missing (CAPUNLINK, ADR-0023)"; \
echo "Boot log:"; \
cat /tmp/qemu-boot.log 2>/dev/null || true; \
echo ""; \
Expand Down Expand Up @@ -1227,6 +1234,25 @@ ci-smoke: kernel
echo ""; echo "=== Smoke Test FAILED ==="; exit 1; \
fi
@echo "SUCCESS: watchdog rebirthed the shell after exit (QSH: reborn)"
@# IPC re-wire gate (ADR-0023): the REBORN shell must answer the delayed
@# 'ghost' — its ghostd IPC pair comes back only via the declarative
@# ipc_peers re-mint (the citizens.c hand mints are gone; without the
@# re-mint the reborn shell holds no ghostd cap and the send is
@# EPERM-denied). ANCHORED to the post-'QSH: reborn' slice: the session
@# already prints 'qsh: ghost R=' twice BEFORE the rebirth, so a whole-log
@# grep would pass with the feature entirely broken. Single UART + single
@# CPU means serial output is strictly ordered — the slice cannot capture
@# a pre-rebirth answer. Revert-confirm: dropping qsh's ipc_peers
@# declaration removes ghost wiring on EVERY incarnation (all R= answers
@# vanish, including the two the x2 gate above counts); THIS slice is the
@# rebirth discriminator.
@if ! awk '/QSH: reborn/{f=1} f' /tmp/qemu-boot.log 2>/dev/null | grep -q "qsh: ghost R="; then \
echo "ERROR: reborn shell did not answer 'ghost' — IPC peer re-mint broken (ADR-0023)"; \
echo "Post-reborn slice:"; \
awk '/QSH: reborn/{f=1} f' /tmp/qemu-boot.log 2>/dev/null || true; \
echo ""; echo "=== Smoke Test FAILED ==="; exit 1; \
fi
@echo "SUCCESS: reborn shell re-acquired its ghostd IPC pair (declarative re-mint, ADR-0023)"
@# epic #73: the default boot attaches no rtl8139, so the NIC driver must
@# report its honest absence and MUST NOT claim a NIC came up.
@if ! grep -q "NET: no rtl8139" /tmp/qemu-boot.log 2>/dev/null; then \
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0023-ipc-peer-rewire-on-rebirth.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# 23. IPC Peer Re-Wiring on Watchdog Rebirth (and Dead-Target IPC Unlink)

Date: 2026-07-13
Status: Proposed
Status: Accepted (2026-07-14; Part 1 + Part 2 shipped together — generalized unlink,
declarative ipc_peers re-mint, swarm-svc key re-forward, anchored ci-smoke gate)

## Context

Expand Down
7 changes: 4 additions & 3 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ SMP; defer UEFI, a libagent extraction, and a block filesystem — are recorded
|-----|--------|-------|
| [0018](0018-versioned-releases-and-package.md) | Accepted | Versioned releases and a published host package |
| [0019](0019-authenticated-swarm-plane.md) | Accepted | Authenticate the swarm plane (FSYN + COM2 reply-auth shipped) |
| [0020](0020-v1-contract-freeze.md) | Proposed | Freeze the v1 agent-surface contracts |
| [0021](0021-honest-memory-1gb.md) | Proposed | Honest memory to the 1 GB identity window |
| [0022](0022-com2-latency-scheduler-bound.md) | Proposed | COM2 round-trip latency is scheduler-cadence-bound |
| [0020](0020-v1-contract-freeze.md) | Accepted | Freeze the v1 agent-surface contracts |
| [0021](0021-honest-memory-1gb.md) | Accepted | Honest memory to the 1 GB identity window |
| [0022](0022-com2-latency-scheduler-bound.md) | Accepted | COM2 round-trip latency is scheduler-cadence-bound |
| [0023](0023-ipc-peer-rewire-on-rebirth.md) | Accepted | IPC peer re-wiring on watchdog rebirth + dead-target IPC unlink |

## Sequencing of the next phases

Expand Down
33 changes: 19 additions & 14 deletions kernel/include/kernel/capability.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,12 @@ typedef struct {
uint8_t is_revocable; /* Can this be revoked? */
uint8_t is_inherited; /* Derived from a parent? */
uint8_t is_spawn_channel; /* Origin tag: minted by SYS_SPAWN as one half of a
* parent<->child IPC channel (epic #175). Scopes
* cap_revoke_spawn_channels to spawn wiring ONLY —
* kernel hand-minted pairs are never tagged, so a
* monitored service's restart cannot sever them. */
* parent<->child IPC channel (epic #175). Purely
* informational since ADR-0023: the dead-target
* unlink covers EVERY IPC cap naming the dead pid,
* tagged or not — hand-minted service pairs are
* re-minted declaratively on restart instead of
* surviving by pid-reuse luck. */
uint32_t parent_cap; /* Parent capability ID (0 = root) */
} capability_t;

Expand Down Expand Up @@ -158,16 +160,19 @@ void cap_revoke_all_for_process(uint32_t pid);
* syscall that cap_create'd it, so the tag is never observable half-set. */
cap_result_t cap_mark_spawn_channel(uint32_t cap_id);

/* Free every spawn-channel IPC cap TARGETING dead_pid (epic #175): the
* surviving peer's half of a spawn-minted channel whose other end died.
* Scoped by the is_spawn_channel origin tag + CAP_RESOURCE_IPC + resource_id,
* so hand-minted pairs and non-IPC caps with a colliding resource_id survive.
* MUST be called from process_destroy BEFORE the state=UNUSED store — pids
* are first-fit slot indices, so a later call would let a recycled pid
* inherit a live inbound channel (or worse, kill the recycled pid's fresh
* channel). Ledger: each freed cap records AUDIT_UNLINK under its SURVIVING
* owner (never REAP — the owner did not die). */
void cap_revoke_spawn_channels(uint32_t dead_pid);
/* Free EVERY IPC cap TARGETING dead_pid (epic #175, generalized by ADR-0023):
* the surviving peer's half of any channel whose other end died — spawn-minted
* or hand/declaratively-minted alike. No capability ever outlives the process
* it names: pids are recycled first-fit, so a stale cap would silently
* re-attach to whatever process lands on the recycled pid (an authority leak
* SYS_SEND_TO and the SYS_CAP_DERIVE targeted-peer check would both honor).
* Scoped by CAP_RESOURCE_IPC + resource_id — a FIELD/PROC/DEVICE cap whose
* resource_id collides with a small pid survives, as does an IPC cap to a
* live pid. MUST be called from process_destroy BEFORE the state=UNUSED
* store — every pid-reuse path funnels through there. Ledger: each freed cap
* records AUDIT_UNLINK under its SURVIVING owner (never REAP — the owner did
* not die). */
void cap_revoke_ipc_targets(uint32_t dead_pid);

/* Audit statistics */
void cap_get_stats(cap_stats_t *stats);
Expand Down
29 changes: 29 additions & 0 deletions kernel/include/kernel/service.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
#define MAX_SERVICES 32
#define SERVICE_NAME_MAX 64
#define SERVICE_MAX_DEPS 8
#define SERVICE_MAX_IPC_PEERS 4
#define SERVICE_DEFAULT_MAX_RESTARTS 3

/* Heartbeat freshness threshold (timer ticks; 10 ms each) */
Expand Down Expand Up @@ -82,6 +83,30 @@ typedef struct {
char dependencies[SERVICE_MAX_DEPS][SERVICE_NAME_MAX];
} service_info_t;

/* Declarative IPC peer wiring (ADR-0023). One entry declares one pair with a
* named peer SERVICE; start_slot re-mints the declared caps on EVERY start
* (first boot and every watchdog rebirth), inside the same cli window as the
* grant_* caps — replacing the boot-time hand mints that a rebirth used to
* lose forever.
*
* The two permission words are INDEPENDENT, never a single symmetric field:
* to_perms — mint declarer->peer with these perms (0 = no cap)
* from_perms — mint peer->declarer with these perms (0 = no cap)
* A one-way pair (e.g. the ADR-0019 swarm-svc->fieldsyncd key cap: from-only,
* to_perms 0) MUST stay one-way — a stray reverse cap would give the declarer
* a second outbound IPC cap and break untargeted send_msg's first-match
* routing non-deterministically (the citizens.c fieldsyncd rationale).
*
* Declare each pair on exactly ONE side (the later-starting service):
* start_slot's two passes make single-sided declaration sufficient in both
* rebirth directions, and it structurally prevents a double mint (which
* would break qsh's singleton-IPC-cap first-match invariant, #176). */
typedef struct {
const char *peer; /* peer service name (NULL terminates the table) */
uint32_t to_perms; /* declarer->peer permissions (0 = don't mint) */
uint32_t from_perms; /* peer->declarer permissions (0 = don't mint) */
} service_ipc_peer_t;

/* Static registration record.
*
* A service is either a kernel thread (set `entry`) or an isolated
Expand Down Expand Up @@ -185,6 +210,10 @@ typedef struct {
* unlimited. Only meaningful alongside grant_qpu_submit. qpu_test proves
* enforcement with qsub_max=2. */
uint32_t qsub_max;
/* Declared IPC peer pairs (ADR-0023), re-minted on every start — see
* service_ipc_peer_t. NULL peer terminates the table; a zero-initialized
* def declares nothing (every existing def keeps its exact behavior). */
service_ipc_peer_t ipc_peers[SERVICE_MAX_IPC_PEERS];
} service_definition_t;

/* ============================================================================
Expand Down
66 changes: 38 additions & 28 deletions kernel/src/capability.c
Original file line number Diff line number Diff line change
Expand Up @@ -423,14 +423,18 @@ cap_result_t cap_mark_spawn_channel(uint32_t cap_id) {
return CAP_SUCCESS;
}

void cap_revoke_spawn_channels(uint32_t dead_pid) {
void cap_revoke_ipc_targets(uint32_t dead_pid) {
for (uint32_t i = 0; i < MAX_CAPABILITIES; i++) {
/* Scoped THREE ways: origin tag (hand-minted pairs survive a peer's
* restart), resource type (a FIELD/PROC cap whose resource_id happens
* to equal a small pid survives — pids are slot indices and collide
* with region/device ids), and the dead target itself. */
if (cap_table[i].in_use && cap_table[i].cap.is_spawn_channel &&
cap_table[i].cap.resource_type == CAP_RESOURCE_IPC &&
/* Scoped TWO ways: resource type (a FIELD/PROC cap whose resource_id
* happens to equal a small pid survives — pids are slot indices and
* collide with region/device ids), and the dead target itself. The
* epic #175 origin-tag filter was DROPPED by ADR-0023: hand-minted
* pairs only ever survived a peer's restart usefully when the reborn
* service reclaimed its old pid by first-fit accident — the
* declarative ipc_peers[] re-mint (service.c start_slot) replaces
* that luck, and an un-unlinked stale cap is a recycled-pid
* authority leak. */
if (cap_table[i].in_use && cap_table[i].cap.resource_type == CAP_RESOURCE_IPC &&
cap_table[i].cap.resource_id == dead_pid) {
uint32_t id = cap_table[i].cap.cap_id;
/* Defensive: channel caps carry no CAP_GRANT so nothing derives
Expand Down Expand Up @@ -546,15 +550,19 @@ cap_result_t cap_selftest(void) {
}
cap_revoke(ephemeral, 1);

/* Spawn-channel unlink gate (epic #175). ORDERING CONSTRAINT: this leg
* creates-and-frees caps, so it must stay AFTER the CAPHWM block above —
* that gate requires a packed hole-free table, and a hole punched before
* it would first-fit its probe cap below the mark and panic the boot.
* Anti-vacuous by survivor asserts: the revoker must be scoped by origin
* tag AND resource type AND target — each survivor below defeats one
* over-broad implementation that a lone "target cap died" assert would
* let ship green. Fake pids: 900 = surviving peer, 901 = dead child,
* 902 = unrelated. */
/* Dead-target IPC unlink gate (epic #175, generalized by ADR-0023).
* ORDERING CONSTRAINT: this leg creates-and-frees caps, so it must stay
* AFTER the CAPHWM block above — that gate requires a packed hole-free
* table, and a hole punched before it would first-fit its probe cap below
* the mark and panic the boot.
* Anti-vacuous by survivor asserts: the revoker must be scoped by
* resource type AND target — each survivor below defeats one over-broad
* implementation that a lone "target cap died" assert would let ship
* green. The Part-1 discriminant is the UNTAGGED `hand` assert: under the
* old tag-scoped policy it survived; ADR-0023 requires it to die (the
* AUDIT_UNLINK ring check alone gates nothing — the tagged `ch` records
* UNLINK under either policy). Fake pids: 900 = surviving peer,
* 901 = dead child, 902 = unrelated. */
{
uint32_t ch = 0, other = 0, field = 0, hand = 0;
ST_ASSERT(cap_create(900, CAP_RESOURCE_IPC, 901, CAP_READ | CAP_WRITE, 0, &ch) ==
Expand All @@ -567,34 +575,36 @@ cap_result_t cap_selftest(void) {
ST_ASSERT(cap_mark_spawn_channel(other) == CAP_SUCCESS, "unlink-gate other tag");
ST_ASSERT(cap_create(900, CAP_RESOURCE_FIELD, 901, CAP_READ, 0, &field) == CAP_SUCCESS,
"unlink-gate field create"); /* resource_id COLLIDES with the dead pid */
/* TAG the field cap too: no tagged non-IPC cap exists in practice (only
* sys_spawn tags, and only on IPC caps), so this deliberately constructs
* the adversarial case where ONLY the type filter protects the survivor —
* without it, dropping the filter would pass vacuously (the untagged
* field cap would survive on the origin filter alone). */
/* TAG the field cap too: this deliberately constructs the adversarial
* case where ONLY the type filter protects the survivor — an
* implementation that keyed on the tag (in either direction) instead
* of the type would kill it. */
ST_ASSERT(cap_mark_spawn_channel(field) == CAP_SUCCESS, "unlink-gate field tag");
ST_ASSERT(cap_create(900, CAP_RESOURCE_IPC, 901, CAP_READ | CAP_WRITE, 0, &hand) ==
CAP_SUCCESS,
"unlink-gate hand-minted create"); /* same target, NO tag */

cap_revoke_spawn_channels(901);
cap_revoke_ipc_targets(901);

ST_ASSERT(cap_check(ch, 900, CAP_RESOURCE_IPC, 901, CAP_READ) == CAP_ERROR_INVALID_ID,
"tagged channel to dead pid freed");
ST_ASSERT(cap_check(other, 900, CAP_RESOURCE_IPC, 902, CAP_READ) == CAP_SUCCESS,
"tagged channel to a LIVE pid survives");
"channel to a LIVE pid survives (dead-target filter)");
ST_ASSERT(cap_check(field, 900, CAP_RESOURCE_FIELD, 901, CAP_READ) == CAP_SUCCESS,
"non-IPC cap with colliding resource_id survives (type filter)");
ST_ASSERT(cap_check(hand, 900, CAP_RESOURCE_IPC, 901, CAP_READ) == CAP_SUCCESS,
"untagged hand-minted pair survives (origin filter)");
/* INVERTED by ADR-0023 (this assert is the Part-1 gate): the untagged
* cap to the dead pid must die too — under the old policy it survived
* on the origin filter alone. */
ST_ASSERT(cap_check(hand, 900, CAP_RESOURCE_IPC, 901, CAP_READ) == CAP_ERROR_INVALID_ID,
"untagged IPC cap to dead pid freed (ADR-0023)");
ST_ASSERT(audit_ring_has_kind(AUDIT_UNLINK), "UNLINK recorded in the ledger");

/* Leave the table as found (later self-tests and the packed-boot
* mints must see no unexpected live caps). */
* mints must see no unexpected live caps). `hand` is already freed
* by the unlink — no cleanup revoke for it. */
cap_revoke(other, 900);
cap_revoke(field, 900);
cap_revoke(hand, 900);
boot_log("CAPUNLINK: spawn-channel caps die with their target (scoped by tag+type)");
boot_log("CAPUNLINK: IPC caps die with their target (scoped by type; ADR-0023)");
}

boot_log("capability self-test: PASS");
Expand Down
Loading
Loading