/* StarForth — Steady-State Virtual Machine Runtime Copyright (c) 2023–2025 Robert A. James All rights reserved. Licensed under the StarForth License, Version 1.0 */ #ifndef __STARKERNEL__ #error "capsule_wirebind.c is kernel-only" #endif #include "starkernel/capsule_wirebind.h" #include "starkernel/capsule_birth.h" #include "starkernel/capsule_runcap.h" #include "starkernel/capsule_console.h" #include "starkernel/vm_identity.h" #include "starkernel/user_identity_seed.h" #include "starkernel/console.h" #include "starkernel/repl.h" /* sk_repl_get/set_active_vm() -- FABRIC-2.md §F.10 EJECT */ #include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 4 -- register/unregister */ #include "blkio.h" #include "block_subsystem.h" /* compute_crc64() */ #include "word_source/include/block_words.h" /* blk_vm_flush_all() -- §F.10 EJECT */ #include "freestanding/stdio.h" #include #include /* FABRIC-2.md §F.10: "the VM currently attached via the home-blocks USB * path" -- WIREBIND's own to track, set at successful birth in * capsule_wirebind_try_attach(), read and cleared by both * capsule_wirebind_eject() and capsule_wirebind_unclean_detach(). Single- * USB-device constraint (§F.8) means there is never more than one * candidate, so one tracked id is enough -- no set/list. */ static VMUuid g_wirebind_attached_vm_id; static int g_wirebind_attached_valid = 0; /* Plain username (no "~user" registry-name suffix), tracked separately * from g_wirebind_attached_vm_id -- the registry entry's own name is * user_vm_name ("~user"), not the bare form the (user) prompt * segment wants (FABRIC-2.md §I.1/4.4s). */ static char g_wirebind_attached_username[USER_IDENTITY_USERNAME_MAX] = {0}; /* FABRIC-2.md §I.2 overflow trigger, 2026-09-05: the attached drive's own * blkio_dev pointer, tracked alongside the two fields above so * capsule_wirebind_overflow_idle_check() can query its free space later * without a new attach-time argument threaded through the idle loop. */ static struct blkio_dev *g_wirebind_attached_dev = (struct blkio_dev *) 0; /* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): the singleton above correctly * models "who is paired to the one physical console" (repl.c's own * console-relay/`(user)` prompt logic genuinely is single-valued -- there * is only one serial/framebuffer console). It does NOT correctly model * "which devices are live," despite g_wirebind_attached_eject()'s own * header doc claiming a "single-USB-device constraint (§F.8)" -- that * claim is stale: §XV/§XVI (2026-09-11/12) proved 9 identities genuinely * simultaneously live via this exact attach path. Every attach after the * first silently overwrote the singleton, so capsule_wirebind_unclean_ * detach()'s `dev != g_wirebind_attached_dev` check only ever matched the * *most recently attached* identity -- any earlier identity's drive * being pulled hit that check, returned early, and orphaned: never torn * down, its VM and native stack leaked forever, no console/log trace at * all. Silent because nothing in this project's verification history * ever detached anything but the single most-recent identity. * * This new table is additive and separate from the console singleton * above (which is untouched, still governs console pairing exactly as * before) -- it tracks every currently-live WIREBIND identity by its own * device pointer, so unclean-detach (and Stage 4's own switch-signal * registration bookkeeping) can resolve *any* attached device to *its * own* identity, not just the last one. Sized off messaging.4th's own * VM-MAX (16) minus the 3 slots Hera/Hermes/Artemis permanently reserve * (capsules/common/messaging.4th: "identities get NEW slots 3-10, never * renumbered") -- a real, already-agreed system-wide ceiling, not a new * guess (see feedback_no_hardcoded_small_scale_bounds in project memory: * Bob has rejected an invented cap here before). */ #define WIREBIND_MAX_LIVE 13u typedef struct { int in_use; struct blkio_dev *dev; VMUuid user_vm_id; char username[USER_IDENTITY_USERNAME_MAX]; int switch_slot; /* Stage 4: index into the switch-signal * table, or -1 if not yet registered * (wired in the next increment). */ } wirebind_live_entry_t; static wirebind_live_entry_t g_wirebind_live[WIREBIND_MAX_LIVE]; static wirebind_live_entry_t *wirebind_live_find_by_dev(struct blkio_dev *dev) { for (uint32_t i = 0; i < WIREBIND_MAX_LIVE; i++) { if (g_wirebind_live[i].in_use && g_wirebind_live[i].dev == dev) { return &g_wirebind_live[i]; } } return (wirebind_live_entry_t *)0; } static wirebind_live_entry_t *wirebind_live_alloc(void) { for (uint32_t i = 0; i < WIREBIND_MAX_LIVE; i++) { if (!g_wirebind_live[i].in_use) return &g_wirebind_live[i]; } return (wirebind_live_entry_t *)0; } /* WIREBIND_CERT_MAX_DEVBLOCKS: a sane upper bound on how much cert * content this reads, independent of whatever sig->cert_devblocks * claims -- MINT (capsule_mint.c) only ever writes 1 devblock's worth * (a real cert is ~150-250 bytes), so 4 devblocks (16KB) is already * generous headroom, not a real constraint. */ #define WIREBIND_CERT_MAX_DEVBLOCKS 4u /* FABRIC-2.md §I.2 overflow trigger, 2026-09-05: fixed constants, "fixed * first" per this project's own standing sequencing (MIGRATION_WEAR_ * THRESHOLD, ZUSE_SESSION_TTL_SECONDS -- adaptive only after a real, * measured baseline exists, not from day one). A single DoE-tunable knob * apiece, not yet wired to Kconfig -- revisit once real usage data exists. * 10% is a conservative first guess (extend well before actually full); * 8 devblocks (24 Forth blocks, ~24 KiB) is a modest first extension, not * a large land-grab. */ #define WIREBIND_OVERFLOW_FREE_THRESHOLD_PCT 10u #define WIREBIND_OVERFLOW_CLAIM_DEVBLOCKS 8u /* Read exactly one devblock (4096 bytes) at devblock offset `devblock` * -- same convention capsule_runcap.c/capsule_mint.c already use. */ static int read_devblock(struct blkio_dev *dev, uint32_t devblock, uint8_t *buf4096) { uint32_t base = devblock * 4u; for (uint32_t i = 0; i < 4u; i++) { if (blkio_read((blkio_dev_t *)dev, base + i, buf4096 + (size_t)i * BLKIO_FORTH_BLOCK_SIZE) != BLKIO_OK) { return -1; } } return 0; } int capsule_wirebind_verify_cert(struct blkio_dev *dev, const homeblocks_sig_t *sig, VM *mama_vm, VMIdentity *out) { if (!dev || !sig || !mama_vm || !out) return -1; /* Genesis-mode Zuse drives have no cert region -- capsule_zuse_boot_ * try_attach() owns that case, not this one. */ if (sig->cert_offset == 0 || sig->cert_devblocks == 0) return -1; /* FABRIC-3.md, 2026-09-09: verifying a WIREBIND cert needs nothing but * Zuse's already-public key -- it does not need her live session. * zuse_root_pubkey_known/zuse_root_pubkey (capsule_zuse_boot_load_ * root_pubkey(), loaded unconditionally from the persistent genesis- * marker fence as soon as Artemis's own storage is up) is what this * checks now, not zuse_cert_installed -- that pair stays reserved for * MINT, which genuinely does need her live private seed. One * identity's attach must not depend on another identity's live * presence. */ if (!mama_vm->zuse_root_pubkey_known) return -1; /* Read the cert region into a local buffer. */ uint32_t n_devblocks = sig->cert_devblocks; if (n_devblocks > WIREBIND_CERT_MAX_DEVBLOCKS) n_devblocks = WIREBIND_CERT_MAX_DEVBLOCKS; uint8_t cert_der[WIREBIND_CERT_MAX_DEVBLOCKS * 4096]; for (uint32_t d = 0; d < n_devblocks; d++) { if (read_devblock(dev, sig->cert_offset + d, cert_der + (size_t)d * 4096) != 0) { return -1; } } size_t cert_len = (size_t)n_devblocks * 4096; memset(out, 0, sizeof(*out)); return vm_identity_from_cert(out, cert_der, cert_len, mama_vm->zuse_root_pubkey, sig->drive_uuid, 0 /* acl_caps: no bits assigned yet, §F.2 */); } void capsule_wirebind_try_attach(struct blkio_dev *dev, const homeblocks_sig_t *sig, VM *mama_vm) { if (!dev || !sig || !mama_vm) return; /* Genesis-mode Zuse drives have no cert region -- capsule_zuse_boot_ * try_attach() already owns that case. Check here too so the right * "not applicable" case stays silent (matches capsule_wirebind_ * verify_cert()'s own -1, but avoids a misleading refusal message * for a drive this function was never going to act on anyway). */ if (sig->cert_offset == 0 || sig->cert_devblocks == 0) return; if (!mama_vm->zuse_root_pubkey_known) return; VMIdentity identity; if (capsule_wirebind_verify_cert(dev, sig, mama_vm, &identity) != 0) { console_println("WIREBIND: cert verification FAILED -- drive refused"); return; } /* Verified. Read the drive's own profile for its username. */ if (sig->identity_src_offset == 0 || sig->identity_src_devblocks < 1) { console_println("WIREBIND: verified cert but no identity_src region -- refusing"); return; } user_identity_seed_t idrec; if (read_devblock(dev, sig->identity_src_offset, (uint8_t *)&idrec) != 0) { console_println("WIREBIND: verified cert but identity record unreadable -- refusing"); return; } /* Same magic -> version -> CRC-64 discipline as capsule_zuse_boot.c: * the identity record is the same on-disk format, so a corrupt or * format-mismatched record must be refused rather than trusted. */ if (idrec.magic != USER_IDENTITY_SEED_MAGIC || idrec.version != USER_IDENTITY_SEED_VERSION) { console_println("WIREBIND: verified cert but identity record unreadable -- refusing"); return; } uint64_t want_crc = compute_crc64((const uint8_t *)&idrec, offsetof(user_identity_seed_t, crc)); if (want_crc != idrec.crc) { console_println("WIREBIND: verified cert but identity record corrupt -- refusing"); return; } char username[USER_IDENTITY_USERNAME_MAX]; memcpy(username, idrec.username, sizeof(username)); username[sizeof(username) - 1] = '\0'; /* defensive; MINT already NUL-terminates */ char user_vm_name[USER_IDENTITY_USERNAME_MAX + 8]; size_t ulen = strlen(username); if (ulen == 0 || ulen + 6 > sizeof(user_vm_name)) { console_println("WIREBIND: empty or oversized username -- refusing"); return; } memcpy(user_vm_name, username, ulen); memcpy(user_vm_name + ulen, "~user", 6); /* Idempotent: already bound this session (console VM already live * under this username) -- nothing to do. Matches BIRTH's own * idempotent convention. */ { VMRegistryEntry existing; if (capsule_vm_find_by_name(username, &existing) == 0 && existing.state == VM_STATE_LIVE) { return; } } VMUuid console_id, user_id; void *console_ctx = (void *)0; void *user_ctx = (void *)0; if (capsule_console_birth(username, mama_vm->stadium_vm_id, &console_id, &console_ctx) != CAPSULE_RUN_OK) { console_println("WIREBIND: console VM birth FAILED"); return; } /* FABRIC-3.6.md Phase 4 (Stage E): console_ctx's only use (the * routing-table VM-NAME-REG call above) is gone -- still populated * because capsule_console_birth()'s own signature requires an * out-param regardless, just no longer read here. */ (void)console_ctx; if (capsule_runcap_birth(dev, sig, user_vm_name, mama_vm->stadium_vm_id, &user_id, &user_ctx) != CAPSULE_RUN_OK) { console_println("WIREBIND: user VM birth FAILED"); /* The console VM above already succeeded and is live/registered * under `username` -- without this, it's an orphan: nothing ever * points a real user at it (WIREBIND only ever hands the caller * the user VM's id), so it just sits there permanently holding * its own kernel-heap arena allocation. Confirmed live 2026-09-07 * (identity-heap-capacity testing): this is exactly what turns a * single failed identity attach into a permanent net loss of * heap, compounding every subsequent attempt's odds of failing * too, rather than a neutral retry. capsule_vm_kill() is the same * teardown capsule_wirebind_eject()/capsule_wirebind_unclean_ * detach() already use elsewhere in this file -- vm_cleanup() + * sf_free(), verified to actually reclaim per-word dictionary * allocations (FABRIC-3.md §IX.2/§IX.3, 2026-09-06). */ capsule_vm_kill(username); return; } /* Install the verified identity onto the user VM -- the one BINDSTEP * (§F.9) will later re-verify against on every USE. */ if (user_ctx) { ((VM *)user_ctx)->identity = identity; } /* FABRIC-2.md §F.10: this is the successful-birth point EJECT/UNCLEAN * need a tracked VMUuid from -- the user VM, not the console VM * (blk_vm_flush_all()/capsule_vm_kill() below both act on the VM that * actually owns block-subsystem state). */ g_wirebind_attached_vm_id = user_id; g_wirebind_attached_valid = 1; g_wirebind_attached_dev = dev; memcpy(g_wirebind_attached_username, username, sizeof(g_wirebind_attached_username)); /* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): additive per-device * tracking, independent of the console-pairing singleton just above * (which stays exactly as it was) -- see g_wirebind_live[]'s own doc * comment. A full table is reported, not silently dropped: running * out of the real, sourced ceiling here means something is actually * wrong (more identities live than this system's own VM-MAX allows * for), worth knowing about rather than quietly leaving a device * untracked for unclean-detach/switch-signal purposes. */ { wirebind_live_entry_t *live = wirebind_live_alloc(); if (live) { live->in_use = 1; live->dev = dev; live->user_vm_id = user_id; /* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): the user VM is * the one that actually runs FORTH work (VM-EXEC/MSG-TICK * dispatch target it directly) -- it participates in * preemptive switching. The console VM does not: it is a * REPL-session proxy whose own control flow * sk_repl_get_active_vm() already owns, never itself the * target of VM-EXEC or MSG-SEND, so it has nothing for a * switch-in to preempt. Registration failure (table full) * degrades to "this identity won't be preempted," not to a * failed attach -- the identity is still fully usable via * ordinary VM-EXEC/console-relay dispatch, same as before * Stage 4 existed. */ live->switch_slot = sk_vm_switch_signal_register(user_id); memcpy(live->username, username, sizeof(live->username)); if (live->switch_slot < 0) { console_println("WIREBIND: switch-signal table full -- this identity won't be preempted"); } } else { console_println("WIREBIND: live-identity table full -- detach/switch tracking degraded for this attach"); } } /* Decided 2026-09-05: no console for the running system unless a * thumbdrive is present. Revised 2026-09-06: this used to call * sk_console_mark_login() here (a one-way sticky flag) -- the gate is * now a live check (sk_console_identity_present(), repl.c) driven * directly by g_wirebind_attached_username being set above, re-checked * continuously by sk_repl_run()'s own main loop rather than a one-shot * signal at login time. Nothing to call here anymore. */ /* FABRIC-3.6.md Phase 4 (Stage E, Category B strip): the messaging.4th * routing-table registration that used to happen here (`S" %s" 3 * VM-NAME-REG`) is gone -- sk_repl_dispatch_line() (repl.c) has not * read that table since task 3.9's own cutover (it resolves the * paired identity directly via capsule_vm_find_by_name(), not an * index), and messaging.4th (which defined VM-NAME-REG) no longer * exists at all as of this strip. This was already dead, just not * yet removed -- confirmed by reading, not assumed. */ console_puts("WIREBIND: "); console_puts(username); console_println(" attached and ready -- USE it to begin"); } /* Shared by capsule_wirebind_eject() and capsule_wirebind_unclean_detach(): * resolve the tracked attached-VM id to a live entry, or clear the tracked * state and report "nothing attached" if it's already gone (idempotent -- * matches capsule_vm_kill()'s own convention). */ static int wirebind_resolve_attached(VMRegistryEntry *out) { if (!g_wirebind_attached_valid) return -1; if (capsule_vm_registry_get(g_wirebind_attached_vm_id, out) != 0 || out->state != VM_STATE_LIVE) { g_wirebind_attached_valid = 0; g_wirebind_attached_username[0] = '\0'; return -1; } return 0; } /* FABRIC-3.md, 2026-09-09: shared by capsule_wirebind_eject() and * capsule_wirebind_unclean_detach() -- tears down the *console* VM * (registered under the bare username, e.g. "00" -- capsule_console_ * birth() in capsule_wirebind_try_attach() above) paired with the user * VM ("~user") those two functions already kill. * * Real bug found live, 2026-09-09: both functions used to compare * sk_repl_get_active_vm() against entry.vm_ptr, where `entry` comes * from wirebind_resolve_attached() -- which resolves g_wirebind_ * attached_vm_id, the *user* VM's id (see that field's own doc comment: * "the user VM, not the console VM"). But USE/g_repl_active_vm always * points at the *console* VM, never the user VM -- so that comparison * was never true, the dangling-pointer guard never fired, and the * console VM was never killed at all here, just silently orphaned * (paired to a now-dead user VM, but still live and still whatever the * active REPL session was). Confirmed live: reusing an xHCI port for a * second identity right after an unclean detach of a first left the * console stuck showing the first identity's own dead-paired prompt. * * Takes the bare username explicitly (FABRIC-3.md §XXVIII Stage 4, * 2026-09-14 -- was g_wirebind_attached_username directly, which only * ever names the single console-paired identity; unclean-detach of any * other simultaneously-live identity needs its own username, not the * console-pairing singleton's, so this can no longer read the global * itself). capsule_wirebind_eject() still passes the singleton's own * username, since EJECT (a typed command) is inherently about "whichever * identity is currently console-paired." */ static void wirebind_teardown_console(const char *username) { if (!username || username[0] == '\0') return; VMRegistryEntry console_entry; if (capsule_vm_find_by_name(username, &console_entry) != 0 || console_entry.state != VM_STATE_LIVE) { return; } if (sk_repl_get_active_vm() == (VM *)console_entry.vm_ptr) { sk_repl_set_active_vm((VM *)0); } capsule_vm_kill(console_entry.name); } static void wirebind_live_remove(wirebind_live_entry_t *e) { if (!e) return; /* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): centralized here rather * than at each of eject()/unclean_detach()'s own call sites -- both * paths that legitimately free this VM (as opposed to the tombstone * path, which does not call this while a VM is still switched-out) * must release its switch-signal slot the same way, or repeated * attach/detach cycles exhaust the bounded slot table. No-op if it * was never registered (table was full at attach time). */ if (e->switch_slot >= 0) { sk_vm_switch_signal_unregister(e->user_vm_id); } e->in_use = 0; e->dev = (struct blkio_dev *) 0; e->username[0] = '\0'; e->switch_slot = -1; } int capsule_wirebind_eject(void) { VMRegistryEntry entry; if (wirebind_resolve_attached(&entry) != 0) { console_println("EJECT: nothing attached via WIREBIND"); return -1; } /* Drive is still physically present -- flush before it's safe to * remove (§F.10 decision 1). */ blk_vm_flush_all((VM *)entry.vm_ptr); wirebind_teardown_console(g_wirebind_attached_username); capsule_vm_kill(entry.name); wirebind_live_remove(wirebind_live_find_by_dev(g_wirebind_attached_dev)); g_wirebind_attached_valid = 0; g_wirebind_attached_dev = (struct blkio_dev *) 0; g_wirebind_attached_username[0] = '\0'; console_puts("EJECT: "); console_puts(entry.name); console_println(" flushed and detached"); return 0; } void capsule_wirebind_unclean_detach(struct blkio_dev *dev) { /* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): was `dev != * g_wirebind_attached_dev`, comparing against the single console- * pairing global -- correct only for the single most-recently- * attached identity (see g_wirebind_live[]'s own doc comment above * for the real bug this caused: every earlier identity's device-pull * was silently ignored, leaking that VM forever). Now resolves via * the per-device live table instead, so any of several simultaneously * live identities' own device-pull is handled correctly. */ wirebind_live_entry_t *live = wirebind_live_find_by_dev(dev); if (!live) return; /* not a tracked WIREBIND device, or already torn down */ VMRegistryEntry entry; if (capsule_vm_registry_get(live->user_vm_id, &entry) != 0 || entry.state == VM_STATE_DEAD || entry.state == VM_STATE_STILLBORN) { wirebind_live_remove(live); return; } /* Device is already gone (§F.10 decision 2) -- no flush attempted; * data since the last flush is lost, matching real-OS unclean-removal * semantics. The console VM has no switch-signal participation (only * user VMs do, once Stage 4's registration lands) so it is always * safely LIVE and killable regardless of the user VM's own state * below. */ wirebind_teardown_console(live->username); int killed = (capsule_vm_kill(entry.name) == 0); /* Only clear the console-pairing singleton if THIS device is the one * it currently tracks -- an unrelated identity's own detach must * never disturb who is paired to the one physical console. */ if (g_wirebind_attached_valid && g_wirebind_attached_dev == dev) { g_wirebind_attached_valid = 0; g_wirebind_attached_dev = (struct blkio_dev *) 0; g_wirebind_attached_username[0] = '\0'; } if (killed) { console_puts("WIREBIND: "); console_puts(entry.name); console_println(" detached without flush (device already gone)"); wirebind_live_remove(live); } else { /* capsule_vm_kill() refused -- VM_STATE_SWITCHED_OUT, a live * register/stack context is parked on this VM's own native * stack. Mark it for the Stage 3 checkpoint (vm_core.c) to reap * on its own next attempt to resume this VM, instead of freeing * memory a parked context still points into -- see * VMRegistryEntry.pending_reap's own doc comment. The live-table * entry stays in place until then: capsule_wirebind_reap_idle_ * check() below removes it once capsule_vm_force_reap() has * actually run and the VM reads back DEAD. */ capsule_vm_set_pending_reap(live->user_vm_id, 1); console_puts("WIREBIND: "); console_puts(entry.name); console_println(" device gone but VM is switched-out -- deferred reap"); } } void capsule_wirebind_reap_idle_check(void) { /* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): the Stage 3 checkpoint * (vm_core.c) is the only thing that actually frees a pending_reap * VM -- see capsule_vm_force_reap()'s own doc comment for why (it * must run at a cooperative point the switcher itself controls, not * from this idle tick). This sweep only cleans up the now-stale * live-table entry once that has already happened; it is not the * reap itself and never calls capsule_vm_kill()/force_reap(). */ for (uint32_t i = 0; i < WIREBIND_MAX_LIVE; i++) { if (!g_wirebind_live[i].in_use) continue; VMRegistryEntry entry; if (capsule_vm_registry_get(g_wirebind_live[i].user_vm_id, &entry) != 0 || entry.state == VM_STATE_DEAD) { wirebind_live_remove(&g_wirebind_live[i]); } } } const char *capsule_wirebind_attached_username(void) { if (!g_wirebind_attached_valid) return (const char *)0; return g_wirebind_attached_username; } void capsule_wirebind_overflow_idle_check(void) { if (!g_wirebind_attached_valid || !g_wirebind_attached_dev) return; VMRegistryEntry entry; if (capsule_vm_registry_get(g_wirebind_attached_vm_id, &entry) != 0 || entry.state != VM_STATE_LIVE || !entry.vm_ptr) { return; } uint64_t free_blocks, total_blocks; if (blk_get_device_free_blocks(g_wirebind_attached_dev, &free_blocks, &total_blocks) != BLK_OK || total_blocks == 0) { return; } if (free_blocks * 100u >= total_blocks * WIREBIND_OVERFLOW_FREE_THRESHOLD_PCT) { return; /* plenty of room left on the drive itself */ } uint8_t owner_fp[8]; memcpy(owner_fp, ((VM *)entry.vm_ptr)->identity.owner_pubkey, sizeof(owner_fp)); if (blk_owner_has_claim(owner_fp)) { return; /* already extended once -- not a growth loop, see header doc */ } uint32_t chain_head; if (blk_firsttouch_claim(owner_fp, WIREBIND_OVERFLOW_CLAIM_DEVBLOCKS, &chain_head) != BLK_OK) { console_println("WIREBIND: overflow trigger fired but Artemis has no room to extend into"); return; } console_puts("WIREBIND: "); console_puts(g_wirebind_attached_username); console_println(" running low on drive space -- extended onto system-resident storage"); }