chore: cleanup plans

This commit is contained in:
Guillaume ARM 2026-06-18 20:23:50 +02:00
parent 9bc1ce711d
commit 364df77368
14 changed files with 0 additions and 2451 deletions

View File

@ -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.

View File

@ -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 <wrong>` 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.

View File

@ -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 <password>`) 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 <wrong>` 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.

View File

@ -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 <wrong>` 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`.

View File

@ -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`.

View File

@ -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.

View File

@ -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/<pkg>/...`. 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`.

View File

@ -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 = '<diskMount>/.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.

View File

@ -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 `<disk>/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 '<lua creating a periphemu drive + calling
createInstallerDisk().create()>'` 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.

View File

@ -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 `<Code>:<Meta>;<Payload>` (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.

View File

@ -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/<id>`.
- 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/<id>` 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.

View File

@ -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).

View File

@ -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/<id>`
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.

View File

@ -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/<id>`.
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.