From 364df77368590fe90634fd46b0ab489186e54175 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 18 Jun 2026 20:23:50 +0200 Subject: [PATCH] chore: cleanup plans --- .plans/archived/boot-lifecycle-refactor.md | 161 ------ ...-verify-password-plan-grilled-by-claude.md | 163 ------- ...gin-verify-password-plan-grilled-by-gpt.md | 460 ------------------ ...loud-login-verify-password-plan-initial.md | 124 ----- .../opencode-ai-cli-full-integration-plan.md | 157 ------ .../opencode-fake-provider-direct-plan.md | 121 ----- .plans/archived/remove-mcp-bridge-plan.md | 173 ------- .plans/archived/trapos-create-disk-plan.md | 161 ------ .../trapos-create-installer-disk-plan.md | 152 ------ .plans/archived/tror-helloworld-plan.md | 55 --- ...oud-login-interactive-shell-history-fix.md | 190 -------- .plans/cloud-login-session-crash-fix-plan.md | 129 ----- ...gin-session-crash-merged-fix-plan-final.md | 215 -------- ...oud-login-session-crash-merged-fix-plan.md | 190 -------- 14 files changed, 2451 deletions(-) delete mode 100644 .plans/archived/boot-lifecycle-refactor.md delete mode 100644 .plans/archived/cloud-login-verify-password-plan-grilled-by-claude.md delete mode 100644 .plans/archived/cloud-login-verify-password-plan-grilled-by-gpt.md delete mode 100644 .plans/archived/cloud-login-verify-password-plan-initial.md delete mode 100644 .plans/archived/opencode-ai-cli-full-integration-plan.md delete mode 100644 .plans/archived/opencode-fake-provider-direct-plan.md delete mode 100644 .plans/archived/remove-mcp-bridge-plan.md delete mode 100644 .plans/archived/trapos-create-disk-plan.md delete mode 100644 .plans/archived/trapos-create-installer-disk-plan.md delete mode 100644 .plans/archived/tror-helloworld-plan.md delete mode 100644 .plans/cloud-login-interactive-shell-history-fix.md delete mode 100644 .plans/cloud-login-session-crash-fix-plan.md delete mode 100644 .plans/cloud-login-session-crash-merged-fix-plan-final.md delete mode 100644 .plans/cloud-login-session-crash-merged-fix-plan.md diff --git a/.plans/archived/boot-lifecycle-refactor.md b/.plans/archived/boot-lifecycle-refactor.md deleted file mode 100644 index f63c9bb..0000000 --- a/.plans/archived/boot-lifecycle-refactor.md +++ /dev/null @@ -1,161 +0,0 @@ -# Plan: Fix the TrapOS boot lifecycle - -## Context - -`startup/servers.lua` is filed as a CraftOS **startup hook** but behaves as an **init -process**. CraftOS runs `/startup/*` files *inside* its own shell and expects each to -return; `servers.lua` instead blocks forever (`parallel.waitForAny(shellFn, eventLoopFn)`) -and then calls `os.shutdown()` (`startup/servers.lua:69`). That `os.shutdown()` is not a -feature — it's a guard against falling through to the host CraftOS shell with no eventloop -and no servers running (a degraded bare prompt). - -The installer makes this worse: `install-trapos.lua:229-231` "boots TrapOS in place" via -`shell.execute('/startup/servers.lua')`, three shells deep, where the `os.shutdown()` kills -the machine instead of letting `shell.setDir(previousDir)` (line 233) run. - -Goal: keep the (correct, unavoidable) `parallel(shell, eventLoop)` shape, but stop running -the orchestrator as a startup-hook-pretending-to-be-init. Make the boot path single and -honest, decouple the installer, and turn the implicit shutdown into an explicit policy. - -### Decisions (confirmed with user) - -- **Session-end default = `shutdown`**, with `reboot` and `relaunch` as opt-in policy values. -- **Move `motd` into `programs/`** and invoke it from the orchestrator (single `/startup/` file). -- **Docs: new ADR-0019** for the boot-lifecycle decision; update living docs - (README/AGENTS/periphemu); leave accepted ADRs 0002/0005/0010 as historical record. -- **`trapos.shutdown_on_shell_exit`**: clean break — replaced by `trapos.on_session_end`. - The old key is undocumented and never persisted (only read in the orchestrator + one test), - so no migration shim. (Recorded in ADR-0019.) - -## Implementation - -### 1. Rename orchestrator → `startup/boot.lua` + explicit session-end policy - -Rename `startup/servers.lua` → `startup/boot.lua`. Replace the `SHUTDOWN_ON_SHELL_EXIT_SETTING` -boolean logic with a named policy: - -- New setting `trapos.on_session_end`, values `"shutdown"` (default) | `"reboot"` | `"relaunch"`. - Any unset/invalid value falls back to `"shutdown"`. -- `relaunch`: when the shell exits, re-run it (never drop to bare CraftOS); shutdown/reboot - only via explicit command. Implement as a `repeat shell.run("shell") until action ~= "relaunch"` - loop inside `shellFn`, re-reading the setting each iteration (so `set ... shutdown` then exit - actually powers off). -- After `parallel.waitForAny`: if `shellExited`, dispatch `os.reboot()` for `reboot`, else - `os.shutdown()` (covers `shutdown`; `relaunch` never reaches here). -- Drop the file's self-deprecating TODO comment (`servers.lua:6`). - -### 2. Move `motd` into `programs/` and call it from `boot.lua` - -- Move `startup/motd.lua` → `programs/motd.lua` (drop its TODO comment, lines 3-4). No code - change otherwise — it already reads `/trapos/manifest.json` and returns early if absent. -- In `boot.lua`, after `init()` (which appends `:/programs` to the path), call - `shell.run('/programs/motd.lua')` early — before the "Starting servers..." block — to - preserve the current banner-before-servers order. Use the absolute path so it doesn't depend - on path-setup ordering. - -### 3. Installer: replace "boot in place" with `os.reboot()` - -In `install-trapos.lua`, replace lines 229-231 (the `fs.exists('/startup/servers.lua')` + -`shell.execute(...)` block) with a reboot at the end of the full-install path: - -```lua -shell.execute('trapos-postinstall'); -shell.setDir(previousDir); -print(); -print('=> Rebooting...'); -os.reboot(); -``` - -This gives one boot path (fresh install == normal power-on), drops the deep nesting, and the -installer no longer needs to know the orchestrator's filename. (`--cpm-only` already returns -early at line 220, so it won't reboot.) Mirrors the existing `programs/trapos-upgrade.lua:34-37` -reboot pattern. - -### 4. Upgrade migration: prune stale startup files (self-heal) - -`api.upgrade` → `api.install(force=true)` (`apis/libccpm.lua:412,432,372`) writes the new file -list but **never deletes** files dropped from a package (only `uninstall` deletes, -`libccpm.lua:463-466`). So upgraded machines would keep the old `startup/servers.lua` + -`startup/motd.lua` alongside the new files → two orchestrators in `/startup/`. - -Fix in `boot.lua`: at startup (in `init()`), prune known-stale paths, guarded by `fs.exists`: - -```lua -for _, p in ipairs({ '/startup/servers.lua', '/startup/motd.lua' }) do - if fs.exists(p) then fs.delete(p) end -end -``` - -CraftOS runs `/startup/*` alphabetically; `boot.lua` sorts before `servers.lua`, so it deletes -the stale orchestrator before it would ever run. Idempotent; converges in one boot. -(The general "ccpm should prune removed files on force-install" gap is noted as future work in -ADR-0019, not fixed here to keep scope contained.) - -### 5. Package metadata - -- `packages/trapos-boot/ccpm.json`: `files` → `["programs/motd.lua", "startup/boot.lua"]`; - bump `version` `0.3.3` → `0.4.0` (file set + behavior changed). -- `packages/index.json:5`: bump `trapos-boot` `0.3.3` → `0.4.0` so `ccpm update` advertises it - and `ccpm upgrade` detects the new version on existing machines. - -### 6. Tests - -- **`tests/startup-servers.lua` → rename `tests/boot.lua`** (or keep name; rename preferred for - clarity). Update `loadfile('/startup/servers.lua', ...)` → `/startup/boot.lua`. Keep the two - existing cases retargeted to `trapos.on_session_end` semantics: - - default → `shutdown` (existing "shuts down by default"). - - add `reboot` case → asserts `calls.rebooted == true`, `calls.shutdown == false`. - - add `relaunch` case → make the `settings.get` stub return `"relaunch"` once then `"shutdown"` - so the `repeat...until` loop terminates; assert `shellRuns` ran twice and ended in shutdown. - The harness already stubs `os.reboot`/`os.shutdown`/`parallel.waitForAny` (lines 21-39). -- **`tests/install-trapos.lua`**: the env stub (lines 75-101) does **not** stub `os`, so the new - `os.reboot()` would really reboot during the test. Add an `os` stub that records the reboot - (e.g. push a `{ program = '__reboot__' }` marker into `calls.executes` or set `calls.rebooted`). - Rewrite the "postinstall before startup servers" test (lines 164-174): drop the - `/startup/servers.lua` lookup; assert reboot occurs after `trapos-postinstall`. In the - `--cpm-only` test (176-184), assert reboot was **not** called. -- `tools/trapos-server/test-integration/lua/full-boot.lua:1`: update the comment reference - `startup/servers.lua` → `startup/boot.lua` (no functional change — it doesn't load the file). - -### 7. Docs - -- **New `docs/adrs/adr-0019-boot-lifecycle.md`**: orchestrator renamed `boot.lua` and framed as - init; installer reboots instead of boot-in-place (single boot path); `trapos.on_session_end` - policy (shutdown default / reboot / relaunch) replacing the `shutdown_on_shell_exit` boolean; - motd moved to `programs/`; boot.lua self-heals stale startup files. Note ccpm-prune-on-upgrade - as future work. Add it to the Records list in `docs/adrs/README.md` (markdown link — lychee - validates it). -- Update living-doc prose mentions `startup/servers.lua` → `startup/boot.lua`: - `README.md:73`, `AGENTS.md:27,36,42`, `docs/periphemu.md:9,26`. -- Leave accepted ADRs `0002:36`, `0005:22,41,86`, `0010:57,86` unchanged (historical; the - mentions are backticked prose, not links, so `just check`/lychee is unaffected). - -## Files to modify - -- `startup/servers.lua` → **rename** `startup/boot.lua` (policy + prune + motd call) -- `startup/motd.lua` → **move** `programs/motd.lua` -- `install-trapos.lua` (reboot instead of boot-in-place) -- `packages/trapos-boot/ccpm.json`, `packages/index.json` (file list + version bumps) -- `tests/startup-servers.lua` → **rename** `tests/boot.lua` (policy cases) -- `tests/install-trapos.lua` (os stub + reboot assertions) -- `tools/trapos-server/test-integration/lua/full-boot.lua` (comment) -- `docs/adrs/adr-0019-boot-lifecycle.md` (**new**), `docs/adrs/README.md` -- `README.md`, `AGENTS.md`, `docs/periphemu.md` - -## Verification - -- **Unit tests**: run the suite (`just test` / `programs/runtest.lua`). Confirm the renamed - `tests/boot.lua` passes all three session-end cases and `tests/install-trapos.lua` passes with - the new reboot assertions. Confirm the test runner discovers the renamed file. -- **Markdown lint**: `just check` (lychee `lint-markdown`) — confirms the new ADR-0019 link in - `docs/adrs/README.md` resolves and no doc links broke. -- **Boot path (CraftOS-PC harness, per ADR-0005)**: launch a clean computer, confirm `boot.lua` - prints the motd banner, starts autostart servers, and gives an interactive shell with the - eventloop live (e.g. a `net.call`-based program responds). Type `exit` → machine powers off - (default). `set trapos.on_session_end reboot` → exit re-enters TrapOS. `set ... relaunch` → - exit returns a fresh prompt without powering off. -- **Upgrade migration**: on a computer with the old `startup/servers.lua` + `startup/motd.lua` - present, drop in the new `boot.lua` and reboot; confirm both stale files are deleted and only - `boot.lua` remains in `/startup/`. -- **Installer**: run `install-trapos.lua` end-to-end (or via the test harness) and confirm it - finishes with `=> Rebooting...` and comes back up into a live TrapOS rather than a nested shell. diff --git a/.plans/archived/cloud-login-verify-password-plan-grilled-by-claude.md b/.plans/archived/cloud-login-verify-password-plan-grilled-by-claude.md deleted file mode 100644 index 532bad7..0000000 --- a/.plans/archived/cloud-login-verify-password-plan-grilled-by-claude.md +++ /dev/null @@ -1,163 +0,0 @@ -# Plan: verify the password in `cloud login` - -## Context - -Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89-113`) **never verifies** the -password. It writes `cloud.password` to settings, queues `trapos_cloud_reconnect`, prints -`cloud.password set`, and returns. Verification only happens later and asynchronously inside the -daemon: `startSession` sends the secret in the `hello` handshake and the server -(`tools/trapos-server/src/auth.ts` `resolveAccount`) rejects a wrong secret. On rejection the -daemon's `onHello` (`libcloud.lua:278-297`) silently sets the terminal `unauthorized` state — no -event — so the user types a bad password and still sees "cloud.password set". - -We want `cloud login` to confirm the gateway **accepts** the password before reporting success, and -to never overwrite a known-good saved password with an unverified one: - -- **Interactive** (no password arg → prompted): on a wrong password, re-prompt and retry; cancel - with ctrl+t. -- **Non-interactive** (password passed as an arg): on a wrong password, fail with `error(msg, 0)` - (non-zero exit). - -The daemon owns the only websocket and shares the OS event queue with programs (`libcloud.lua:6-8`). -`cloud login` drives verification by asking the daemon to reconnect with a *candidate* secret and -waiting for the handshake verdict; nothing is persisted until the gateway accepts it. The same -cooperative-coroutine pattern `api.request` (`libcloud.lua:416`) already uses applies here: login -yields in `pullEventRaw`, which lets the daemon coroutine advance. - -## Confirmed design decisions - -1. **Correlation token.** Login generates a token, passes it as a 3rd arg on `trapos_cloud_reconnect`; - the daemon echoes it on the verdict events; login **ignores any verdict whose token doesn't - match**. This prevents a coincidental boot/network-flap `connected` from being mis-read as - acceptance (a false-positive save) or an unrelated `unauthorized` from being mis-attributed. -2. **`secretOverride` cleared on successful hello** (not left sticky), so `getSecret()` stays the - single source of truth after verification; it still survives mid-verify network retries. -3. **Daemon restored on every interactive exit path.** A guard around the whole `login` body queues a - plain `trapos_cloud_reconnect` on any exit (terminate, error, failure), **plus** a restore between - rounds so the link stays up while the user types the next attempt. The token from (1) makes the - restore's own `connected` event safely ignorable. -4. **Add a `cloud.url` guard** as the first thing in `login` (login has none today — only `status` - does, `cloud.lua:67-71`), so an unset URL fails fast with `set cloud.url first` instead of a 12s - timeout. -5. **Success message is `logged in` only** (drop the `cloud.password set` line); the existing test is - updated. -6. **Verify timeout is a snappy single-attempt ~12s**, hardcoded with a comment noting the coupling - to the daemon `helloTimeout` (10s). Slower/retry paths surface as "could not reach gateway" with - nothing saved. -7. **Scope kept tight:** daemon-not-running and `displaced` both surface as the ~12s "could not reach - gateway" timeout (nothing saved). No liveness flag, no pre-verify probe, no displaced-specific - handling — noted as known limitations. - -## Changes - -### 1. `packages/trapos-cloud/apis/libcloud.lua` — observable, candidate- and token-based verification - -- **Candidate secret + login token state.** In `startSession`, add locals `secretOverride = nil` and - `loginToken = nil`. -- **Use the override in `connect()`** (`libcloud.lua:244`): change `secret = getSecret();` to - `secret = secretOverride or getSecret();`. Login rejects empty candidates, so `or` is safe. Do - **not** clear the override here — it must survive `websocket_failure → scheduleReconnect → connect` - retries during the verify window. -- **Emit the verdict events with the token.** In `onHello`: - - success branch (after `state = 'ready'`, around `libcloud.lua:290`): - `queueEvent('trapos_cloud_connected', traposId, loginToken);` then **clear both** - `secretOverride = nil; loginToken = nil;`. - - rejection branch (around `libcloud.lua:291-296`, after setting `state = 'unauthorized'`): - `queueEvent('trapos_cloud_unauthorized', traposId, lastError, loginToken);`. -- **Carry candidate + token on reconnect** (`libcloud.lua:402`): - `el.register('trapos_cloud_reconnect', function(reason, candidateSecret, token) - secretOverride = candidateSecret; loginToken = token; reconnectNow(reason); end)`. The handler - **always assigns both from its args**, so a plain reconnect (no args) clears both to `nil` — - exactly how login restores the daemon to the saved password. - -Note the verdict-event shapes (login matches the token per-branch): `trapos_cloud_connected` = -`(name, traposId, token)`; `trapos_cloud_unauthorized` = `(name, traposId, lastError, token)`. - -### 2. `packages/trapos-cloud/programs/cloud.lua` — verify in the `login` command - -Replace the body of `if command == 'login'` (`cloud.lua:89-113`). Keep `parseLoginArgs`, -`readPassword`, and the `--force`/already-set guard. Additions: - -- **`cloud.url` guard first** (mirror `status`): `if isBlank(settings.get(URL_SETTING)) then - print('set cloud.url first'); return; end` — before the already-set guard and any prompt. -- **Constant:** `local VERIFY_TIMEOUT = 12; -- one hello attempt; daemon helloTimeout is 10s`. -- **`verifyPassword(candidate)` helper** — drives one verification round using `os.pullEventRaw` so - ctrl+t is caught, generates a fresh token, and **only acts on a verdict whose token matches**: - -```lua -local function verifyPassword(candidate) - local token = createCloud().uuid(); -- correlation token for this round - os.queueEvent(RECONNECT_EVENT, 'login', candidate, token); - local timer = os.startTimer(VERIFY_TIMEOUT); - while true do - local e = table.pack(os.pullEventRaw()); - local name = e[1]; - if name == 'terminate' then - error('Terminated', 0); -- guard (below) restores the daemon - elseif name == 'trapos_cloud_connected' and e[3] == token then - os.cancelTimer(timer); return 'ok'; - elseif name == 'trapos_cloud_unauthorized' and e[4] == token then - os.cancelTimer(timer); return 'unauthorized'; - elseif name == 'timer' and e[2] == timer then - return 'timeout'; - end - -- any other event (incl. mismatched-token verdicts from a restore) is ignored - end -end -``` - -- **Restore guard.** Wrap the verification/command flow so that **any** exit queues a plain - `os.queueEvent(RECONNECT_EVENT)` to restore the daemon to its saved secret before propagating — - e.g. run the flow under `pcall`; on failure, queue the plain reconnect, then re-`error(err, 0)`. - This covers ctrl+t at the re-prompt (outside `verifyPassword`) and any unexpected error. - -- **Interactive flow** (`opts.password == nil`): loop — `readPassword()`; blank ⇒ print - `password cannot be empty` and re-prompt; else `verifyPassword(candidate)`: - - `ok` ⇒ `settings.set/save`, print `logged in`, return. - - `unauthorized` ⇒ queue a plain reconnect (restore the link while the user types), print - `invalid password, try again (ctrl+t to cancel)`, loop. - - `timeout` ⇒ queue a plain reconnect, print `cloud login failed: could not reach gateway`, return. - -- **Non-interactive flow** (`opts.password` set): blank ⇒ - `error('cloud login failed: password cannot be empty', 0)`. Else `verifyPassword`: - - `ok` ⇒ save + print `logged in`, return. - - else queue a plain reconnect, then `error('cloud login failed: invalid password', 0)` - (unauthorized) or `error('cloud login failed: could not reach gateway', 0)` (timeout). - -Password is saved **only** on `ok`, so a wrong/aborted attempt never clobbers an existing valid -`cloud.password`. - -### 3. Tests - -- **`packages/trapos-cloud/tests/cloud.lua`** (daemon/api): assert `onHello(false, …)` queues - `trapos_cloud_unauthorized` carrying `(traposId, lastError, token)`; that a - `trapos_cloud_reconnect` carrying a candidate makes the next `connect()` send that secret in the - hello (vs. `getSecret()` when absent); that the success `trapos_cloud_connected` carries the token - and that a successful hello clears the override (a subsequent network reconnect falls back to - `getSecret()`). Existing assertions (`connected[2] == '7:base'`, etc.) are unaffected by the added - token arg. -- **`packages/trapos-cloud/tests/cloud-program.lua`**: the fake `os` (lines ~64-68) stubs only - `queueEvent`; extend it to drive events — stub `os.pullEventRaw`/`startTimer`/`cancelTimer`. The - fake `queueEvent` already records queued events, so it **captures the token** from the - `trapos_cloud_reconnect` event; the scripted `pullEventRaw` **echoes that captured token** into the - fed `connected`/`unauthorized` event (no test-only seam in `login`). Update existing login tests - (they assert the immediate `cloud.password set` + reconnect, and the `cloud.password set` line) and - add: interactive wrong-then-right retry (saved only after the accepted attempt; plain reconnect - queued between rounds), interactive timeout (not saved, plain reconnect queued), non-interactive - wrong password (raises, not saved), non-interactive accepted (saved, prints `logged in`), - `cloud.url` unset (prints `set cloud.url first`, no prompt/save). - -## Verification - -- Lua suite: `just test` (or `just _craftos-test --pretty`) — exercises `tests/cloud.lua` and - `tests/cloud-program.lua` under headless CraftOS-PC. -- End-to-end: `just e2e` (`tools/trapos-server` gateway + headless CraftOS), then with the daemon - running and a server password configured: - - `cloud login --force` with a wrong password should re-prompt (interactive), and ctrl+t at the - prompt should leave the daemon reconnected to the saved secret (check `/logs/cloud.log` shows a - reconnect, not a lingering `unauthorized`). - - `cloud login ` should `error` with a non-zero exit and not change `cloud.password`. - - A correct password should print `logged in` and the daemon log should show `connected`. -- **Known limitations** (by design): with the daemon not running, or when the computer is - `displaced`, `cloud login` reports `could not reach gateway` after ~12s (nothing saved) rather than - a more specific message. diff --git a/.plans/archived/cloud-login-verify-password-plan-grilled-by-gpt.md b/.plans/archived/cloud-login-verify-password-plan-grilled-by-gpt.md deleted file mode 100644 index 7d2620b..0000000 --- a/.plans/archived/cloud-login-verify-password-plan-grilled-by-gpt.md +++ /dev/null @@ -1,460 +0,0 @@ -# Plan: verify the password in `cloud login` (grilled by GPT) - -## Context - -Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89-113`) does not verify the password. -It writes `cloud.password` to settings, queues `trapos_cloud_reconnect`, prints `cloud.password set`, -and returns. Verification only happens later and asynchronously inside the daemon: `startSession` sends -the secret in the `hello` handshake, and the gateway rejects a wrong secret. On rejection, the daemon's -`onHello` sets terminal `unauthorized` state without a user-visible event, so the user can type a bad -password and still see success-looking output. - -The desired behavior is: - -- Interactive login (`cloud login`, no password arg) verifies the candidate password before saving it. -- Interactive wrong passwords re-prompt; ctrl+t cancels and restores the daemon to persisted settings. -- Non-interactive login (`cloud login `) verifies before saving, and wrong passwords fail with - `error(msg, 0)` for a non-zero exit. -- A wrong, aborted, timed-out, or unsaved candidate never overwrites a previously valid saved password. - -The daemon owns the only websocket and shares the OS event queue with programs. `cloud login` should not -open its own websocket. Instead, it asks the daemon to reconnect with a candidate secret and waits for the -candidate hello verdict. - -## Confirmed Decisions - -- Verify before commit: save `cloud.password` only after the gateway accepts the candidate. -- Use `error(msg, 0)` for non-interactive failures and storage failures. -- Use a per-attempt correlation token so login ignores unrelated `trapos_cloud_connected` and - `trapos_cloud_unauthorized` events. -- Use a local `/apis/eventloop` instance inside `cloud login` for the wait, not a hand-written - `os.pullEventRaw()` loop. -- Keep `trapos_cloud_connected` as the success event, with token as an optional trailing argument. -- Emit `trapos_cloud_unauthorized` for every rejected hello, not only login verification attempts. -- Keep candidate override active until an explicit plain reconnect clears it, so transient websocket - retries during the verify window still use the candidate. -- After successful verification and save, queue a plain reconnect with reason `login-saved` so the daemon - clears candidate state and resumes reading persisted settings. -- After failed, aborted, timed-out, unauthorized, or save-failed verification, queue a plain reconnect - with reason `login-restore`. -- Use reconnect reason `login-verify` for candidate verification. -- Do not log verification tokens; log only the reconnect reason. -- Do not trim, lowercase, normalize, or otherwise transform passwords. Reject only the exact empty - string `''`. -- Add a `cloud.url` guard to `cloud login` and fail fast with `set cloud.url first` when verification - cannot be started. -- Keep the existing already-set guard unless `--force` is passed. -- Keep `lastError()` returning a string code; structured unauthorized details are only for the event. -- Do not add a concurrent-login lock. -- Treat daemon absence and unresolved displaced state as timeout/`could not reach gateway`. -- Bump `trapos-cloud` from `0.4.2` to `0.4.3` in both package metadata locations. - -## Non-Goals - -- Do not add a standalone Lua test harness. -- Do not add daemon liveness/status APIs for login. -- Do not open a program-owned websocket for verification. -- Do not trim or normalize passwords. -- Do not add a concurrent-login lock. -- Do not change `lastError()` to return structured errors. -- Do not broaden this into general cloud daemon state-management UX. - -## Event Contract - -Verification reconnect: - -```lua -os.queueEvent('trapos_cloud_reconnect', 'login-verify', candidateSecret, token); -``` - -Successful verified hello: - -```lua -queueEvent('trapos_cloud_connected', traposId, token); -``` - -Unauthorized hello: - -```lua -queueEvent('trapos_cloud_unauthorized', traposId, err, token); -``` - -Restore after failed/aborted verification: - -```lua -os.queueEvent('trapos_cloud_reconnect', 'login-restore'); -``` - -Cleanup after successful save: - -```lua -os.queueEvent('trapos_cloud_reconnect', 'login-saved'); -``` - -Rules: - -- `candidateSecret == nil` or `candidateSecret == ''` means plain reconnect: clear candidate and token. -- Candidate absence wins; a token without a candidate is ignored by clearing both candidate and token. -- `cloud login` rejects empty candidates before queueing verification. -- Ordinary daemon reconnects may emit tokenless `trapos_cloud_connected` or `trapos_cloud_unauthorized`. -- Login only treats events with the exact current token as authoritative. -- Login drops unrelated or tokenless verdict events it consumes; it does not re-queue them. - -## Daemon Changes - -File: `packages/trapos-cloud/apis/libcloud.lua` - -### Candidate And Token State - -Inside `api.startSession(opts)`, add candidate state: - -```lua -local secretOverride = nil; -local verifyToken = nil; -local activeSecret = nil; -local activeVerifyToken = nil; -``` - -In `connect()`, capture the candidate state for that physical connection attempt: - -```lua -activeSecret = secretOverride or getSecret(); -activeVerifyToken = verifyToken; -``` - -Then send hello with `activeSecret`, not the mutable outer `secret` value. - -Reason: reconnect events can supersede earlier attempts. The secret/token used for a hello result must be -the secret/token captured for the active attempt, not whatever mutable candidate state happens to contain -when `onHello` runs. - -### Reconnect Handler - -Change the `trapos_cloud_reconnect` handler to accept candidate and token: - -```lua -el.register('trapos_cloud_reconnect', function(reason, candidateSecret, token) - if type(candidateSecret) == 'string' and candidateSecret ~= '' then - secretOverride = candidateSecret; - verifyToken = token; - else - secretOverride = nil; - verifyToken = nil; - end - reconnectNow(reason); -end); -``` - -This makes `login-restore`, `login-saved`, and any manual/plain reconnect clear login verification state. - -### Hello Verdict Events - -In `onHello`, keep `lastError()` as a string code, but emit observable events. - -On success: - -```lua -queueEvent('trapos_cloud_connected', traposId, activeVerifyToken); -``` - -On rejection, normalize the gateway error table for the event: - -```lua -local errBody = err; -if type(errBody) ~= 'table' then - errBody = { code = 'unauthorized', message = 'unauthorized' }; -end -lastError = errBody.code or 'unauthorized'; -queueEvent('trapos_cloud_unauthorized', traposId, errBody, activeVerifyToken); -``` - -Do not log the token. Existing reconnect logs should become more useful through reasons such as -`login-verify`, `login-restore`, and `login-saved`. - -### Stale Socket Investigation - -Before finalizing stale-message behavior, probe or test whether the runtime's `websocket_message` event -includes a websocket handle in addition to `(url, content)`. - -If a handle is available, only accept messages for the current `activeWs` attempt. If the runtime only -provides `(url, content)`, document the limitation and rely on the existing URL/state guards plus -per-attempt token capture. - -## Program Changes - -File: `packages/trapos-cloud/programs/cloud.lua` - -### Setup - -Add: - -```lua -local createEventLoop = require('/apis/eventloop'); -local VERIFY_TIMEOUT = 12; -- daemon helloTimeout is 10s -``` - -Instantiate the cloud API once: - -```lua -local cloudApi = createCloud(); -``` - -Use `cloudApi.request(...)` for `status` and `cloudApi.uuid()` for login verification tokens. - -### Guard Order - -In `login`, keep parse behavior and the existing force semantics. Use this order: - -```lua -local opts, parseError = parseLoginArgs(rawArgs); -if parseError then - print(parseError); - printUsage(); - return; -end - -if not opts.force and not isBlank(settings.get(PASSWORD_SETTING)) then - print('cloud.password is already set; use cloud login --force to replace it'); - return; -end - -if isBlank(settings.get(URL_SETTING)) then - print('set cloud.url first'); - return; -end -``` - -Reason: replacement intent is validated before requiring gateway configuration. If replacement is allowed -or no password exists, verification requires `cloud.url` and fails fast when absent. - -### Verification Helper - -Use a local eventloop per candidate attempt: - -```lua -local function verifyPassword(candidate) - local token = cloudApi.uuid(); - local result = nil; - local el = createEventLoop(); - - local function finish(value) - if result ~= nil then return; end - result = value; - el.stopLoop(); - end - - el.register('trapos_cloud_connected', function(_traposId, eventToken) - if eventToken == token then finish('ok'); end - end); - - el.register('trapos_cloud_unauthorized', function(_traposId, _err, eventToken) - if eventToken == token then finish('unauthorized'); end - end); - - el.register('terminate', function() - result = 'terminate'; - end); - - el.setTimeout(function() - finish('timeout'); - end, VERIFY_TIMEOUT); - - os.queueEvent(RECONNECT_EVENT, 'login-verify', candidate, token); - el.runLoop(); - - return result; -end -``` - -Important details: - -- Create a new eventloop for each retry attempt. -- Use default `el.runLoop()`, not `runLoop(true)`, so `terminate` stops the loop. -- The terminate handler only records `result = 'terminate'`; command-level flow restores and throws. -- `finish()` is idempotent because `stopLoop()` queues an internal stop event rather than interrupting - the current dispatch immediately. -- Rely on `eventloop.runLoop()` cleanup for local timers and handlers. - -### Save Helper - -Add a helper or inline flow that saves only after verified `ok`, and rolls back live settings if saving -fails: - -```lua -local oldPassword = settings.get(PASSWORD_SETTING); -local hadPassword = oldPassword ~= nil; - -settings.set(PASSWORD_SETTING, password); -if not settings.save() then - if hadPassword then - settings.set(PASSWORD_SETTING, oldPassword); - else - settings.unset(PASSWORD_SETTING); - end - os.queueEvent(RECONNECT_EVENT, 'login-restore'); - error('cloud login failed: could not save settings', 0); -end - -os.queueEvent(RECONNECT_EVENT, 'login-saved'); -print('logged in; cloud.password set'); -``` - -Reason: the daemon reads live `settings.get(PASSWORD_SETTING)`, not just persisted files. If save fails, -the in-memory setting must be restored before `login-restore` reconnects. - -### Interactive Flow - -Interactive is exactly `opts.password == nil`; do not add TTY probing. - -Loop behavior: - -- Prompt with existing masked `readPassword()`. -- If input is exactly `''`, print a password-empty message and re-prompt. -- Do not trim or normalize the input. -- Run `verifyPassword(candidate)`. -- On `ok`, save, queue `login-saved`, print `logged in; cloud.password set`, and return. -- On `unauthorized`, queue `login-restore`, print `invalid password, try again (ctrl+t to cancel)`, - and immediately re-prompt without waiting for restore completion. -- On `timeout`, queue `login-restore`, print `cloud login failed: could not reach gateway`, and return. -- On `terminate`, queue `login-restore`, then `error('Terminated', 0)`. - -### Non-Interactive Flow - -Non-interactive is exactly `opts.password ~= nil`. - -Behavior: - -- If password is exactly `''`, `error('cloud login failed: password cannot be empty', 0)`. -- Do not trim or normalize the argument. -- Run `verifyPassword(password)`. -- On `ok`, save, queue `login-saved`, print `logged in; cloud.password set`, and return. -- On `unauthorized`, queue `login-restore`, then `error('cloud login failed: invalid password', 0)`. -- On `timeout`, queue `login-restore`, then `error('cloud login failed: could not reach gateway', 0)`. -- On `terminate`, queue `login-restore`, then `error('Terminated', 0)`. - -Do not print before non-interactive `error(...)`; the error text is the output. - -## Tests - -### Daemon/API Tests - -File: `packages/trapos-cloud/tests/cloud.lua` - -Add or update tests to cover: - -- Unauthorized hello queues `trapos_cloud_unauthorized` with `(traposId, normalizedErrTable, token)`. -- `lastError()` remains the string code, for example `'unauthorized'`. -- Candidate reconnect sends the candidate secret in the next hello. -- Plain reconnect clears candidate and token and falls back to `getSecret()`. -- Successful hello queues `trapos_cloud_connected` with `(traposId, token)`. -- Token without candidate is ignored/cleared by reconnect handler. -- Candidate override survives websocket failure/retry until a plain reconnect clears it. -- Existing network drop, displacement, send, request, serve, and respond tests remain valid. - -If websocket-message identity is available in the runtime, add a stale-socket test proving old socket -messages cannot drive the current attempt. If identity is not available, document the limitation in test -comments or plan follow-up notes. - -### Program Tests - -File: `packages/trapos-cloud/tests/cloud-program.lua` - -Update the harness: - -- Intercept `require('/apis/eventloop')` and provide a fake eventloop with `register`, `setTimeout`, - `runLoop`, and `stopLoop`. -- Script verification events through `opts.events` or equivalent. -- Let fake `runLoop()` consume scripted events and dispatch registered handlers until stopped. -- Capture the token from queued `trapos_cloud_reconnect` verification events and use it in scripted - matching `trapos_cloud_connected` / `trapos_cloud_unauthorized` events. -- Support non-matching/tokenless verdict events so tests can prove login ignores them. -- Extend fake settings with `unset()`. -- Add save-failure injection, for example `saveOk = false`. -- Change `runCloud()` to return `ok` and `err` instead of rethrowing expected program errors. -- Update successful tests to assert `ctx.ok == true`. - -Update existing login tests because old behavior saved immediately and printed `cloud.password set`. - -Add/cover these scenarios: - -- `cloud login` with existing password and no `--force` refuses replacement before checking `cloud.url`. -- `cloud login` with missing `cloud.url` prints `set cloud.url first`, does not prompt, does not save, - and does not reconnect. -- Interactive accepted password saves only after matching `trapos_cloud_connected` token, queues - `login-verify` then `login-saved`, and prints `logged in; cloud.password set`. -- Non-interactive accepted password does not prompt, saves after matching connected token, queues - `login-verify` then `login-saved`, and succeeds. -- Interactive wrong-then-right flow queues `login-restore` after unauthorized, re-prompts immediately, - saves only the accepted second password, and never saves the rejected one. -- Interactive blank password re-prompts and does not queue verification for the blank value. -- Non-interactive blank password errors with `cloud login failed: password cannot be empty`, does not - queue restore, and does not save. -- Interactive timeout queues `login-restore`, prints `cloud login failed: could not reach gateway`, and - does not save. -- Non-interactive unauthorized queues `login-restore`, errors with `cloud login failed: invalid password`, - and does not save. -- Non-interactive timeout queues `login-restore`, errors with `cloud login failed: could not reach gateway`, - and does not save. -- Interactive terminate during verification queues `login-restore`, errors with `Terminated`, and leaves - settings unchanged. -- Matching-token events complete verification; non-matching and tokenless verdict events are ignored until - matching result or timeout. -- Save failure after verified success with an old password restores the old in-memory password, queues - `login-restore`, errors with `cloud login failed: could not save settings`, and does not queue - `login-saved`. -- Save failure after verified success with no old password calls `settings.unset(PASSWORD_SETTING)`, queues - `login-restore`, errors with `cloud login failed: could not save settings`, and does not queue - `login-saved`. - -## Package Version - -Update: - -- `packages/trapos-cloud/ccpm.json`: `0.4.2` -> `0.4.3` -- `packages/index.json`: `trapos-cloud` `0.4.2` -> `0.4.3` - -Reason: this changes runtime behavior of `trapos-cloud` APIs and programs, and `cloud --version` reports -the package version through `libversion`. - -## Implementation Order - -1. Update `packages/trapos-cloud/apis/libcloud.lua` with candidate/token reconnect contract and events. -2. Update `packages/trapos-cloud/tests/cloud.lua` for daemon/API behavior. -3. Update `packages/trapos-cloud/programs/cloud.lua` with local eventloop verification and save rollback. -4. Update `packages/trapos-cloud/tests/cloud-program.lua` harness and CLI tests. -5. Update package versions in `packages/trapos-cloud/ccpm.json` and `packages/index.json`. -6. Run verification and fix issues. - -## Verification - -Because this change edits Lua and this Markdown plan, run: - -```sh -just check -just test -``` - -`just check` covers Lua lint and offline Markdown link validation. `just test` runs the CraftOS-PC test -suite, including `tests/cloud.lua` and `tests/cloud-program.lua`. - -Optional end-to-end verification against the real gateway: - -```sh -just e2e -``` - -Manual behavior to confirm with the daemon running and server password configured: - -- `cloud login --force` with a wrong password re-prompts and does not save. -- Ctrl+t during interactive verification restores daemon state and exits with `Terminated`. -- `cloud login ` errors non-zero with `cloud login failed: invalid password` and does not save. -- A correct password prints `logged in; cloud.password set` and `/logs/cloud.log` shows reconnect reasons - around `login-verify` and `login-saved`, without logging verification tokens. - -## Known Limitations - -- If the daemon is not running, login times out with `cloud login failed: could not reach gateway`. -- If the computer is displaced by another registration and cannot recover during verification, login times - out with `cloud login failed: could not reach gateway`. -- Unless the runtime exposes websocket identity in message events, stale websocket messages can only be - mitigated by URL/state guards and per-attempt token capture, not by direct socket-handle comparison. diff --git a/.plans/archived/cloud-login-verify-password-plan-initial.md b/.plans/archived/cloud-login-verify-password-plan-initial.md deleted file mode 100644 index 5b673bc..0000000 --- a/.plans/archived/cloud-login-verify-password-plan-initial.md +++ /dev/null @@ -1,124 +0,0 @@ -# Plan: verify the password in `cloud login` - -## Context - -Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89`) **never verifies** the -password. It writes `cloud.password` to settings, queues `trapos_cloud_reconnect`, prints -`cloud.password set`, and returns. Verification only happens later and asynchronously inside the -daemon: `startSession` sends the secret in the `hello` handshake, and the server -(`tools/trapos-server/src/auth.ts` `resolveAccount`) rejects a wrong secret. On rejection the -daemon's `onHello` (`apis/libcloud.lua:278`) sets the terminal `unauthorized` state **silently** — -no event, so nothing tells the user their password was wrong. The user types a bad password and -still sees "cloud.password set". - -We want `cloud login` to confirm the password is accepted by the gateway before reporting success: -- **Interactive** (no password arg → prompted): on a wrong password, re-prompt and let the user - retry; cancel with ctrl+t. -- **Non-interactive** (password passed as an arg): on a wrong password, fail with an error and a - non-zero exit (`error(msg, 0)`). - -Confirmed decisions: **verify before commit** (never overwrite a known-good saved password with an -unverified one) and **`error()` non-zero exit** for non-interactive failure. - -## Design - -The daemon owns the only websocket and shares the os event queue with programs. `cloud login` drives -verification by asking the daemon to reconnect with a *candidate* secret and waiting for the -handshake result. Nothing is persisted until the gateway accepts it. - -``` -login: candidate = arg or prompt - -> os.queueEvent('trapos_cloud_reconnect', 'login', candidate) # candidate, NOT saved - -> wait: trapos_cloud_connected | trapos_cloud_unauthorized | timeout - connected -> settings.set/save(candidate); print "logged in"; done - unauthorized -> interactive: re-prompt and loop - non-interactive: queue plain reconnect (restore daemon); error() - timeout -> queue plain reconnect (restore daemon); report "could not reach gateway" -``` - -Interactive/non-interactive is decided exactly as today: `opts.password == nil` ⇒ interactive -(prompted), else non-interactive. No TTY probing. - -## Changes - -### 1. `packages/trapos-cloud/apis/libcloud.lua` — make verification observable + candidate-based - -- **Emit a hello-failure event.** In `onHello`'s rejection branch (around line 291-296), after - setting `state = 'unauthorized'`, add - `queueEvent('trapos_cloud_unauthorized', traposId, lastError);`. Success already emits - `trapos_cloud_connected` (line 290) — leave it. -- **One-shot candidate secret.** Add a local `secretOverride = nil` in `startSession`. In `connect()` - (line 239) change `secret = getSecret();` to `secret = secretOverride or getSecret();`. The - override is non-empty when present (login rejects empty), so `or` is safe. -- **Carry the candidate on reconnect.** Change the `trapos_cloud_reconnect` handler (line 402) to - accept a second arg: `function(reason, candidateSecret) secretOverride = candidateSecret; reconnectNow(reason); end`. - A reconnect with no candidate clears the override (back to `getSecret()`), which is exactly how - login restores the daemon to the previously-saved password after a failed/aborted attempt. - -### 2. `packages/trapos-cloud/programs/cloud.lua` — verify in the `login` command - -Replace the body of `if command == 'login'` (lines 89-113). Keep the existing `parseLoginArgs`, -`readPassword`, the `cloud.url` guard, and the `--force`/already-set guard. Add a helper that drives -one verification round, using **`os.pullEventRaw`** so ctrl+t is caught and the daemon is restored -before exiting: - -```lua -local VERIFY_TIMEOUT = 12; -- comfortably over the daemon helloTimeout (10s) - -local function verifyPassword(candidate) - os.queueEvent(RECONNECT_EVENT, 'login', candidate); - local timer = os.startTimer(VERIFY_TIMEOUT); - while true do - local e = table.pack(os.pullEventRaw()); - local name = e[1]; - if name == 'terminate' then - os.queueEvent(RECONNECT_EVENT); -- restore daemon to saved secret - error('Terminated', 0); - elseif name == 'trapos_cloud_connected' then - os.cancelTimer(timer); return 'ok'; - elseif name == 'trapos_cloud_unauthorized' then - os.cancelTimer(timer); return 'unauthorized'; - elseif name == 'timer' and e[2] == timer then - return 'timeout'; - end - end -end -``` - -Command flow: -- Interactive (`opts.password == nil`): loop — `readPassword()`; reject blank and re-prompt; else - `verifyPassword`. `ok` ⇒ `settings.set/save`, print `logged in; cloud.password set`, return. - `unauthorized` ⇒ print `invalid password, try again (ctrl+t to cancel)` and loop. `timeout` ⇒ queue - plain `trapos_cloud_reconnect`, print `cloud login failed: could not reach gateway`, return. -- Non-interactive (`opts.password` set): blank ⇒ `error('cloud login failed: password cannot be empty', 0)`. - Else `verifyPassword`. `ok` ⇒ save + print, return. Otherwise queue plain reconnect to restore the - daemon, then `error('cloud login failed: invalid password', 0)` (unauthorized) or - `error('cloud login failed: could not reach gateway', 0)` (timeout). - -Note: password is saved **only** on `ok`, so a wrong attempt never clobbers an existing valid -`cloud.password`. If `cloud.url` is unset the existing `set cloud.url first` guard applies; if the -daemon isn't running, verification simply times out into the "could not reach gateway" path (no -new global coupling needed). - -### 3. Tests - -- `packages/trapos-cloud/tests/cloud.lua` (daemon/api): assert `onHello(false, ...)` queues - `trapos_cloud_unauthorized`, and that a `trapos_cloud_reconnect` carrying a candidate makes the - next `connect()` send that secret in the hello (vs. falling back to `getSecret()` when the - candidate is absent). -- `packages/trapos-cloud/tests/cloud-program.lua`: the harness's fake `os` (lines ~64) stubs only - `queueEvent`; extend it to feed events — add `opts.events` consumed by stubbed - `os.pullEventRaw`/`startTimer`/`cancelTimer`. Update existing login tests (they currently assert - the immediate `cloud.password set` + reconnect) and add: interactive wrong-then-right retry - (password saved only after the accepted attempt), interactive timeout (not saved, plain reconnect - queued), non-interactive wrong password (raises, not saved), non-interactive accepted (saved). - -## Verification - -- Run the lua suite: `just test` (or `just _craftos-test --pretty` for grouped output) — exercises - `tests/cloud.lua` and `tests/cloud-program.lua` under headless CraftOS-PC. -- End-to-end against the real gateway: `just e2e` (`tools/trapos-server` gateway + headless CraftOS), - then manually: with the daemon running and a server password configured, `cloud login --force` - with a wrong password should re-prompt (interactive) and `cloud login ` should error with a - non-zero exit; a correct password should print `logged in; cloud.password set` and the daemon log - (`/logs/cloud.log`) should show `connected`. diff --git a/.plans/archived/opencode-ai-cli-full-integration-plan.md b/.plans/archived/opencode-ai-cli-full-integration-plan.md deleted file mode 100644 index 91ab467..0000000 --- a/.plans/archived/opencode-ai-cli-full-integration-plan.md +++ /dev/null @@ -1,157 +0,0 @@ -# Plan: Full AI CLI Integration Through Real Opencode - -## Goal - -Run the real ComputerCraft `ai` CLI against a real `opencode serve` process through the WebSocket bridge proxy, while using the fake provider/model fixture proven by `opencode-fake-provider-direct-plan.md`. - -This test should cover the actual runtime chain used in-game. - -## Dependency - -Do not implement this plan until `.plans/opencode-fake-provider-direct-plan.md` has produced a working fake provider fixture. - -Update this plan first with the concrete results from plan 1: - -- Working fake provider plugin code shape. -- Working opencode startup command/env. -- Working readiness endpoint. -- Any internal prompt behavior discovered. - -## Desired Boundary - -Real: - -- CraftOS-PC harness -- `/programs/ai.lua` -- `/apis/libai.lua` -- `/apis/libhttpws.lua` -- `tools/mcp-bridge` opencode proxy -- `opencode serve` -- opencode sessions/messages/agents/model selection - -Fake: - -- The provider/model response behavior only, through the reusable fake provider fixture from plan 1 - -## Runtime Chain - -```text -CraftOS /programs/ai.lua - -> libai.lua - -> libhttpws.lua - -> mcp-bridge opencode proxy - -> real opencode serve - -> fake provider/model -``` - -## Test Fixture - -Reuse the fake provider workspace generator from plan 1. - -Response mappings needed for the CLI cases: - -```json -[ - { "match": "reply with exactly: pong", "reply": "pong" }, - { "match": "fresh start", "reply": "new reply" }, - { "match": "continue please", "reply": "plain reply" } -] -``` - -Keep the mapping fixture easy to extend so future CLI cases can add entries without changing provider code. - -## CraftOS Wrapper - -Create or update a Lua wrapper under: - -- `tools/mcp-bridge/test-integration/lua/ai-cli-check.lua` - -The wrapper should: - -1. Accept the WebSocket proxy URL as its first argument. -2. Clear stale settings: - - `opencc.server_url` - - `opencc.session_id` -3. Set: - - `opencc.bridge_url` - - `opencc.request_timeout_seconds` -4. Run: - - `ai sessions` - - `ai ping` - - `ai new fresh start` - - `ai continue please` -5. Print markers around each command. -6. Print persisted session markers after commands. -7. Call `os.shutdown()`. - -Expected marker examples: - -```lua -print('--- sessions ---'); -shell.run('/programs/ai.lua', 'sessions'); - -print('--- ping ---'); -shell.run('/programs/ai.lua', 'ping'); -print('SESSION_AFTER_PING=' .. tostring(settings.get('opencc.session_id'))); - -print('--- new ---'); -shell.run('/programs/ai.lua', 'new', 'fresh', 'start'); -print('SESSION_AFTER_NEW=' .. tostring(settings.get('opencc.session_id'))); - -print('--- ask ---'); -shell.run('/programs/ai.lua', 'continue', 'please'); -print('SESSION_AFTER_ASK=' .. tostring(settings.get('opencc.session_id'))); -``` - -## Node Test Implementation - -Add or replace the current CLI integration test under: - -- `tools/mcp-bridge/test-integration/ai-cli.test.ts` - -Test steps: - -1. Create temp fake-provider opencode workspace using the plan 1 fixture. -2. Start `opencode serve` on a random local port. -3. Poll until opencode is ready. -4. Start `startOpencodeProxy({ opencodeUrl })`. -5. Start CraftOS with: - - `mountRepo: true` - - `shellArgs: [proxyUrl]` - - a generous timeout, likely `30_000` or higher depending on measured opencode startup time -6. Assert CLI output includes: - - `pong` - - `new reply` - - `plain reply` - - session markers proving `ai new` replaces the session and plain `ai ...` reuses it -7. Stop CraftOS, proxy, and opencode in `finally`. - -## Useful Assertions - -- `ai sessions` exits without an opencode transport/config error. -- `ai ping` prints `pong`. -- `ai new fresh start` prints `new reply`. -- Plain `ai continue please` prints `plain reply`. -- `SESSION_AFTER_NEW` is non-empty. -- `SESSION_AFTER_ASK` equals `SESSION_AFTER_NEW`. -- If `SESSION_AFTER_PING` is printed, decide whether ping should persist a session or whether `ai ping` should become non-persistent in a separate behavior change. - -## Current Open Questions - -- Should `ai ping` persist `opencc.session_id`? Current `programs/ai.lua` calls `ai.ping(askOptions())`, and `libai.ping` behavior must be checked before asserting this too tightly. -- Should `ai sessions` be expected to show no sessions, one session, or just avoid failing before messages are created? Real opencode behavior may differ from the old fake HTTP server. -- Does opencode generate a title/summary for each message during the synchronous `/message` call? If yes, the fake provider fallback must make that harmless. -- What is the most stable way to choose a free opencode port in CI? - -## Verification - -After implementation, run: - -```sh -npx tsx --test test-integration/opencode-fake-provider.test.ts -npx tsx --test test-integration/ai-cli.test.ts -npm run check -just check -``` - -If the full test is too slow for the default integration suite, keep it as a separately named test command or document why it is excluded from `npm run test:integration`. diff --git a/.plans/archived/opencode-fake-provider-direct-plan.md b/.plans/archived/opencode-fake-provider-direct-plan.md deleted file mode 100644 index ba4fb23..0000000 --- a/.plans/archived/opencode-fake-provider-direct-plan.md +++ /dev/null @@ -1,121 +0,0 @@ -# Plan: Direct Fake Provider Integration - -## Goal - -Prove that a real `opencode serve` process can run with a deterministic fake provider/model and answer HTTP API requests without calling an external LLM. - -This plan deliberately stops before CraftOS, the WebSocket bridge, or `/programs/ai.lua`. It validates only the opencode-side fixture that the full integration test will reuse. - -## Desired Boundary - -Real: - -- `opencode serve` -- opencode config validation and loading -- opencode session/message HTTP endpoints -- opencode model/provider selection -- opencode agent/title/summary plumbing as far as it is triggered by simple messages - -Fake: - -- The provider/model response behavior only - -## Proposed Test Fixture - -Create a test-only temporary opencode workspace during the test. Do not modify the project `.opencode/opencode.json` for this. - -Files generated under a temp directory: - -- `opencode.json` -- `fake-provider.ts` or `fake-provider.js` -- `fake-responses.json` - -Example response mapping: - -```json -[ - { "match": "reply with exactly: pong", "reply": "pong" }, - { "match": "fresh start", "reply": "new reply" }, - { "match": "continue please", "reply": "plain reply" } -] -``` - -The fake provider should return the first response whose `match` appears in the final model prompt. Unknown prompts should return a deterministic fallback such as `ok` or `unhandled fake prompt`, not fail immediately, because opencode may issue title/summary/internal prompts. - -## Config Shape To Validate - -Use the published schema as the source of truth before finalizing fields. - -Candidate config: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "model": "traptest/fake", - "small_model": "traptest/fake", - "enabled_providers": ["traptest"], - "plugin": ["./fake-provider.ts"], - "provider": { - "traptest": { - "name": "Trap Test", - "models": { - "fake": { - "id": "fake", - "name": "Trap Test Fake Model", - "limit": { "context": 100000, "output": 10000 }, - "cost": { "input": 0, "output": 0 }, - "status": "active" - } - } - } - }, - "agent": { - "build": { "model": "traptest/fake" }, - "title": { "model": "traptest/fake" }, - "summary": { "model": "traptest/fake" } - } -} -``` - -Open question: the exact plugin provider hook shape must be verified against opencode's runtime/plugin API. Do not guess this implementation from the config schema alone. - -## Test Implementation - -Add a Node integration test, likely under: - -- `tools/mcp-bridge/test-integration/opencode-fake-provider.test.ts` - -Test steps: - -1. Create a temp directory. -2. Write the test `opencode.json`. -3. Write `fake-responses.json`. -4. Write the fake provider plugin. -5. Start `opencode serve` on `127.0.0.1` with a random free port. -6. Wait for readiness by polling an HTTP endpoint such as `GET /session`. -7. Call opencode HTTP directly: - - `POST /session` - - `POST /session/:id/message` with `reply with exactly: pong` - - `POST /session/:id/message` with `fresh start` -8. Assert the responses contain `pong` and `new reply`. -9. Stop the opencode process and clean up the temp directory. - -## Useful Assertions - -- `opencode serve` starts successfully with the generated config. -- `POST /session` returns a usable session ID. -- `POST /session/:id/message` returns text from the fake mapping. -- Unknown/internal prompts do not break the test fixture. -- No external provider credentials are required. - -## Result To Capture For Plan 2 - -After this plan is run, record: - -- Exact working fake provider plugin API shape. -- Exact command/env used to start `opencode serve` reliably. -- Confirmed readiness endpoint and polling logic. -- Whether title/summary/internal model calls happen during simple message requests. -- Any required config fields not listed above. - -Plan 2 should be updated with these facts before implementation. diff --git a/.plans/archived/remove-mcp-bridge-plan.md b/.plans/archived/remove-mcp-bridge-plan.md deleted file mode 100644 index f509664..0000000 --- a/.plans/archived/remove-mcp-bridge-plan.md +++ /dev/null @@ -1,173 +0,0 @@ -# Plan: Remove `tools/mcp-bridge` - -## Context - -`tools/mcp-bridge` used to provide two separate host-side surfaces: - -1. MCP tools and a dedicated ComputerCraft link transport. -2. An opencode HTTP-over-WebSocket proxy used by the in-game `ai` bridge mode. - -The MCP tools have already moved to `tools/trapos-server` and `.mcp.json` already points at -the cloud MCP endpoint (`http://127.0.0.1:4445`). The remaining live dependency is the old -opencode proxy path (`opencc.bridge_url` / `packages/trapos-ai/apis/libhttpws.lua`). - -Post-pull path note: Lua source files now live under `packages//...`. The CraftOS-PC -test harness stages those files into flat runtime roots via `just/stage.just`, and package -descriptors still list installed flat paths such as `apis/libai.lua`. - -Decision: remove `tools/mcp-bridge` completely and intentionally drop the old opencode proxy -implementation for now. If bridge/proxy behavior is needed again, re-implement it from -scratch later; the old implementation remains available in git history. - -## Scope - -- Delete `tools/mcp-bridge/` entirely. -- Remove `ai` WebSocket bridge-mode support. -- Keep direct HTTP/HTTPS opencode support through `packages/trapos-ai/apis/libhttp.lua`. -- Keep current MCP tooling in `tools/trapos-server` unchanged, except for docs/tooling cleanup. - -## Runtime Changes - -### Drop `libhttpws` - -- Delete `packages/trapos-ai/apis/libhttpws.lua`. -- Remove `require('/apis/libhttpws')` from `packages/trapos-ai/apis/libai.lua`. -- Remove `opencc.bridge_url` handling from `packages/trapos-ai/apis/libai.lua`. -- Remove the fallback that treats `ws://` / `wss:// opencc.server_url` as bridge mode. -- Remove `opencc.request_timeout_seconds` usage if it only exists for the WebSocket transport. - -### Update `ai` CLI Help - -Update `packages/trapos-ai/programs/ai.lua` help text: - -- Remove `opencc.bridge_url` from required settings. -- Remove `opencc.request_timeout_seconds` from optional settings. -- Make `opencc.server_url` clearly HTTP/HTTPS-only. - -Expected behavior after removal: users configure `opencc.server_url` to an HTTP/HTTPS -`opencode serve` endpoint. There is no WebSocket bridge mode until a new implementation is -added later. - -## Tests - -### Lua Tests - -Update `packages/trapos-ai/tests/ai.lua`: - -- Remove bridge-specific tests under the `ask over the bridge ws transport` section. -- Remove fake WebSocket helper code if it becomes unused. -- Keep direct HTTP opencode behavior tests. - -### Node / Integration Tests - -- Delete all `tools/mcp-bridge/test/` and `tools/mcp-bridge/test-integration/` tests with the - directory. -- Do not port `opencode-proxy.test.ts` or `ai-cli.test.ts` to `tools/trapos-server`; the proxy - behavior is intentionally removed. -- Keep `tools/trapos-server/test/` and `tools/trapos-server/test-integration/` as the supported - MCP/gateway coverage. - -## Tooling Changes - -### `just/npm.just` - -- Remove `npm-install-mcp-bridge`. -- Remove `npm-build-mcp-bridge`. -- Remove `npm-check-mcp-bridge`. -- Remove `npm-test-mcp-bridge`. -- Remove `npm-test-integration-mcp-bridge`. -- Make aggregate Node recipes target only `tools/trapos-server`: - - `npm-install` - - `npm-build` - - `npm-check` - - `npm-test` - - `npm-test-integration` - -### `just/install.just` - -- Change `clean` to clean `tools/trapos-server` caches instead of `tools/mcp-bridge`, or make it - a broader Node-tool clean if useful. -- Update the comment above `clean`. - -### `just/test.just` - -- Update comments that describe end-to-end tests as spanning the MCP bridge; they now span the - cloud gateway / MCP endpoint. - -### `just/stage.just` - -- No behavior change expected. It already stages package-owned sources into `.stage/`; deleting - `packages/trapos-ai/apis/libhttpws.lua` and removing it from `packages/trapos-ai/ccpm.json` - is sufficient. - -## Packaging - -Update `packages/trapos-ai/ccpm.json`: - -- Remove `apis/libhttpws.lua` from `files`. -- Bump the `trapos-ai` package version because runtime behavior/help changes. - -Update `packages/index.json`: - -- Mirror the new `trapos-ai` version. - -No `trapos-cloud` package bump is required unless its behavior changes during cleanup. - -## Documentation Cleanup - -### Required Docs - -- `DEVELOPMENT.md`: update `just clean` description so it no longer mentions `mcp-bridge`. -- `docs/public-ports.md`: remove the old `4243` / `CC_LINK_PORT` ComputerCraft bridge entry, - unless a new service explicitly claims that port later. -- `docs/adrs/adr-0019-mcp-over-cloud-gateway.md`: replace the statement that - `tools/mcp-bridge` is kept for the opencode proxy with a note that it has been removed and - the proxy was intentionally dropped pending a future rewrite. -- `docs/adrs/adr-0016-js-tool-verification.md`: replace `tools/mcp-bridge` as the JS tool - verification example with `tools/trapos-server`. - -### Historical / Optional Cleanup - -- `docs/adrs/adr-0017-mcp-remote-lua-execution.md`: this is historical; either leave it as-is - or add a short superseded note pointing to ADR-0019. -- `.opencode/agent/atm10-expert.md`: update old `computercraft-mcp-bridge_*` permission names - if they are still relevant. -- `.plans/archived/*`: leave old references as archived history unless doing a full text - cleanup pass. - -## Files Expected To Change - -- Delete: `tools/mcp-bridge/` -- Delete: `packages/trapos-ai/apis/libhttpws.lua` -- Modify: `packages/trapos-ai/apis/libai.lua` -- Modify: `packages/trapos-ai/programs/ai.lua` -- Modify: `packages/trapos-ai/tests/ai.lua` -- Modify: `just/npm.just` -- Modify: `just/install.just` -- Modify: `just/test.just` -- Modify: `packages/trapos-ai/ccpm.json` -- Modify: `packages/index.json` -- Modify: `DEVELOPMENT.md` -- Modify: `docs/public-ports.md` -- Modify: `docs/adrs/adr-0019-mcp-over-cloud-gateway.md` -- Modify: `docs/adrs/adr-0016-js-tool-verification.md` -- Optional: `docs/adrs/adr-0017-mcp-remote-lua-execution.md` -- Optional: `.opencode/agent/atm10-expert.md` - -## Verification - -After implementation: - -1. Run `just check`. -2. Run `just test`. -3. Run `just npm-test-integration` if not already covered by the selected test target. -4. Optionally run full `just ci` before merging. - -Expected result: - -- No recipe references `tools/mcp-bridge`. -- No package descriptor references `apis/libhttpws.lua`. -- No source file references `require('/apis/libhttpws')`, `opencc.bridge_url`, or - `opencc.request_timeout_seconds` except archived history or generated/local CraftOS state. -- `ai` works only through direct HTTP/HTTPS `opencc.server_url`. -- MCP tools continue to work through `tools/trapos-server`. diff --git a/.plans/archived/trapos-create-disk-plan.md b/.plans/archived/trapos-create-disk-plan.md deleted file mode 100644 index 22a2f52..0000000 --- a/.plans/archived/trapos-create-disk-plan.md +++ /dev/null @@ -1,161 +0,0 @@ -# Plan: `trapos-create-disk` + ccpm prune/autoprune - -## Context - -Today `programs/trapos-create-installer-disk.lua` only builds an **install** floppy disk -(its `startup.lua` installs TrapOS on a _blank_ computer). We want one disk-creation tool -that can also produce **reinstall** and **uninstall** disks, and we want a real "remove -TrapOS" path. ComputerCraft's `ccpm uninstall trapos` alone is insufficient: it only deletes -the `trapos` meta-package's own files and its dependency guard blocks cascading removal. - -The fix is two-sided: - -1. Teach `ccpm` to remove orphaned dependencies (`prune`), auto-pruned on `uninstall`, so - `ccpm uninstall trapos` cleanly wipes the whole tree and the `/trapos` state dir. -2. Rename/generalize the disk creator to emit install / reinstall / uninstall disks whose - `startup.lua` performs the matching action on the booted target. - -## Decisions (settled with user) - -- Program rename: `programs/trapos-create-installer-disk.lua` → **`programs/trapos-create-disk.lua`**. - Bare command prints help. Flags select the disk variant: `--install`, `--reinstall`, - `--uninstall`. Keep `version` and `help`. -- **No** separate `trapos-uninstall.lua`. Everything routes through `ccpm uninstall trapos`. -- `ccpm` gains a new **`explicit`** flag per lock entry, a **`prune`** command, **autoprune - on uninstall** (`--no-prune` to disable), interactive **y/N confirm** with **`--yes`/`-y`**, - and **state-dir deletion when empty**. `ccpm` never auto-reboots — the disk `startup.lua` - owns reboot/eject. - ---- - -## Part A — ccpm changes - -### A1. `explicit` flag (`apis/libccpm.lua`, `api.install` ~L397) - -When writing a lock entry, set `explicit = (item.name == pkg)`. Deps pulled in get -`explicit = false`. Preserve an existing `true` (re-installing a dep that was previously -explicit must not demote it). **Legacy/migration:** a lock entry with no `explicit` field is -treated as `explicit = true` (never auto-prune pre-existing installs). - -### A2. `api.prune(opts)` (new, `apis/libccpm.lua`) - -- Compute the set of packages **reachable** from every explicit root by walking - `dependencies` (reuse the traversal idea from `api.resolve`, L314). -- Orphans = installed packages not in the reachable set. -- Remove the **whole orphan set atomically**: delete each orphan's `files`, drop all their - lock entries in one pass, then `writeLock` + `writeOsState`. This deliberately bypasses the - per-package dependents guard in `api.uninstall` (orphans depend on each other but none are - reachable from a root). Return the list of pruned names. - -### A3. Autoprune in `api.uninstall` (`apis/libccpm.lua` L439) - -- Add `opts.prune` (default behavior = prune). After removing `pkg` (existing logic L463-469), - if pruning is enabled call `api.prune` and merge its removed list into the result/log. -- `libccpm.uninstall` stays **non-interactive** (CLI owns the prompt) so `tests/ccpm.lua` - keep working. - -### A4. State-dir cleanup when empty (`apis/libccpm.lua`) - -- After any uninstall/prune that leaves `lock.packages` empty, delete the entire state dir - (`stateDir`, default `/trapos`) so the machine is blank and the reinstall guard - `fs.exists('/trapos')` passes. Also remove now-empty generated dirs created by the installer - (`/programs`, `/apis`, `/servers`, `/startup`) if empty. Guard the path so a custom test - `stateDir` is still removed safely. - -### A5. CLI wiring (`programs/ccpm.lua`) - -- `uninstall|remove|rm` handler (L102): parse `--no-prune` and `--yes`/`-y` from args. Unless - `--yes`, print what will be removed (`pkg` + a dry-run prune list) and read a y/N line via a - small local `confirm()` helper (no read() helper exists in the repo — add one using - `read()`/`io.read`). Pass `{ prune = not noPrune, log = logLine }` to `ccpm.uninstall`. -- Add `prune` command → `ccpm.prune({ log = logLine })`, printing pruned packages or - "nothing to prune". -- Update `printUsage()` to list `prune`, `uninstall [--yes] [--no-prune]`. - ---- - -## Part B — disk creator - -### B1. Generalize `apis/libinstallerdisk.lua` - -- `create(opts)` takes `opts.mode = 'install' | 'reinstall' | 'uninstall'` (default `install`). -- `buildAutorun(mode)` emits the variant `startup.lua`. Reuse `findTargetDrive`, labeling, - and `DEFAULT_INSTALL_URL` (`https://os.trapcloud.fr/install`). Rename disk label to - **"TrapOS Disk"**. -- **install** mode: current behavior unchanged — clone `/.settings` onto the disk, autorun - guards `if fs.exists('/trapos') then return`, restores settings, `wget run INSTALL_URL`. - Only this mode clones settings. -- **uninstall** mode autorun: - - ```lua - if not fs.exists('/trapos') then print('TrapOS already removed.') else - shell.run('ccpm','uninstall','trapos','--yes') - end - print('TrapOS removed. Eject the disk to finish.') - os.pullEvent('disk_eject'); os.reboot() - ``` - -- **reinstall** mode autorun (marker breaks the post-install loop, since `install-trapos` - reboots with the disk still inserted): - - ```lua - local MARKER = '/.trapos-reinstalled' - if fs.exists(MARKER) then - print('Reinstall complete. Eject the disk.') - os.pullEvent('disk_eject'); os.reboot(); return - end - -- write MARKER - if fs.exists('/trapos') then shell.run('ccpm','uninstall','trapos','--yes') end - shell.run('wget','run', INSTALL_URL) -- installer reboots - ``` - - `/.settings` is left untouched by uninstall, so the machine keeps its own config — no clone. - -### B2. Rewrite `programs/trapos-create-disk.lua` - -- Replace positional command parsing with flag parsing (pattern from `programs/ai.lua` L4-26). -- Bare invocation (or `help`) → `printUsage()` documenting `--install/--reinstall/--uninstall`. -- Keep `version` via `require('/apis/libversion')().forSelf()` and the local `printColored`. -- Map flag → `createInstallerDisk().create({ mode = ... })`; reuse existing success/warning - output (label, settingsCopied/Warning, insert-and-reboot hint), adjusting copy per mode. - ---- - -## Part C — packaging / metadata - -- `packages/trapos-core/ccpm.json`: rename `programs/trapos-create-installer-disk.lua` → - `programs/trapos-create-disk.lua` in `files`; bump `version`. -- `packages/index.json`: bump `trapos-core` version to match. -- Delete the old `programs/trapos-create-installer-disk.lua` file (currently uncommitted/new). - ---- - -## Files to modify - -- `apis/libccpm.lua` — `explicit` flag, `api.prune`, autoprune in `api.uninstall`, empty-state cleanup. -- `programs/ccpm.lua` — `prune` command, uninstall `--yes`/`--no-prune` + confirm helper, usage. -- `apis/libinstallerdisk.lua` — mode-parameterized `create`/`buildAutorun`, label rename. -- `programs/trapos-create-disk.lua` — new (renamed from `trapos-create-installer-disk.lua`). -- `packages/trapos-core/ccpm.json`, `packages/index.json` — file rename + version bump. - -## Tests (repo uses `apis/libtest`, `__TRAPOS_TEST_OK__` on pass) - -- `tests/ccpm.lua` — add: - - install marks target `explicit=true`, deps `explicit=false`. - - `prune` removes orphans not reachable from explicit roots; keeps reachable ones. - - `uninstall trapos` (with deps) autoprunes the whole tree; `--no-prune`/`{prune=false}` - removes only the target. - - uninstalling the last package deletes the (test) state dir. - - legacy entry without `explicit` is not pruned. -- Rename `tests/installer-disk.lua` → `tests/create-disk.lua`; add `buildAutorun` assertions - per mode: install contains settings-copy-before-`wget`; uninstall contains `ccpm uninstall -trapos --yes` + `disk_eject` and no `wget`; reinstall contains marker check + `uninstall ---yes` + `wget run`. - -## Verification - -1. Run the suite: `tests/ccpm.lua` and `tests/create-disk.lua` (look for `__TRAPOS_TEST_OK__`). -2. Manually inspect generated `startup.lua` for each mode via the new `buildAutorun` tests. -3. In-world (if available): build an install disk on a blank computer (unchanged path), then a - reinstall disk and an uninstall disk on an installed computer; confirm uninstall wipes - `/trapos` and waits for eject, and reinstall completes then waits for eject. diff --git a/.plans/archived/trapos-create-installer-disk-plan.md b/.plans/archived/trapos-create-installer-disk-plan.md deleted file mode 100644 index ab85882..0000000 --- a/.plans/archived/trapos-create-installer-disk-plan.md +++ /dev/null @@ -1,152 +0,0 @@ -# Plan: `trapos-create-installer-disk` — one-shot TrapOS installer floppies - -## Context - -Today TrapOS is installed by typing `wget run https://os.trapcloud.fr/install` on each -fresh computer, then manually configuring settings (`cloud.url`, `cloud.password`, …) per -machine. We want a **provisioning floppy**: run one program on a configured machine to "bake" -an installer disk, then on any blank machine just insert the disk and boot — it installs the -latest TrapOS online and clones *your* `.settings` onto the new computer, hands-free. - -The disk's value is **hands-free install + config cloning**, not offline file shipping. - -## Resolved design decisions (from grill session) - -- **Install method**: autorun installs **online** via the existing `wget run` bootstrap. No - files are bundled on the disk; the disk stays tiny and never goes stale vs the registry. -- **Disk boot file**: CC only auto-runs `startup`/`startup.lua` from a floppy at boot. We put - the autorun logic **directly in `/startup.lua`** (the name "autorun" is conceptual). -- **Ordering (copy-then-install, no reboot)**: `install-trapos.lua` ends by handing off to the - live `/startup/servers.lua` shell, which never returns. So the autorun **copies `.settings` - first**, calls `settings.load()`, *then* runs the installer (whose `trapos-postinstall` - respects the already-set `cloud.url` and never touches `cloud.password`, so the disk's - config wins). **No changes to `install-trapos.lua`** and **no reboot** here — the - boot/reboot rework is a separate effort. -- **Sentinel**: autorun no-ops if `/trapos` exists. To reinstall: `rm /trapos`, reinsert, boot. -- **Drive selection**: pick the **first eligible** drive = present, writable floppy that is - **empty** (`#fs.list(mountPath) == 0`). Empty is preferred over non-empty. `0` empty → error - (distinguish "disk not empty" vs "insert a blank floppy"); `2+` empty → print "multiple blank - disks found, remove all but one" and stop. No interactive insert handling. -- **Missing `/.settings` at creation**: warn + still build a valid (settings-less) installer. - When `/.settings` exists, `settings.save()` first to flush in-session values, then copy. -- **Label**: `TrapOS Installer`. -- **Code shape**: injectable factory `apis/libinstallerdisk.lua` + thin CLI - `programs/trapos-create-installer-disk.lua` + `tests/installer-disk.lua`; ship in - `trapos-core`. Mirrors `apis/libccpm.lua` + `programs/ccpm.lua` + `tests/ccpm.lua`. - -## Conventions to follow - -2-space indent, semicolons, `local function` (per `AGENTS.md`). `require` absolute paths. -Programs support `-version`/`--version` and `-help`/`--help` via -`require('/apis/libversion')().forSelf()`. Colored output guarded by `term.isColor()` (see -`printColored` in `programs/ccpm.lua`). Factory opts default to globals (see `createCcpm(opts)` -in `apis/libccpm.lua`). - -## 1. New: `apis/libinstallerdisk.lua` (testable factory) - -```lua --- createInstallerDisk(opts) -> api --- opts.peripheral (default global `peripheral`) -- drive discovery --- opts.settings (default global `settings`) -- save() flush --- opts.settingsSourcePath (default '/.settings') -- computer config to clone --- opts.installerLabel (default 'TrapOS Installer') --- opts.installUrl (default 'https://os.trapcloud.fr/install') --- (uses real global `fs`; tests isolate with real dirs, like tests/ccpm.lua) -``` - -Public API: -- `api.buildAutorun()` → returns the `startup.lua` source string (pure, fully unit-testable). -- `api.findTargetDrive()` → `ok, { name, mountPath } | err`. Iterates - `peripheral.find('drive')`/`getNames`, keeps present writable floppies (`isDiskPresent()` - and `hasData()`), filters to empty (`#fs.list(getMountPath()) == 0`), applies the - 0/1/2+ rules above with the distinct error messages. -- `api.create()` → `ok, result | err`. Orchestrates: find drive → `setDiskLabel('TrapOS - Installer')` → if `settingsSourcePath` exists: `settings.save()` then - `fs.copy(settingsSourcePath, mount/.settings)` (delete dest first) else set - `result.settingsWarning = true` → write `buildAutorun()` to `mount/startup.lua`. - `result = { name, mountPath, label, settingsCopied, settingsWarning }`. - -`buildAutorun()` returns (note: copy-then-install, locate-own-disk-by-label, no reboot): - -```lua --- TrapOS installer disk autorun (generated; do not edit) -local INSTALLER_LABEL = 'TrapOS Installer'; -local INSTALL_URL = 'https://os.trapcloud.fr/install'; - -if fs.exists('/trapos') then return; end -- already installed: leave machine alone - -local function installerMount() - for _, name in ipairs(peripheral.getNames()) do - if peripheral.getType(name) == 'drive' then - local drive = peripheral.wrap(name); - if drive.isDiskPresent() and drive.getDiskLabel() == INSTALLER_LABEL then - return drive.getMountPath(); - end - end - end -end - -local mount = installerMount(); -if mount then - local src = fs.combine(mount, '.settings'); - if fs.exists(src) then - fs.delete('/.settings'); - fs.copy(src, '/.settings'); - settings.load(); - end -end - -print('TrapOS Installer: installing TrapOS...'); -shell.run('wget', 'run', INSTALL_URL); -``` - -## 2. New: `programs/trapos-create-installer-disk.lua` (thin CLI) - -- Handles `version`/`-version`/`--version` (prints `v' .. createVersion().forSelf()`) and - `help`/`-help`/`--help` (usage), mirroring `programs/trapos-upgrade.lua`. -- Calls `createInstallerDisk().create()`. On error: print message in red, `return`. On success: - print a green summary (label set, `.settings` cloned / or the missing-settings warning, - which drive), plus a hint: "Insert this disk into a blank computer and reboot it." -- No confirmation prompt (target disk is required to be empty → low risk). - -## 3. New: `tests/installer-disk.lua` - -Follow `tests/ccpm.lua`: `local createLibTest = require('/apis/libtest')`, isolated real-fs -dirs per case, fake `peripheral` injected. Cases: -- `buildAutorun()` contains the `/trapos` sentinel, the label, the install URL, copies - `.settings` before `wget run`, and has no `os.reboot`. -- `findTargetDrive`: zero drives → "insert a blank" error; one non-empty floppy → "not empty" - error; one empty + one non-empty → picks the empty; two empty → "remove all but one" error. -- `create`: sets label, writes `startup.lua` (== `buildAutorun()`) into the fake mount dir, - copies a temp `settingsSourcePath` into `mount/.settings`; missing source → `settingsWarning` - true and no copy. Fake `settings.save` is a recording no-op so the real `/.settings` is never - touched. - -Fake `peripheral`: `getNames`/`getType`/`wrap` returning fake drives whose `getMountPath()` -points at a real temp dir (e.g. `/installer-disk-test/disk-N`) so `fs.copy`/`fs.open`/`fs.list` -operate on real fs; `isDiskPresent`/`hasData` return true; `set/getDiskLabel` store a field. - -## 4. Packaging - -- `packages/trapos-core/ccpm.json`: append `"apis/libinstallerdisk.lua"` and - `"programs/trapos-create-installer-disk.lua"` to `files`; bump `version` `0.6.3` → `0.7.0`. -- `packages/index.json`: mirror `trapos-core` → `0.7.0`. - -## Verification - -1. `just check` — luacheck clean (respect `.luacheckrc` `lua51+cc`) and lychee link check. -2. `just test` (or `runtest tests/installer-disk.lua`) — suite prints `__TRAPOS_TEST_OK__`. -3. Optional headless probe: `just trapos-exec ''` to smoke-test against an emulated drive end-to-end. -4. In-game (human): run `trapos-create-installer-disk` on a configured machine → verify label - `TrapOS Installer`, `startup.lua` + `.settings` present on the floppy. Insert into a blank - computer, reboot → TrapOS installs and `.settings` is cloned. Confirm re-boot with disk - still in is a no-op (because `/trapos` exists), and that `rm /trapos` + reboot reinstalls. - -## Assumptions / caveats - -- Target blank computer has `shell.allow_disk_startup` enabled (CC default `true`) and HTTP - enabled with `trapcloud.fr` reachable/allowed (CC default). -- Literal order is **copy-then-install** (not "install-then-copy"); functionally the disk's - settings still win. The pre-existing "exit the OS shell re-runs `servers.lua`" wart and any - forced reboot are **out of scope** — owned by the separate boot/reboot rework. diff --git a/.plans/archived/tror-helloworld-plan.md b/.plans/archived/tror-helloworld-plan.md deleted file mode 100644 index 16b8b0b..0000000 --- a/.plans/archived/tror-helloworld-plan.md +++ /dev/null @@ -1,55 +0,0 @@ -# TRoR Hello World POC Plan - -## Goal - -Build a small proof-of-concept that drives an in-game ComputerCraft computer (on an ATM10 server) from a local CraftOS-PC instance using TRoR (Terminal Redirection over Rednet). - -The POC is intentionally minimal: see "Hello World" printed by an in-game computer rendered locally, and have a local keystroke produce a CC event in-game. - -## Background - -- `craftos --tror` enables the TRoR renderer but only reads/writes packets over **stdio** — it does not speak real rednet. See `docs/craftos_pc_glossary.md`. -- TRoR is a ComputerCraft standard (oeed/CraftOS-Standards #10): packet format `:;` (e.g. `TW` write, `TC` cursor, `EV` event). -- [`lyqyd/cc-netshell`](https://github.com/lyqyd/cc-netshell) already implements a TRoR server/client over rednet inside CC. We piggyback on it instead of reimplementing the protocol. - -## Architecture - -Three actors: - -```text -[ local craftos --tror ] <-- stdio --> [ ws bridge ] <-- ws --> [ in-game relay CC ] <-- rednet --> [ in-game target CC ] -``` - -1. **Target CC** (in-game): runs `cc-netshell` server, prints "Hello World", reads `key`/`char` events. -2. **Relay CC** (in-game): has wireless modem + uses `http.websocket` to bridge TRoR packets between rednet and an external WS endpoint. -3. **WS bridge** (host): tiny Node/Python WS server that also pipes stdio to/from `craftos --tror`. Could be a single script that spawns `craftos` as a child process. - -Why a relay: CC has no raw TCP socket; the only off-world transport is `http`/`websocket`. The ATM10 server must allow outbound HTTP/WS to our host (default CC:Tweaked config does). - -## Milestones - -1. **In-game baseline**: install `cc-netshell` on two test computers (creative world / single-player first), confirm server/client TRoR shell works over rednet alone. -2. **WS bridge skeleton**: minimal local WS server that logs frames. Verify a CC computer can connect via `http.websocket` and exchange text frames. -3. **Relay program**: in-game program that joins the WS server and forwards every WS frame as a rednet message to a configured target ID, and vice versa. Treat frames as opaque TRoR packets. -4. **Local TRoR client**: spawn `craftos --tror`, pipe its stdout to the WS bridge, pipe WS frames to its stdin. A "Hello World" written by the target CC should render in CraftOS-PC. -5. **Input loopback**: confirm keystrokes typed in local CraftOS-PC reach the target as `key`/`char` events (TRoR `EV` packets). -6. **ATM10 deployment**: copy the relay + target programs to the real server, point relay at the public host running the WS bridge. - -## Open Questions - -- Does `cc-netshell`'s wire format match the on-the-wire TRoR packet format byte-for-byte, or does it wrap packets in a rednet envelope? If wrapped, the relay must unwrap before forwarding to the WS bridge (and rewrap on the way back). -- ATM10 default CC:Tweaked config: is outbound WS to arbitrary hosts allowed? May need a whitelist entry in `computercraft-server.toml`. -- Auth: any pairing/handshake between local client and target CC, or do we rely on "obscure WS URL" for the POC? -- One target only, or do we want the relay to multiplex by target ID from day one? - -## Out Of Scope - -- Multi-user, auth, TLS hardening. -- Reconnect logic beyond "restart both ends". -- Packaging into this repo's `programs/` / `servers/` layout — that comes after the POC proves the loop works. - -## Deliverables - -- `programs/tror-relay.lua` (in-game relay, prototype quality). -- `tools/tror-bridge/` (host-side WS + craftos spawner script, language TBD). -- Notes appended to `docs/craftos_pc_glossary.md` once the stdio contract is verified. diff --git a/.plans/cloud-login-interactive-shell-history-fix.md b/.plans/cloud-login-interactive-shell-history-fix.md deleted file mode 100644 index ebb227f..0000000 --- a/.plans/cloud-login-interactive-shell-history-fix.md +++ /dev/null @@ -1,190 +0,0 @@ -# Plan: fix interactive `cloud login` retry killing the shell - -## Context - -Interactive `cloud login` now verifies a candidate password by asking the cloud daemon to reconnect -with that candidate, then waiting for either `trapos_cloud_connected` or -`trapos_cloud_unauthorized`. - -Observed bug: - -- Run `cloud login` with no password argument. -- Enter a wrong password. -- Instead of printing `invalid password, try again` and prompting again, the command exits back to - the CraftOS shell. -- The shell's in-memory history is gone, so up-arrow no longer recalls the previous `cloud login` - command. - -The interactive branch in `packages/trapos-cloud/programs/cloud.lua` already looks logically -correct: on an `unauthorized` verdict it calls `restore()`, prints the retry message, and continues -the loop. Existing unit coverage also models wrong-then-right retry successfully. That points to the -live boot/runtime topology rather than the branch itself. - -## Root Cause Hypothesis - -The short-lived verifier event loop inside `cloud login` can accidentally stop the boot event loop. - -Relevant flow: - -- `packages/trapos-cloud/programs/cloud.lua` creates a local event loop inside `verifyPassword()`. -- When the daemon emits `trapos_cloud_unauthorized`, the verifier calls `el.stopLoop()`. -- `packages/trapos-core/apis/eventloop.lua` implements `stopLoop()` by globally queueing a synthetic - event named `@libeventloop/END_OF_LOOP/`. -- Event loop IDs currently come from a module-local counter. -- If `/apis/eventloop.lua` is loaded as independent module instances, both the boot event loop and - the local verifier loop can use ID `1`. -- The verifier's synthetic stop event can therefore match the boot loop's stop event name. -- `packages/trapos-boot/startup/boot.lua` runs the shell and boot event loop with - `parallel.waitForAny(shellFn, eventLoopFn)`. -- If the boot event loop exits, `parallel.waitForAny` returns and the shell coroutine is killed. -- Killing/restarting the shell explains losing shell history. - -The stale hello `messageId` issue found during exploration is real hardening work, but it does not -directly explain shell history loss. It can cause wrong verdict attribution, not shell teardown. - -## Goals - -- Wrong passwords in interactive `cloud login` re-prompt without leaving the command. -- Stopping a short-lived local event loop must not stop `_G.bootEventLoop`. -- Preserve CraftOS shell history after wrong-password retry. -- Keep the fix minimal and local to the event loop semantics where possible. -- Add regression tests that reproduce the event-loop collision class. - -## Non-Goals - -- Do not replace `cloud login` verification with a program-owned websocket. -- Do not add a standalone Lua test harness. -- Do not redesign `startup/boot.lua` or shell lifecycle unless the event-loop fix proves insufficient. -- Do not add persisted shell history as a workaround for shell teardown. - -## Implementation - -### 1. Make event loop IDs process-global - -File: `packages/trapos-core/apis/eventloop.lua` - -Replace the module-local `next_eventloop_id` allocation with a `_G`-backed counter so independently -loaded copies of the module cannot reuse the same synthetic end event name. - -Sketch: - -```lua -local COUNTER_KEY = '__trapos_next_eventloop_id'; - -local function nextEventLoopId() - local id = tonumber(_G[COUNTER_KEY]) or 1; - _G[COUNTER_KEY] = id + 1; - return id; -end -``` - -Then use: - -```lua -local eventloop_id = nextEventLoopId(); -``` - -This keeps the existing `@libeventloop/END_OF_LOOP/` design but removes collisions across module -instances. - -### 2. Avoid queued stop events when stopping from inside the same loop - -File: `packages/trapos-core/apis/eventloop.lua` - -Add a local `stopRequested = false` flag. - -When `stopLoop()` is called while the loop is actively dispatching its own handler or timeout, set -`stopRequested = true` instead of queueing a global event. After dispatching the current event, break -the loop if `stopRequested` is set. - -Keep queueing the synthetic end event when `stopLoop()` is called from another coroutine, because the -loop may be blocked inside `os.pullEventRaw()` and needs to be woken. - -This is a second layer of protection: the verifier's `finish()` calls `stopLoop()` from inside its own -event handler, so no global end event should be needed for that path at all. - -### 3. Add an event-loop collision regression test - -File: `packages/trapos-core/tests/eventloop.lua` - -Add a test that simulates independent module loads: - -- Load `/apis/eventloop.lua` twice via `loadfile()` or an equivalent isolated environment. -- Create one boot-like loop from the first module and one verifier-like loop from the second module. -- Run both under `parallel`. -- Stop the verifier loop from one of its handlers. -- Assert the boot-like loop still handles a later probe event. -- Stop the boot-like loop explicitly at the end so the test cannot hang. - -The regression should fail against the current module-local ID allocation and pass after the fix. - -### 4. Add or extend cloud program coverage - -File: `packages/trapos-cloud/tests/cloud-program.lua` - -Keep the existing wrong-then-right interactive test, because it verifies the program branch behavior. - -Add a small test for prompt-time terminate handling if practical: - -- Fake `read('*')` raises `Terminated`. -- Assert `cloud login` queues `login-restore` when needed and re-raises `Terminated`. - -This is not the primary wrong-password bug, but it closes an adjacent interactive cancellation gap. - -### 5. Optional cloud hardening: match hello responses by `messageId` - -File: `packages/trapos-cloud/apis/libcloud.lua` - -Store the active hello `messageId`, pass `frame.messageId` through `api.onMessage()` to `onHello`, -and ignore hello responses that do not match the currently active hello attempt. - -This prevents stale hello responses from earlier reconnects being attributed to the current -`cloud login` token. It should be treated as a separate hardening change if the event-loop fix is -intended to stay small. - -## Package Versions - -If only `eventloop.lua` changes: - -- Bump `trapos-core` in `packages/trapos-core/ccpm.json`. -- Mirror the same version in `packages/index.json`. - -If cloud program or cloud daemon behavior also changes: - -- Bump `trapos-cloud` in `packages/trapos-cloud/ccpm.json`. -- Mirror the same version in `packages/index.json`. - -Only bump the full `trapos` meta-package if the repository's release convention for this change -requires it. - -## Verification - -Targeted iteration: - -```sh -just trapos-exec 'shell.run("/programs/runtest.lua", "--pretty", "/tests/eventloop.lua", "/tests/cloud-program.lua", "/tests/cloud.lua")' -``` - -Required after Lua/package edits: - -```sh -just check -just test --pretty -``` - -Manual validation on a running TrapOS computer: - -- Run `cloud login --force`. -- Enter a known wrong password. -- Confirm it prints `invalid password, try again (ctrl+t to cancel)` and prompts again. -- Press up-arrow after eventually cancelling or completing, and confirm shell history still contains - `cloud login --force`. - -## Risks - -- Event loop stop semantics are shared by servers and programs, so regression tests should cover both - same-loop stop and cross-coroutine stop. -- If CraftOS-PC loads `require('/apis/eventloop')` as a singleton in more cases than expected, the ID - collision may be intermittent; the `_G` counter still makes the behavior deterministic and safer. -- The optional hello `messageId` hardening will require updating existing cloud tests that currently - send hello responses without message IDs. diff --git a/.plans/cloud-login-session-crash-fix-plan.md b/.plans/cloud-login-session-crash-fix-plan.md deleted file mode 100644 index fab882a..0000000 --- a/.plans/cloud-login-session-crash-fix-plan.md +++ /dev/null @@ -1,129 +0,0 @@ -# Fix: interactive `cloud login` tears down the whole trapos session on a wrong password - -## Context - -`cloud login` (interactive) was just rewritten to verify the password before saving -(commit `1d4789b`). When testing it at the **local in-game terminal**, typing a wrong -password produces two bugs: - -1. It prints `invalid password, try again (ctrl+t to cancel)` and then **quits to the - shell instead of re-prompting**. -2. The CraftOS shell **command history is gone** (up-arrow no longer recalls `cloud login`). - -### Why this happens (root cause — confirmed by code, not guessed) - -The interactive loop itself is correct: verified three ways (logic trace, a faithful -real-`eventloop`+daemon simulation, and a real-CC probe on `8:trap2`) that a wrong -password returns `'unauthorized'`, runs the re-prompt branch (which is why the user sees -the "invalid password" line), and loops back to `readPassword()`. So the program is *not* -the thing that exits — **the whole trapos session is being torn down underneath it.** - -The teardown chain, all confirmed in the code: - -- A wrong password triggers a rapid reconnect storm on the daemon: `login-verify` - (close live socket → open with candidate → rejected) immediately followed by - `login-restore` (close → open with persisted secret), plus any `reconnectDelay` retries. -- `connect()` opens the socket with `httpLike.websocketAsync(url)` **un-`pcall`'d** - (`packages/trapos-cloud/apis/libcloud.lua:256`). On rapid open/close this can throw - (CC's per-computer websocket limit / "already open" races). -- The shared boot event loop dispatches handlers **un-`pcall`'d** - (`packages/trapos-core/apis/eventloop.lua:294` — `handler(table.unpack(packed))`). -- The boot loop runs under `parallel.waitForAny(shellFn, eventLoopFn)` with no isolation - (`packages/trapos-boot/startup/boot.lua:80`). A throw in `eventLoopFn` propagates out of - `waitForAny`, the `startup` script dies, and CraftOS drops to its **default shell with a - fresh history** — i.e. "quit to shell" + "history lost". It fires right after the - "invalid password" line because the throw happens while the daemon processes the - `login-restore` reconnect. - -The connection bouncing is harmless on a local terminal; the **session crash** is the bug. - -## Fix - -Two small, complementary changes. The eventloop change is the real fix (it makes the -shared boot loop fault-isolated so no server hiccup can ever kill the session); the -`connect()` change removes the specific throw and keeps the daemon retrying correctly. - -### 1. (Recommended, systemic) Isolate handler errors in the boot event loop - -`packages/trapos-core/apis/eventloop.lua`, in `runLoop` (~line 293): - -Wrap the handler call so a throwing handler can never escape `runLoop` (and thus never -crash `parallel.waitForAny` / the trapos session). Keep the existing `api.STOP` -unregister behaviour for the success case; on error, report and continue. - -```lua --- before: -local result_handler = handler(table.unpack(packed)) -if result_handler == api.STOP then - api.unregister(eventName, handler) -end --- after: -local ok, result_handler = pcall(handler, table.unpack(packed)) -if not ok then - -- one bad handler must not tear down the shared boot loop / session - printError('eventloop handler error ('..eventName..'): '..tostring(result_handler)) -elseif result_handler == api.STOP then - api.unregister(eventName, handler) -end -``` - -(Use whatever error sink fits the file's style — `printError` if available, else a no-op / -existing logger. The point is: catch, don't propagate.) - -### 2. (Cause-specific) Make the daemon's socket-open crash-safe - -`packages/trapos-cloud/apis/libcloud.lua`, `connect()` (~line 246-257): - -`pcall` the `websocketAsync` call and, on failure, fall back to `scheduleReconnect()` so a -throw becomes a normal retry instead of a fatal error. `scheduleReconnect` is defined -*after* `connect`, so add a forward declaration `local scheduleReconnect` before `connect` -and drop the `local` on its later definition. - -```lua -local function connect() - if clearReconnectTimeout then clearReconnectTimeout(); clearReconnectTimeout = nil; end - secret = secretOverride or getSecret(); - state = 'connecting'; - if not reconnecting then log('info', 'connecting', { url = url }); end - local ok = pcall(function() httpLike.websocketAsync(url); end) - if not ok then - log('warn', 'websocket open failed', { url = url }); - scheduleReconnect(); - end -end -``` - -After these, the wrong-password flow is: verify → `unauthorized` → print "invalid -password" → `login-restore` (now crash-safe) → `readPassword()` re-prompts. Both symptoms -are fixed: the session survives and the prompt re-appears. - -### Note (not the cause, but flagged by the grilling plans) - -The interactive `terminate` branch still does `error('Terminated', 0)` -(`packages/trapos-cloud/programs/cloud.lua:182`). With the eventloop hardened this no -longer risks the session, but a clean `print('login cancelled'); return;` for the Ctrl+T -cancel is a reasonable follow-up (optional, out of scope for the crash fix). - -## Tests - -- **`packages/trapos-core/tests/eventloop.lua`** — add: a handler that throws does not - propagate out of `runLoop`; sibling handlers and subsequent events still fire. -- **`packages/trapos-cloud/tests/cloud.lua`** — add (uses the existing - `fakes.fakeHttp()` / `ctx.el.fire` harness, ~line 186-205): configure - `http.websocketAsync` to `error()`, fire `trapos_cloud_reconnect`, assert no error - propagates and the daemon schedules a retry rather than dying. -- Re-run the existing cloud suites (`tests/cloud.lua`, `tests/cloud-program.lua`) — the - fake-eventloop program tests are unaffected. - -## Verification (end to end) - -On a disposable/test CC computer (do **not** use the shared `8`/`10` machines — driving a -wrong-password verify bounces their live gateway connection): - -1. Deploy the updated `trapos-core` + `trapos-cloud`, set `cloud.url`, reboot so the daemon - runs. -2. `cloud login --force`, type a **wrong** password, Enter. - - Expect: `invalid password, try again (ctrl+t to cancel)` **and the prompt returns** — - the shell/session stays alive and command history is intact. -3. Type the correct password → `logged in`. -4. Sanity: `cloud status` still works (daemon recovered after the verify bounce). diff --git a/.plans/cloud-login-session-crash-merged-fix-plan-final.md b/.plans/cloud-login-session-crash-merged-fix-plan-final.md deleted file mode 100644 index 5c7c7d4..0000000 --- a/.plans/cloud-login-session-crash-merged-fix-plan-final.md +++ /dev/null @@ -1,215 +0,0 @@ -# Final Plan: fix interactive `cloud login` session teardown - -## Goal - -Fix the user-visible bug where interactive `cloud login` with a wrong password prints the retry -message, then drops back to a fresh CraftOS shell with lost in-memory command history instead of -prompting again. - -One end-to-end fix, split into small independently testable changes. Before building all of it, we -**disambiguate the actual root cause** (see Step 0) so we know which fix is curing the bug and which -are hardening — the prior merged plan shipped three fixes across two competing theories without ever -confirming which one mattered. - -## Confirmed code facts (verified against source) - -These are grounded in the current tree, not hypotheses: - -- `eventloop.lua:19` — `next_eventloop_id` is **module-local**. `eventloop.lua:174/186` — - `END_OF_LOOP` is keyed to that id, and `stopLoop()` stops a loop by queueing that global event. - Boot loop is created at `boot.lua:48`; the `cloud login` verifier loop at `cloud.lua:117`. -- `eventloop.lua:294` — handlers are dispatched **un-`pcall`'d**. The boot loop runs this under - `parallel.waitForAny(shellFn, eventLoopFn)` (`boot.lua:63,80`), so a throwing handler ends the - startup session and drops to a fresh shell. -- `libcloud.lua:256` — `connect()` calls `httpLike.websocketAsync(url)` **un-`pcall`'d**. The daemon - runs on `_G.bootEventLoop` (`servers/cloud.lua:45`), and `trapos_cloud_reconnect` → - `reconnectNow` → `connect()` (`libcloud.lua:415→425`). So a throw here escapes into the boot loop - and out through `waitForAny`. - -## Two candidate root causes predict *different* symptoms - -This is the key insight that drives Step 0. - -- **A. Event-loop ID collision (clean exit).** Only possible if `require('/apis/eventloop')` hands - the cloud program a *separate module instance* from boot's. If so, both loops can take id `1`; the - verifier's `stopLoop()` queues `END_OF_LOOP/1`, the boot loop also matches it and exits **cleanly** - → `waitForAny` returns → `startup` ends with **no error printed** → fresh shell. If `require` is a - singleton (normal CraftOS module cache), boot=id1 / verifier=id2+, names never collide, and this is - a non-cause. -- **B. Thrown handler (crash exit).** A throw in a boot-loop handler (e.g. websocket open) propagates - through `waitForAny`; bios prints a `startup:80:`-style **traceback** before the fresh shell. - -The original report — "invalid password, then dropped to shell", no traceback mentioned — leans -toward **A (silent clean exit)**. Step 0 confirms which it is so the after-test is meaningful. - -## Step 0: Disambiguate and reproduce the failure FIRST - -On a disposable TrapOS computer (never the shared `8`/`10` machines — a wrong-password verify bounces -their live gateway connection): - -1. Deploy the *current* (unfixed) `trapos-core` + `trapos-cloud`, set `cloud.url`, reboot so the - daemon runs. -2. `cloud login --force`, enter a **wrong** password. -3. Record exactly what happens: - - Was a Lua **traceback** printed before the shell returned? → cause is **B** (a throw); fix #3 is - load-bearing, fix #1 is unrelated hardening. - - Did it drop to the shell **silently** (only the "invalid password" line, then a fresh prompt)? - → cause is **A** (collision); fix #1 is load-bearing. -4. Confirm the runtime instance question directly. Probe whether the cloud program actually gets a - separate eventloop module instance from boot: - - ```sh - # from a shell.run program context, compare against boot's loader identity / next id - just trapos-exec 'print(tostring(require("/apis/eventloop")))' - ``` - - Compare the loader identity (and observed allocated ids) seen from boot vs. from a `shell.run` - program. If identical → singleton → collision (A) is impossible and the bug must be B. - -Do not skip this. If the failure does not reproduce on demand it may be instance-identity dependent -and therefore intermittent; a post-fix "it re-prompts once" result would prove nothing. - -## Bugs / Fix Areas - -### 1. Event-loop stop can leak across loops - -`packages/trapos-core/apis/eventloop.lua`. - -**Primary fix — same-loop `stopRequested` (cleaner, more local).** The verifier's `finish()` always -calls `stopLoop()` from *inside its own handler*. So when `stopLoop()` is invoked while the loop is -actively dispatching its own handler/timeout, set an internal `stopRequested = true` and break after -the current dispatch — never queue a global event at all. This makes the verifier→boot leak -impossible regardless of ids. - -**Belt-and-suspenders — process-global id counter.** Keep queueing the synthetic `END_OF_LOOP/` -for *external* (cross-coroutine) stop calls, where the loop may be blocked in `os.pullEventRaw()` and -must be woken. To make those ids collision-proof across independently loaded module instances, -allocate the id from a `_G`-backed counter: - -```lua -local COUNTER_KEY = '__trapos_next_eventloop_id'; -local function nextEventLoopId() - local id = tonumber(_G[COUNTER_KEY]) or 1; - _G[COUNTER_KEY] = id + 1; - return id; -end -``` - -Preserve existing contracts: `api.STOP` unregister on successful handler return, duplicate-register -error, and "stopping an already stopped loop" error. - -### 2. Boot event-loop handler errors can tear down the session - -`packages/trapos-core/apis/eventloop.lua` + `packages/trapos-boot/startup/boot.lua`. - -- Add protected handler dispatch as an explicit, opt-in event-loop option, e.g. - `createEventLoop({ onError = function(eventName, err) ... end })`. -- Use protected dispatch for `_G.bootEventLoop` in `boot.lua`; the error handler reports via - `printError` (else `print`) and the loop continues after a handler error. -- Keep dispatch **unprotected by default** for short-lived program-local loops so dev/test failures - still fail loudly unless explicitly isolated. -- Interaction to verify: after a handler throws under protected dispatch, the `api.STOP` unregister - (`:295`) and the `END_OF_LOOP`/`terminate` break (`:303`, which sits *outside* the handler loop) - must still run. - -### 3. Cloud daemon websocket open can throw during reconnect - -`packages/trapos-cloud/apis/libcloud.lua`. - -- Forward-declare `scheduleReconnect` so `connect()` can call it. -- Wrap `httpLike.websocketAsync(url)` in `pcall`. -- On failure, log `websocket open failed` and call `scheduleReconnect()` instead of throwing. -- Avoid duplicate noisy logs while already in reconnect-loop mode. - -## Out of scope (state the reasoning so it isn't re-litigated) - -- **`error('Terminated', 0)` at `cloud.lua:182/205` (ctrl+t path).** Raised in the *cloud program* - (shellFn side), which `shell.run` already `pcall`s — it never reaches `waitForAny` and is not - fatal to the session. Protected boot dispatch (#2) does not and need not cover it. Leave as-is. -- **Stale hello `messageId` matching.** Useful cloud-login hardening, but it causes wrong-verdict - attribution, not session teardown. Follow-up only if Step 0 / manual testing still shows wrong - verdicts after the main fixes. -- **Persisted shell history.** Not a workaround for session teardown. Do not add. - -## Implementation Steps - -### Step 1: Regression coverage - -`packages/trapos-core/tests/eventloop.lua`: - -- **Same-loop stop:** a handler calls `stopLoop()` on its own loop; assert the loop stops after the - current dispatch and that no global `END_OF_LOOP` event is required (i.e. a *second* sibling loop - sharing the id is unaffected). This is the test that actually corresponds to the real failure - mode — frame it that way, not as the artificial double-`loadfile`. -- **Cross-instance id:** simulate independent module loads (`loadfile`), create a boot-like and a - verifier-like loop, stop the verifier from inside its handler, assert the boot-like loop still - handles a later probe event. (Belt-and-suspenders for the `_G` counter.) Explicitly stop the - boot-like loop at the end so the test cannot hang. -- **Protected dispatch:** one handler throws, a sibling/later handler still runs, and the configured - `onError` sink receives the error. - -`packages/trapos-cloud/tests/cloud.lua`: - -- `http.websocketAsync` throws during `startSession()`/reconnect; assert the error does not - propagate and a reconnect timer is scheduled. - -Keep existing `cloud-program.lua` wrong-then-right interactive tests. - -### Step 2: Fix `eventloop.lua` - -- Add same-loop `stopRequested` break (primary). -- Replace module-local id allocation with the `_G` counter (secondary). -- Add opt-in protected dispatch (`onError`) for handlers (and timeout callbacks if practical). -- Preserve `api.STOP` unregister on success and the duplicate-register / already-stopped contracts. - -### Step 3: Use protected boot event loop - -`packages/trapos-boot/startup/boot.lua` — construct `_G.bootEventLoop` with an `onError` that reports -via `printError`/`print` and continues. - -### Step 4: Harden cloud reconnect - -`packages/trapos-cloud/apis/libcloud.lua` — forward-declare `scheduleReconnect`, `pcall` the -`websocketAsync` open, log + reschedule on failure, no duplicate noise while reconnecting. - -### Step 5: Package versions - -Bump owning packages and mirror in `packages/index.json`: - -- `trapos-core` (`apis/eventloop.lua`) -- `trapos-boot` (`startup/boot.lua`) -- `trapos-cloud` (`apis/libcloud.lua`) - -Do not bump the `trapos` meta-package unless release convention requires it. - -## Verification - -Targeted iteration: - -```sh -just trapos-exec 'shell.run("/programs/runtest.lua", "--pretty", "/tests/eventloop.lua", "/tests/cloud.lua", "/tests/cloud-program.lua")' -``` - -Required after Lua/package edits: - -```sh -just check -just test --pretty -``` - -Manual validation on the **same disposable computer used in Step 0** (so before/after is comparable): - -1. Deploy updated `trapos-core`, `trapos-boot`, `trapos-cloud`; reboot. -2. `cloud login --force`, enter a wrong password → retry message prints **and the prompt returns**. -3. Cancel or complete login, then up-arrow → shell history still contains the command. -4. Enter the correct password → `logged in`. -5. `cloud status` → daemon recovered after the verify bounce. - -Tie it back to Step 0: the symptom recorded there (silent vs. traceback) must be gone, and the fix -that addresses *that* cause must be the one demonstrated, not just "all three are in and it passes." - -## Recommended split (three reviewable chunks) - -1. Event-loop same-loop stop + cross-instance id safety. -2. Boot-loop protected dispatch. -3. Cloud reconnect crash-safety. diff --git a/.plans/cloud-login-session-crash-merged-fix-plan.md b/.plans/cloud-login-session-crash-merged-fix-plan.md deleted file mode 100644 index 2efc7f3..0000000 --- a/.plans/cloud-login-session-crash-merged-fix-plan.md +++ /dev/null @@ -1,190 +0,0 @@ -# Merged Plan: fix interactive `cloud login` session teardown - -## Goal - -Fix the user-visible bug where interactive `cloud login` with a wrong password prints the retry -message, then drops back to a fresh CraftOS shell with lost in-memory command history instead of -prompting again. - -This should be handled as one end-to-end fix plan, split into small independently testable changes. - -## Current Assessment - -The interactive `cloud login` branch itself is already covered and appears logically correct: - -- wrong password returns an unauthorized verdict; -- the program queues `login-restore`; -- it prints `invalid password, try again (ctrl+t to cancel)`; -- it loops back to `readPassword()`. - -The observed shell/history loss points to the surrounding runtime being torn down, not to the login -loop simply returning. - -There are two plausible and concrete runtime failure mechanisms in the current code. Either one can -explain the same user-visible symptom, and both are worth fixing because both affect boot-session -stability. - -## Bugs / Fix Areas - -### 1. Event-loop stop event collision - -`packages/trapos-core/apis/eventloop.lua` allocates loop IDs from a module-local counter and stops a -running loop by queueing a synthetic global event named `@libeventloop/END_OF_LOOP/`. - -If `/apis/eventloop.lua` is loaded as independent module instances, two loops can both use ID `1`. -The short-lived verifier loop created by `cloud login` can then queue an end event that also matches -the boot event loop. Since `startup/boot.lua` runs shell + boot loop under `parallel.waitForAny`, a -boot-loop exit kills the shell coroutine and loses shell history. - -Fix: - -- Allocate event loop IDs from a `_G`-backed process-global counter. -- Avoid queueing a global synthetic stop event when `stopLoop()` is called from inside the same - event loop's handler or timeout. Use an internal `stopRequested` flag and break after the current - dispatch. -- Keep queued synthetic stop events for external stop calls, where the loop may need to wake from - `os.pullEventRaw()`. - -### 2. Boot event-loop handler errors can tear down the session - -`eventloop.lua` currently dispatches handlers directly. A thrown handler escapes `runLoop()`. The -boot event loop is run directly inside `parallel.waitForAny(shellFn, eventLoopFn)`, so an unhandled -server/daemon error can terminate the TrapOS startup session and drop the user into a fresh shell. - -Fix: - -- Add protected handler dispatch as an explicit event-loop option, for example - `createEventLoop({ onError = function(eventName, err) ... end })`. -- Use protected dispatch for `_G.bootEventLoop` in `packages/trapos-boot/startup/boot.lua`. -- Keep the default unprotected for short-lived program-local loops so development/test failures still - fail loudly unless explicitly isolated. - -### 3. Cloud daemon websocket open can throw during reconnect - -`packages/trapos-cloud/apis/libcloud.lua` calls `httpLike.websocketAsync(url)` without `pcall`. -During wrong-password verification the daemon rapidly reconnects with the candidate secret, receives -an unauthorized response, restores the persisted secret, and reconnects again. If `websocketAsync` -throws during that churn, the error can currently escape through the boot event loop. - -Fix: - -- Forward-declare `scheduleReconnect` so `connect()` can call it. -- Wrap `httpLike.websocketAsync(url)` in `pcall`. -- On failure, log a warning and schedule a normal reconnect instead of throwing. - -## Out Of Scope For This Fix - -### Stale hello `messageId` matching - -Matching hello responses by the active hello `messageId` is useful cloud-login hardening. It can -prevent stale hello responses from an older reconnect from being attributed to the current candidate. - -However, this does not directly explain shell/session teardown. Treat it as a follow-up unless the -main fixes still leave wrong verdict attribution in manual or probe testing. - -### Persisted shell history - -Do not add persisted shell history as a workaround. The correct fix is to stop tearing down the -TrapOS session. - -## Implementation Steps - -### Step 1: Add regression coverage - -`packages/trapos-core/tests/eventloop.lua`: - -- Add a test that simulates independent module loads of `/apis/eventloop.lua`. -- Create one boot-like loop and one verifier-like loop from separate module instances. -- Stop the verifier-like loop from inside its handler. -- Assert the boot-like loop still handles a later probe event. -- Explicitly stop the boot-like loop at the end so the test cannot hang. - -`packages/trapos-core/tests/eventloop.lua`: - -- Add a protected-dispatch test where one handler throws but a sibling or later handler/event still - runs. -- Assert the configured error sink receives the error. - -`packages/trapos-cloud/tests/cloud.lua`: - -- Add a daemon test where `http.websocketAsync` throws during `startSession()` or a reconnect. -- Assert the error does not propagate. -- Assert a reconnect timer is scheduled. - -Keep existing `packages/trapos-cloud/tests/cloud-program.lua` retry tests. They already prove the -program-level wrong-then-right interactive branch. - -### Step 2: Fix `eventloop.lua` - -Change `packages/trapos-core/apis/eventloop.lua`: - -- Replace module-local event-loop ID allocation with a `_G` counter. -- Add same-loop `stopRequested` handling. -- Add optional protected dispatch support for handlers and timeout callbacks if practical. At minimum, - protect regular event handlers used by boot servers. -- Preserve existing `api.STOP` unregister behavior on successful handler return. -- Preserve existing error contracts for duplicate registration and stopping an already stopped loop. - -### Step 3: Use protected boot event loop - -Change `packages/trapos-boot/startup/boot.lua`: - -- Construct `_G.bootEventLoop` with an error handler. -- Error handler should report the failure using `printError` if available, otherwise `print`. -- The boot loop should continue after a server handler error. - -### Step 4: Harden cloud reconnect - -Change `packages/trapos-cloud/apis/libcloud.lua`: - -- Forward-declare `scheduleReconnect` before `connect()`. -- Wrap `httpLike.websocketAsync(url)` with `pcall`. -- On failure, log `websocket open failed` and call `scheduleReconnect()`. -- Avoid duplicate noisy logs while already in reconnect-loop mode. - -### Step 5: Package versions - -Bump owning package versions and mirror them in `packages/index.json`: - -- `trapos-core` for `apis/eventloop.lua`. -- `trapos-boot` for `startup/boot.lua`. -- `trapos-cloud` for `apis/libcloud.lua`. - -Do not bump the full `trapos` meta-package unless repository release convention requires it for this -change set. - -## Verification - -Targeted iteration: - -```sh -just trapos-exec 'shell.run("/programs/runtest.lua", "--pretty", "/tests/eventloop.lua", "/tests/cloud.lua", "/tests/cloud-program.lua")' -``` - -Required after Lua/package edits: - -```sh -just check -just test --pretty -``` - -Manual validation on a disposable TrapOS computer: - -1. Deploy updated `trapos-core`, `trapos-boot`, and `trapos-cloud`. -2. Reboot so the cloud daemon and boot loop use the updated code. -3. Run `cloud login --force`. -4. Enter a known wrong password. -5. Confirm the retry message is printed and the password prompt returns. -6. Cancel or complete the login, then use up-arrow to confirm shell history still contains the command. -7. Enter the correct password and confirm `logged in`. -8. Run `cloud status` to confirm the daemon recovered normally. - -## Recommended Split - -Keep this as one merged fix plan, but implement it in three reviewable chunks: - -1. Event-loop stop collision and same-loop stop semantics. -2. Boot-loop protected dispatch. -3. Cloud reconnect crash-safety. - -This split makes each failure mode testable while still fixing the single user-facing bug end to end.