chore: cleanup plans
This commit is contained in:
parent
9bc1ce711d
commit
364df77368
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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`.
|
|
||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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).
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
Loading…
Reference in New Issue
Block a user