refactor: remove old mcp-bridge
This commit is contained in:
parent
b7420d07d8
commit
2d13242f29
@ -12,8 +12,8 @@ permission:
|
|||||||
bash:
|
bash:
|
||||||
"*": ask
|
"*": ask
|
||||||
"just check": allow
|
"just check": allow
|
||||||
computercraft-mcp-bridge_probe-computers: allow
|
trapos-cloud-gateway-mcp_probe-computers: allow
|
||||||
computercraft-mcp-bridge_exec-lua: allow
|
trapos-cloud-gateway-mcp_exec-lua: allow
|
||||||
---
|
---
|
||||||
|
|
||||||
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.
|
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.
|
||||||
|
|||||||
173
.plans/remove-mcp-bridge-plan.md
Normal file
173
.plans/remove-mcp-bridge-plan.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# 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/trap-sandbox` and `.mcp.json` already points at
|
||||||
|
the sandbox 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/trap-sandbox` 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/trap-sandbox`; the proxy
|
||||||
|
behavior is intentionally removed.
|
||||||
|
- Keep `tools/trap-sandbox/test/` and `tools/trap-sandbox/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/trap-sandbox`:
|
||||||
|
- `npm-install`
|
||||||
|
- `npm-build`
|
||||||
|
- `npm-check`
|
||||||
|
- `npm-test`
|
||||||
|
- `npm-test-integration`
|
||||||
|
|
||||||
|
### `just/install.just`
|
||||||
|
|
||||||
|
- Change `clean` to clean `tools/trap-sandbox` 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
|
||||||
|
sandbox 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-sandbox` 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-sandbox-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/trap-sandbox`.
|
||||||
|
|
||||||
|
### 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-sandbox-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/trap-sandbox`.
|
||||||
@ -12,7 +12,7 @@ This installs local Git hooks, checks required tools, generates `.env`, and veri
|
|||||||
|
|
||||||
## Cleaning
|
## Cleaning
|
||||||
|
|
||||||
- `just clean` — clears the `mcp-bridge` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
|
- `just clean` — clears the `trap-sandbox` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
|
||||||
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
|
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
|
||||||
|
|
||||||
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.
|
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.
|
||||||
|
|||||||
@ -10,9 +10,9 @@ Accepted
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The repository now contains `tools/mcp-bridge`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md).
|
The repository contains `tools/trap-sandbox`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md). (This convention was first established for `tools/mcp-bridge`, which has since been removed; `trap-sandbox` is now the JS tool it governs.)
|
||||||
|
|
||||||
The bridge also needs future end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
|
The tool also needs end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
@ -22,12 +22,12 @@ Keep the Node package scripts simple and one-purpose:
|
|||||||
- `npm run test` runs the Node unit tests directly from TypeScript with `tsx --test test/*.test.ts`. No prior build is required.
|
- `npm run test` runs the Node unit tests directly from TypeScript with `tsx --test test/*.test.ts`. No prior build is required.
|
||||||
- `npm run check` runs ESLint. TypeScript compilation is covered by `npm run build` and `npm run test:ci` to avoid duplicate compiler runs in repository CI.
|
- `npm run check` runs ESLint. TypeScript compilation is covered by `npm run build` and `npm run test:ci` to avoid duplicate compiler runs in repository CI.
|
||||||
- `npm run test:ci` runs `npm run check && npm run build && npm run test`, so type errors and lint failures both surface even though `test` itself no longer compiles.
|
- `npm run test:ci` runs `npm run check && npm run build && npm run test`, so type errors and lint failures both surface even though `test` itself no longer compiles.
|
||||||
- `npm run test-integration` runs the bridge-to-CraftOS integration suite with `tsx --test --test-concurrency=1 test-integration/*.test.ts`. Each case boots the bridge in-process on fixed loopback ports (`127.0.0.1:2000` for MCP HTTP, `127.0.0.1:2001` for the CraftOS link), spawns a CraftOS-PC headless computer that connects back, exercises `tools/call probe-computers`, and tears everything down. `--test-concurrency=1` keeps the fixed ports collision-free.
|
- `npm run test:integration` runs the host-to-CraftOS integration suite with `tsx --test --test-concurrency=1 test-integration/*.test.ts`. Each case boots the gateway in-process on an ephemeral loopback port, spawns a CraftOS-PC headless computer that connects back over the gateway WebSocket, drives MCP tool calls through the Fastify app (`inject`, no separate listener), and tears everything down.
|
||||||
- These fixed integration-test ports are loopback-only and unrelated to the public production ports documented in [`../public-ports.md`](../public-ports.md).
|
- These integration-test ports are loopback-only and unrelated to the public production ports documented in [`../public-ports.md`](../public-ports.md).
|
||||||
|
|
||||||
Expose matching repository recipes for the Node lifecycle:
|
Expose matching repository recipes for the Node lifecycle:
|
||||||
|
|
||||||
- `just build` delegates to `npm run build` for the bridge.
|
- `just build` delegates to `npm run build` for the sandbox tool.
|
||||||
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
|
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
|
||||||
- `just check` includes `npm run check` alongside Lua and Markdown checks.
|
- `just check` includes `npm run check` alongside Lua and Markdown checks.
|
||||||
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
|
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
|
||||||
@ -39,7 +39,7 @@ Expose matching repository recipes for the Node lifecycle:
|
|||||||
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
|
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
|
||||||
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
|
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
|
||||||
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
|
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
|
||||||
- Integration tests live in `tools/mcp-bridge/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
|
- Integration tests live in `tools/trap-sandbox/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
|
||||||
|
|
||||||
## Future Work
|
## Future Work
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,9 @@
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-sandbox-gateway.md) — the
|
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-sandbox-gateway.md) — the
|
||||||
MCP tools now ride the sandbox gateway and address computers by `traposId`. The execution
|
MCP tools now ride the sandbox gateway and address computers by `traposId`, and
|
||||||
semantics and danger model below still apply.
|
`tools/mcp-bridge` (referenced below in the past tense) has since been removed. The
|
||||||
|
execution semantics and danger model below still apply.
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
|
|||||||
@ -51,9 +51,11 @@ Move the MCP features onto the sandbox gateway and retire the dedicated MCP tran
|
|||||||
`ok:false` result payload (preserving captured `output`), matching the old bridge's
|
`ok:false` result payload (preserving captured `output`), matching the old bridge's
|
||||||
semantics; only a dropped connection or timeout is a transport-level failure.
|
semantics; only a dropped connection or timeout is a transport-level failure.
|
||||||
|
|
||||||
`tools/mcp-bridge` is **not** deleted: its opencode HTTP-over-WS proxy is still used by the
|
`tools/mcp-bridge` has since been **removed** entirely. Its MCP server and CC-link parts
|
||||||
in-game `ai` bridge mode (`apis/libhttpws`, `apis/libai`). Only the MCP server and CC-link
|
were superseded here; its opencode HTTP-over-WS proxy (and the in-game `ai` WebSocket bridge
|
||||||
parts are superseded here; the proxy migration is deferred.
|
mode it backed, `apis/libhttpws`) was intentionally dropped rather than migrated. The `ai`
|
||||||
|
client now talks direct HTTP/HTTPS to `opencode serve` (`opencc.server_url`); if a proxy is
|
||||||
|
needed again it will be re-implemented from scratch. The old code remains in git history.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
@ -68,5 +70,6 @@ parts are superseded here; the proxy migration is deferred.
|
|||||||
|
|
||||||
## Future Work
|
## Future Work
|
||||||
|
|
||||||
- Migrate the opencode HTTP proxy off `mcp-bridge` so the tool can be fully retired.
|
- Re-implement an opencode proxy / WebSocket transport for the `ai` client if the
|
||||||
|
ComputerCraft ~60s HTTP cap becomes a problem again (the old `mcp-bridge` proxy was dropped).
|
||||||
- Per-computer exec policy if the gateway is ever shared beyond trusted development.
|
- Per-computer exec policy if the gateway is ever shared beyond trusted development.
|
||||||
|
|||||||
@ -5,7 +5,7 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
|
|||||||
| Port | Service | Notes |
|
| Port | Service | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
|
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
|
||||||
| `4243` | ComputerCraft bridge WebSocket | Production/public equivalent of the local bridge link default `3001`. Use `CC_LINK_PORT=4243` for production bridge deployments. |
|
| `4243` | Reserved | Formerly the ComputerCraft bridge WebSocket (removed with `tools/mcp-bridge`). Free for a future service. |
|
||||||
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
|
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
|
||||||
| `4444` | trap-sandbox gateway | Fastify service (`tools/trap-sandbox`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `SANDBOX_PORT`; serve with `just sandbox-serve`. |
|
| `4444` | trap-sandbox gateway | Fastify service (`tools/trap-sandbox`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `SANDBOX_PORT`; serve with `just sandbox-serve`. |
|
||||||
|
|
||||||
@ -13,7 +13,5 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
|
|||||||
|
|
||||||
- Local/dev opencode: `http://127.0.0.1:4096`.
|
- Local/dev opencode: `http://127.0.0.1:4096`.
|
||||||
- Public/production opencode: `http://<public-host>:4242`.
|
- Public/production opencode: `http://<public-host>:4242`.
|
||||||
- Local/dev ComputerCraft bridge link: `ws://<host>:3001`.
|
|
||||||
- Public/production ComputerCraft bridge link: `ws://<public-host>:4243`.
|
|
||||||
|
|
||||||
Production services exposed on public ports should use the normal deployment controls for the host: authentication where supported, firewall rules, and TLS or a reverse proxy when crossing untrusted networks.
|
Production services exposed on public ports should use the normal deployment controls for the host: authentication where supported, firewall rules, and TLS or a reverse proxy when crossing untrusted networks.
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
# Install local development tooling.
|
# Install local development tooling.
|
||||||
install: install-git-hooks check-install npm-install generate-env
|
install: install-git-hooks check-install npm-install generate-env
|
||||||
|
|
||||||
# Remove build caches (tsc / eslint) for the mcp-bridge tool.
|
# Remove build caches (tsc / eslint) for the trap-sandbox tool.
|
||||||
clean:
|
clean:
|
||||||
npm run clean --prefix tools/mcp-bridge
|
npm run clean --prefix tools/trap-sandbox
|
||||||
|
|
||||||
# Remove generated local environment files (e.g. .env with tokens).
|
# Remove generated local environment files (e.g. .env with tokens).
|
||||||
clean-env:
|
clean-env:
|
||||||
|
|||||||
@ -1,46 +1,30 @@
|
|||||||
# Install Node dependencies for repository tools.
|
# Install Node dependencies for repository tools.
|
||||||
npm-install: npm-install-mcp-bridge npm-install-trap-sandbox
|
npm-install: npm-install-trap-sandbox
|
||||||
|
|
||||||
# Install Node dependencies for the MCP bridge.
|
|
||||||
npm-install-mcp-bridge:
|
|
||||||
npm install --prefix tools/mcp-bridge
|
|
||||||
|
|
||||||
# Install Node dependencies for trap-sandbox.
|
# Install Node dependencies for trap-sandbox.
|
||||||
npm-install-trap-sandbox:
|
npm-install-trap-sandbox:
|
||||||
npm install --prefix tools/trap-sandbox/
|
npm install --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Build Node-based repository tools.
|
# Build Node-based repository tools.
|
||||||
npm-build: npm-build-mcp-bridge npm-build-trap-sandbox
|
npm-build: npm-build-trap-sandbox
|
||||||
|
|
||||||
npm-build-mcp-bridge:
|
|
||||||
npm run build --prefix tools/mcp-bridge
|
|
||||||
|
|
||||||
npm-build-trap-sandbox:
|
npm-build-trap-sandbox:
|
||||||
npm run build --prefix tools/trap-sandbox/
|
npm run build --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Check Node-based repository tools.
|
# Check Node-based repository tools.
|
||||||
npm-check: npm-check-mcp-bridge npm-check-trap-sandbox
|
npm-check: npm-check-trap-sandbox
|
||||||
|
|
||||||
npm-check-mcp-bridge:
|
|
||||||
npm run check --prefix tools/mcp-bridge
|
|
||||||
|
|
||||||
npm-check-trap-sandbox:
|
npm-check-trap-sandbox:
|
||||||
npm run check --prefix tools/trap-sandbox/
|
npm run check --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Run Node-based tool tests.
|
# Run Node-based tool tests.
|
||||||
npm-test: npm-test-mcp-bridge npm-test-trap-sandbox
|
npm-test: npm-test-trap-sandbox
|
||||||
|
|
||||||
npm-test-mcp-bridge:
|
|
||||||
npm test --prefix tools/mcp-bridge
|
|
||||||
|
|
||||||
npm-test-trap-sandbox:
|
npm-test-trap-sandbox:
|
||||||
npm test --prefix tools/trap-sandbox/
|
npm test --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Run Node-based tool integration tests.
|
# Run Node-based tool integration tests.
|
||||||
npm-test-integration: stage npm-test-integration-mcp-bridge npm-test-integration-trap-sandbox
|
npm-test-integration: stage npm-test-integration-trap-sandbox
|
||||||
|
|
||||||
npm-test-integration-mcp-bridge:
|
|
||||||
npm run test:integration --prefix tools/mcp-bridge
|
|
||||||
|
|
||||||
npm-test-integration-trap-sandbox:
|
npm-test-integration-trap-sandbox:
|
||||||
npm run test:integration --prefix tools/trap-sandbox/
|
npm run test:integration --prefix tools/trap-sandbox/
|
||||||
|
|||||||
@ -8,7 +8,7 @@ ci *args: check-craftos check check-packages npm-build npm-test
|
|||||||
test *args: build npm-test
|
test *args: build npm-test
|
||||||
@just _craftos-test {{args}}
|
@just _craftos-test {{args}}
|
||||||
|
|
||||||
# Run end-to-end tests that span the MCP bridge and headless CraftOS-PC.
|
# Run end-to-end tests that span the trap-sandbox gateway / MCP endpoint and headless CraftOS-PC.
|
||||||
e2e: npm-test-integration
|
e2e: npm-test-integration
|
||||||
|
|
||||||
# Run integration and harness tests that are too broad for unit test targets.
|
# Run integration and harness tests that are too broad for unit test targets.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
"trapos-boot": "0.4.2",
|
"trapos-boot": "0.4.2",
|
||||||
"trapos-net": "0.3.2",
|
"trapos-net": "0.3.2",
|
||||||
"trapos-ui": "0.2.4",
|
"trapos-ui": "0.2.4",
|
||||||
"trapos-ai": "0.7.2",
|
"trapos-ai": "0.8.0",
|
||||||
"trapos-sandbox": "0.3.5",
|
"trapos-sandbox": "0.3.5",
|
||||||
"trapos-sandbox-legacy": "0.3.2",
|
"trapos-sandbox-legacy": "0.3.2",
|
||||||
"trapos": "0.11.5"
|
"trapos": "0.11.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,21 +2,13 @@ local PING_PROMPT = 'reply with exactly: pong';
|
|||||||
|
|
||||||
local DEFAULT_TIMEOUT_SECONDS = 60;
|
local DEFAULT_TIMEOUT_SECONDS = 60;
|
||||||
local MAX_TIMEOUT_SECONDS = 60;
|
local MAX_TIMEOUT_SECONDS = 60;
|
||||||
local DEFAULT_REQUEST_TIMEOUT_SECONDS = 600;
|
|
||||||
local MAX_REQUEST_TIMEOUT_SECONDS = 600;
|
|
||||||
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
|
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
|
||||||
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
|
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
|
||||||
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
|
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
|
||||||
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
|
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
|
||||||
local DEFAULT_VARIANT_SETTING_KEY = 'opencc.variant';
|
local DEFAULT_VARIANT_SETTING_KEY = 'opencc.variant';
|
||||||
local DEFAULT_BRIDGE_SETTING_KEY = 'opencc.bridge_url';
|
|
||||||
|
|
||||||
local createHttp = require('/apis/libhttp');
|
local createHttp = require('/apis/libhttp');
|
||||||
local createHttpWs = require('/apis/libhttpws');
|
|
||||||
|
|
||||||
local function isWsUrl(url)
|
|
||||||
return type(url) == 'string' and string.match(url, '^wss?://') ~= nil;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function isBlank(s)
|
local function isBlank(s)
|
||||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||||
@ -155,34 +147,7 @@ local function createAi(opts)
|
|||||||
local settingsLib = opts.settings or settings;
|
local settingsLib = opts.settings or settings;
|
||||||
local osLib = opts.os or os;
|
local osLib = opts.os or os;
|
||||||
|
|
||||||
local function resolveRequestTimeout()
|
local httpClient = opts.httpClient or createHttp({ http = httpLib });
|
||||||
local raw = settingsLib.get('opencc.request_timeout_seconds');
|
|
||||||
local n = tonumber(raw);
|
|
||||||
if not n or n <= 0 then n = DEFAULT_REQUEST_TIMEOUT_SECONDS; end
|
|
||||||
if n > MAX_REQUEST_TIMEOUT_SECONDS then n = MAX_REQUEST_TIMEOUT_SECONDS; end
|
|
||||||
return n;
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A ws:// bridge routes opencode calls through the mcp-bridge proxy, escaping
|
|
||||||
-- ComputerCraft's hard ~60s http timeout. Falls back to direct http otherwise.
|
|
||||||
local bridgeUrl = opts.bridgeUrl or settingsLib.get(DEFAULT_BRIDGE_SETTING_KEY);
|
|
||||||
if isBlank(bridgeUrl) and isWsUrl(settingsLib.get('opencc.server_url')) then
|
|
||||||
bridgeUrl = settingsLib.get('opencc.server_url');
|
|
||||||
end
|
|
||||||
local bridgeActive = not isBlank(bridgeUrl);
|
|
||||||
|
|
||||||
local httpClient = opts.httpClient;
|
|
||||||
if not httpClient then
|
|
||||||
if bridgeActive then
|
|
||||||
httpClient = createHttpWs({
|
|
||||||
http = httpLib,
|
|
||||||
bridgeUrl = bridgeUrl,
|
|
||||||
receiveTimeout = resolveRequestTimeout(),
|
|
||||||
});
|
|
||||||
else
|
|
||||||
httpClient = createHttp({ http = httpLib });
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local api = {};
|
local api = {};
|
||||||
|
|
||||||
@ -233,12 +198,8 @@ local function createAi(opts)
|
|||||||
|
|
||||||
local function resolveConfig(options)
|
local function resolveConfig(options)
|
||||||
local url = options.serverUrl or settingsLib.get('opencc.server_url');
|
local url = options.serverUrl or settingsLib.get('opencc.server_url');
|
||||||
-- In bridge mode the proxy holds the real opencode URL, so server_url is optional.
|
|
||||||
if isBlank(url) then
|
if isBlank(url) then
|
||||||
if not bridgeActive then
|
return nil, 'missing opencc.server_url; run: set opencc.server_url <url>';
|
||||||
return nil, 'missing opencc.server_url; run: set opencc.server_url <url>';
|
|
||||||
end
|
|
||||||
url = '';
|
|
||||||
end
|
end
|
||||||
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
|
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
|
||||||
local password = options.password or settingsLib.get('opencc.password') or '';
|
local password = options.password or settingsLib.get('opencc.password') or '';
|
||||||
@ -439,8 +400,7 @@ local function createAi(opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Synchronous /message only: opencode's prompt_async loops the model on some
|
-- Synchronous /message only: opencode's prompt_async loops the model on some
|
||||||
-- builds and strands sessions as busy. The bridge ws transport removes the
|
-- builds and strands sessions as busy.
|
||||||
-- ~60s http cap that previously made blocking impractical.
|
|
||||||
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
|
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
-- WebSocket transport for the opencode bridge proxy.
|
|
||||||
--
|
|
||||||
-- Implements the same client surface as libhttp (getJson/postJson plus the
|
|
||||||
-- trimTrailingSlash/queryString helpers libai calls), but performs each call as
|
|
||||||
-- a blocking websocket round-trip to the mcp-bridge opencode proxy instead of a
|
|
||||||
-- direct http.get/post. ComputerCraft's http API caps requests at ~60s; a
|
|
||||||
-- websocket round-trip has no such cap, so the bridge can run a synchronous
|
|
||||||
-- opencode request server-side for as long as it needs.
|
|
||||||
|
|
||||||
local DEFAULT_RECEIVE_TIMEOUT_SECONDS = 600;
|
|
||||||
|
|
||||||
local function isBlank(s)
|
|
||||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function trimTrailingSlash(s)
|
|
||||||
return (s:gsub('/+$', ''));
|
|
||||||
end
|
|
||||||
|
|
||||||
local function urlEncode(s)
|
|
||||||
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
|
|
||||||
return string.format('%%%02X', string.byte(c));
|
|
||||||
end));
|
|
||||||
end
|
|
||||||
|
|
||||||
local function queryString(params)
|
|
||||||
local parts = {};
|
|
||||||
for _, item in ipairs(params) do
|
|
||||||
if not isBlank(item[2]) then
|
|
||||||
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #parts == 0 then return ''; end
|
|
||||||
return '?' .. table.concat(parts, '&');
|
|
||||||
end
|
|
||||||
|
|
||||||
local function createHttpWs(opts)
|
|
||||||
opts = opts or {};
|
|
||||||
local httpLib = opts.http or http;
|
|
||||||
local textutilsLib = opts.textutils or textutils;
|
|
||||||
local bridgeUrl = opts.bridgeUrl;
|
|
||||||
local receiveTimeout = tonumber(opts.receiveTimeout) or DEFAULT_RECEIVE_TIMEOUT_SECONDS;
|
|
||||||
|
|
||||||
local api = {
|
|
||||||
trimTrailingSlash = trimTrailingSlash,
|
|
||||||
urlEncode = urlEncode,
|
|
||||||
queryString = queryString,
|
|
||||||
};
|
|
||||||
|
|
||||||
local activeWs = nil;
|
|
||||||
|
|
||||||
local function ensureSocket()
|
|
||||||
if activeWs then return activeWs, nil; end
|
|
||||||
if isBlank(bridgeUrl) then
|
|
||||||
return nil, 'missing opencc.bridge_url';
|
|
||||||
end
|
|
||||||
local ws, err = httpLib.websocket(bridgeUrl);
|
|
||||||
if not ws then
|
|
||||||
return nil, 'bridge unreachable: ' .. tostring(err);
|
|
||||||
end
|
|
||||||
activeWs = ws;
|
|
||||||
return ws, nil;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function closeSocket()
|
|
||||||
if activeWs then
|
|
||||||
pcall(function() activeWs.close(); end);
|
|
||||||
activeWs = nil;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local idCounter = 0;
|
|
||||||
local function nextId()
|
|
||||||
idCounter = idCounter + 1;
|
|
||||||
local stamp = os.epoch and os.epoch('utc') or os.clock();
|
|
||||||
return 'req_' .. tostring(stamp) .. '_' .. tostring(idCounter) .. '_' .. tostring(math.random(100000, 999999));
|
|
||||||
end
|
|
||||||
|
|
||||||
local function trySend(ws, text)
|
|
||||||
return pcall(function() ws.send(text); end);
|
|
||||||
end
|
|
||||||
|
|
||||||
local function roundtrip(method, path, payload)
|
|
||||||
local ws, err = ensureSocket();
|
|
||||||
if not ws then return nil, err; end
|
|
||||||
|
|
||||||
local id = nextId();
|
|
||||||
local frame = { type = 'http', id = id, method = method, path = path };
|
|
||||||
if payload ~= nil then
|
|
||||||
frame.body = textutilsLib.serializeJSON(payload);
|
|
||||||
end
|
|
||||||
local text = textutilsLib.serializeJSON(frame);
|
|
||||||
|
|
||||||
if not trySend(ws, text) then
|
|
||||||
-- Socket went away; drop it and try one fresh reconnect.
|
|
||||||
closeSocket();
|
|
||||||
ws, err = ensureSocket();
|
|
||||||
if not ws then return nil, err; end
|
|
||||||
if not trySend(ws, text) then
|
|
||||||
closeSocket();
|
|
||||||
return nil, 'bridge send failed';
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
while true do
|
|
||||||
local ok, message = pcall(function() return ws.receive(receiveTimeout); end);
|
|
||||||
if not ok then
|
|
||||||
closeSocket();
|
|
||||||
return nil, 'bridge connection closed';
|
|
||||||
end
|
|
||||||
if message == nil then
|
|
||||||
return nil, 'timeout waiting for bridge response';
|
|
||||||
end
|
|
||||||
local decoded = textutilsLib.unserializeJSON(message);
|
|
||||||
if type(decoded) == 'table' and decoded.type == 'http-response' and decoded.id == id then
|
|
||||||
if decoded.status == nil or decoded.status == 0 then
|
|
||||||
return nil, decoded.error or 'bridge error';
|
|
||||||
end
|
|
||||||
return decoded.body, decoded.status;
|
|
||||||
end
|
|
||||||
-- Ignore frames that don't correlate (stale/other ids) and keep waiting.
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function api.getJson(_cfg, path)
|
|
||||||
return roundtrip('GET', path, nil);
|
|
||||||
end
|
|
||||||
|
|
||||||
function api.postJson(_cfg, path, payload)
|
|
||||||
return roundtrip('POST', path, payload);
|
|
||||||
end
|
|
||||||
|
|
||||||
function api.close()
|
|
||||||
closeSocket();
|
|
||||||
end
|
|
||||||
|
|
||||||
return api;
|
|
||||||
end
|
|
||||||
|
|
||||||
return createHttpWs;
|
|
||||||
@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-ai",
|
"name": "trapos-ai",
|
||||||
"version": "0.7.2",
|
"version": "0.8.0",
|
||||||
"description": "TrapOS AI client for opencode serve",
|
"description": "TrapOS AI client for opencode serve",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
"apis/libai.lua",
|
"apis/libai.lua",
|
||||||
"apis/libhttp.lua",
|
"apis/libhttp.lua",
|
||||||
"apis/libhttpws.lua",
|
|
||||||
"apis/libtrapgpt.lua",
|
"apis/libtrapgpt.lua",
|
||||||
"programs/ai.lua",
|
"programs/ai.lua",
|
||||||
"programs/trapgpt.lua"
|
"programs/trapgpt.lua"
|
||||||
|
|||||||
@ -41,21 +41,19 @@ local function printUsage()
|
|||||||
print(' ai --version');
|
print(' ai --version');
|
||||||
print(' ai --help');
|
print(' ai --help');
|
||||||
print();
|
print();
|
||||||
print('settings required (one of):');
|
print('settings required:');
|
||||||
print(' opencc.server_url (direct http opencode URL)');
|
print(' opencc.server_url (http/https opencode serve URL)');
|
||||||
print(' opencc.bridge_url (ws:// mcp-bridge proxy; bypasses CC 60s http cap)');
|
|
||||||
print();
|
print();
|
||||||
print('settings optional:');
|
print('settings optional:');
|
||||||
print(' opencc.username (default: opencode; direct mode only)');
|
print(' opencc.username (default: opencode)');
|
||||||
print(' opencc.password (Basic Auth password; direct mode only)');
|
print(' opencc.password (Basic Auth password)');
|
||||||
print(' opencc.session_id (auto-managed)');
|
print(' opencc.session_id (auto-managed)');
|
||||||
print(' opencc.directory (optional session list scope)');
|
print(' opencc.directory (optional session list scope)');
|
||||||
print(' opencc.agent (e.g. atm10-expert)');
|
print(' opencc.agent (e.g. atm10-expert)');
|
||||||
print(' opencc.variant (e.g. low)');
|
print(' opencc.variant (e.g. low)');
|
||||||
print(' opencc.provider_id (e.g. anthropic)');
|
print(' opencc.provider_id (e.g. anthropic)');
|
||||||
print(' opencc.model_id (e.g. claude-opus-4-7)');
|
print(' opencc.model_id (e.g. claude-opus-4-7)');
|
||||||
print(' opencc.timeout_seconds (per HTTP call, max 60; direct mode)');
|
print(' opencc.timeout_seconds (per HTTP call, max 60)');
|
||||||
print(' opencc.request_timeout_seconds (ws reply wait, default/max: 600)');
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function printAiLog(message)
|
local function printAiLog(message)
|
||||||
|
|||||||
@ -67,59 +67,6 @@ local function fakeHttp(postResults, getResults)
|
|||||||
};
|
};
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Fake CC http library exposing websocket(), for the bridge ws transport.
|
|
||||||
-- wsResults: list of { status=, body=, error= } returned per round-trip in order.
|
|
||||||
-- A nil entry simulates ws.receive timing out. opts.connectFail makes
|
|
||||||
-- http.websocket return (nil, err).
|
|
||||||
local function fakeWsHttp(wsResults, opts)
|
|
||||||
wsResults = wsResults or {};
|
|
||||||
opts = opts or {};
|
|
||||||
local sent = {};
|
|
||||||
local receiveTimeouts = {};
|
|
||||||
local idx = 0;
|
|
||||||
local lastId = nil;
|
|
||||||
local closed = false;
|
|
||||||
local connectUrl = nil;
|
|
||||||
|
|
||||||
local ws = {
|
|
||||||
send = function(text)
|
|
||||||
local frame = textutils.unserializeJSON(text);
|
|
||||||
sent[#sent + 1] = frame;
|
|
||||||
lastId = frame and frame.id;
|
|
||||||
end,
|
|
||||||
receive = function(timeout)
|
|
||||||
receiveTimeouts[#receiveTimeouts + 1] = timeout;
|
|
||||||
idx = idx + 1;
|
|
||||||
local r = wsResults[idx];
|
|
||||||
if r == nil then return nil; end
|
|
||||||
return textutils.serializeJSON({
|
|
||||||
type = 'http-response',
|
|
||||||
id = lastId,
|
|
||||||
status = r.status,
|
|
||||||
body = r.body,
|
|
||||||
error = r.error,
|
|
||||||
});
|
|
||||||
end,
|
|
||||||
close = function() closed = true; end,
|
|
||||||
};
|
|
||||||
|
|
||||||
local httpLib = {
|
|
||||||
websocket = function(url)
|
|
||||||
connectUrl = url;
|
|
||||||
if opts.connectFail then return nil, 'refused'; end
|
|
||||||
return ws;
|
|
||||||
end,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
http = httpLib,
|
|
||||||
sent = sent,
|
|
||||||
isClosed = function() return closed; end,
|
|
||||||
connectUrl = function() return connectUrl; end,
|
|
||||||
lastReceiveTimeout = function() return receiveTimeouts[#receiveTimeouts]; end,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
local function httpError(code, body)
|
local function httpError(code, body)
|
||||||
return function()
|
return function()
|
||||||
return nil, 'HTTP response code ' .. tostring(code), response(code, body);
|
return nil, 'HTTP response code ' .. tostring(code), response(code, body);
|
||||||
@ -137,21 +84,6 @@ local function messageResp(reply)
|
|||||||
}));
|
}));
|
||||||
end
|
end
|
||||||
|
|
||||||
local function wsResult(status, body)
|
|
||||||
return { status = status, body = body };
|
|
||||||
end
|
|
||||||
|
|
||||||
local function wsSessionResult(id)
|
|
||||||
return wsResult(200, textutils.serializeJSON({ id = id, title = 'cc-ai' }));
|
|
||||||
end
|
|
||||||
|
|
||||||
local function wsMessageResult(reply)
|
|
||||||
return wsResult(200, textutils.serializeJSON({
|
|
||||||
info = { time = { completed = 1 } },
|
|
||||||
parts = { { type = 'text', text = reply } },
|
|
||||||
}));
|
|
||||||
end
|
|
||||||
|
|
||||||
local function postedText(call)
|
local function postedText(call)
|
||||||
local body = textutils.unserializeJSON(call.body);
|
local body = textutils.unserializeJSON(call.body);
|
||||||
return body.parts[1].text;
|
return body.parts[1].text;
|
||||||
@ -937,152 +869,6 @@ testlib.test('ask omits Authorization header when no password', function()
|
|||||||
testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil);
|
testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
-- ask over the bridge ws transport --
|
|
||||||
|
|
||||||
testlib.test('ask over bridge uses synchronous message endpoint', function()
|
|
||||||
local ws = fakeWsHttp({
|
|
||||||
wsSessionResult('ses_ws'),
|
|
||||||
wsMessageResult('reply'),
|
|
||||||
});
|
|
||||||
local settingsStub = fakeSettings({ ['opencc.bridge_url'] = 'ws://bridge' });
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok, result = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(ok, tostring(result));
|
|
||||||
testlib.assertEquals(result.reply, 'reply');
|
|
||||||
testlib.assertEquals(result.sessionId, 'ses_ws');
|
|
||||||
testlib.assertEquals(ws.connectUrl(), 'ws://bridge');
|
|
||||||
testlib.assertEquals(#ws.sent, 2);
|
|
||||||
testlib.assertEquals(ws.sent[1].method, 'POST');
|
|
||||||
testlib.assertEquals(ws.sent[1].path, '/session');
|
|
||||||
testlib.assertEquals(ws.sent[2].method, 'POST');
|
|
||||||
testlib.assertEquals(ws.sent[2].path, '/session/ses_ws/message');
|
|
||||||
testlib.assertTrue(string.find(ws.sent[2].path, 'prompt_async', 1, true) == nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask uses bridge when server_url has ws scheme', function()
|
|
||||||
local ws = fakeWsHttp({
|
|
||||||
wsSessionResult('ses_ws'),
|
|
||||||
wsMessageResult('reply'),
|
|
||||||
});
|
|
||||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'ws://bridgehost' });
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok, result = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(ok, tostring(result));
|
|
||||||
testlib.assertEquals(result.reply, 'reply');
|
|
||||||
testlib.assertEquals(ws.connectUrl(), 'ws://bridgehost');
|
|
||||||
testlib.assertTrue(#ws.sent >= 1);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask over bridge sends body as serialized json', function()
|
|
||||||
local ws = fakeWsHttp({ wsMessageResult('reply') });
|
|
||||||
local settingsStub = fakeSettings({
|
|
||||||
['opencc.bridge_url'] = 'ws://bridge',
|
|
||||||
['opencc.session_id'] = 'ses_1',
|
|
||||||
['opencc.agent'] = 'atm10-expert',
|
|
||||||
});
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub, os = fakeOs(5, 'turtle') });
|
|
||||||
|
|
||||||
local ok = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(ok);
|
|
||||||
testlib.assertEquals(#ws.sent, 1);
|
|
||||||
testlib.assertEquals(ws.sent[1].path, '/session/ses_1/message');
|
|
||||||
local body = textutils.unserializeJSON(ws.sent[1].body);
|
|
||||||
testlib.assertEquals(body.agent, 'atm10-expert');
|
|
||||||
testlib.assertTrue(string.find(body.parts[1].text, 'computer id: 5', 1, true) ~= nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask over bridge surfaces a transport error frame', function()
|
|
||||||
local ws = fakeWsHttp({ { status = 0, error = 'fetch failed: ECONNREFUSED' } });
|
|
||||||
local settingsStub = fakeSettings({
|
|
||||||
['opencc.bridge_url'] = 'ws://bridge',
|
|
||||||
['opencc.session_id'] = 'ses_1',
|
|
||||||
});
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok, err = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(not ok);
|
|
||||||
testlib.assertTrue(string.find(err, 'fetch failed', 1, true) ~= nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask over bridge times out when no reply', function()
|
|
||||||
local ws = fakeWsHttp({});
|
|
||||||
local settingsStub = fakeSettings({
|
|
||||||
['opencc.bridge_url'] = 'ws://bridge',
|
|
||||||
['opencc.session_id'] = 'ses_1',
|
|
||||||
});
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok, err = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(not ok);
|
|
||||||
testlib.assertTrue(string.find(err, 'timeout', 1, true) ~= nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask over bridge maps a 404 frame to missing session', function()
|
|
||||||
local ws = fakeWsHttp({ wsResult(404, '{}') });
|
|
||||||
local settingsStub = fakeSettings({
|
|
||||||
['opencc.bridge_url'] = 'ws://bridge',
|
|
||||||
['opencc.session_id'] = 'ses_stale',
|
|
||||||
});
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok, err = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(not ok);
|
|
||||||
testlib.assertTrue(string.find(err, 'session introuvable', 1, true) ~= nil);
|
|
||||||
testlib.assertEquals(settingsStub.values['opencc.session_id'], nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask over bridge fails when websocket connect fails', function()
|
|
||||||
local ws = fakeWsHttp({}, { connectFail = true });
|
|
||||||
local settingsStub = fakeSettings({
|
|
||||||
['opencc.bridge_url'] = 'ws://bridge',
|
|
||||||
['opencc.session_id'] = 'ses_1',
|
|
||||||
});
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok, err = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(not ok);
|
|
||||||
testlib.assertTrue(string.find(err, 'bridge unreachable', 1, true) ~= nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask over bridge uses request_timeout_seconds for ws receive', function()
|
|
||||||
local ws = fakeWsHttp({ wsMessageResult('reply') });
|
|
||||||
local settingsStub = fakeSettings({
|
|
||||||
['opencc.bridge_url'] = 'ws://bridge',
|
|
||||||
['opencc.session_id'] = 'ses_1',
|
|
||||||
['opencc.request_timeout_seconds'] = 120,
|
|
||||||
});
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(ok);
|
|
||||||
testlib.assertEquals(ws.lastReceiveTimeout(), 120);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('ask over bridge caps request_timeout_seconds at ten minutes', function()
|
|
||||||
local ws = fakeWsHttp({ wsMessageResult('reply') });
|
|
||||||
local settingsStub = fakeSettings({
|
|
||||||
['opencc.bridge_url'] = 'ws://bridge',
|
|
||||||
['opencc.session_id'] = 'ses_1',
|
|
||||||
['opencc.request_timeout_seconds'] = 5000,
|
|
||||||
});
|
|
||||||
local ai = createAi({ http = ws.http, settings = settingsStub });
|
|
||||||
|
|
||||||
local ok = ai.ask('hello');
|
|
||||||
|
|
||||||
testlib.assertTrue(ok);
|
|
||||||
testlib.assertEquals(ws.lastReceiveTimeout(), 600);
|
|
||||||
end);
|
|
||||||
|
|
||||||
-- lua executor --
|
-- lua executor --
|
||||||
|
|
||||||
testlib.test('lua executor captures print write and returned values', function()
|
testlib.test('lua executor captures print write and returned values', function()
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trapos-boot",
|
"trapos-boot",
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
# mcp-bridge (deprecated, except the opencode proxy)
|
|
||||||
|
|
||||||
> **Deprecated.** The MCP server (`probe-computers` / `exec-lua` / `write-file`) and the
|
|
||||||
> CC-link WebSocket server have been migrated to `tools/trap-sandbox`, which serves the
|
|
||||||
> same tools over the sandbox gateway and addresses computers by `traposId`. See
|
|
||||||
> [ADR-0019](../../docs/adrs/adr-0019-mcp-over-sandbox-gateway.md).
|
|
||||||
|
|
||||||
This tool is kept alive only for its **opencode HTTP-over-WS proxy**, still used by the
|
|
||||||
in-game `ai` bridge mode (`apis/libhttpws.lua`, `apis/libai.lua`, `programs/ai.lua`) to
|
|
||||||
bypass ComputerCraft's ~60s HTTP cap. Once that proxy is migrated onto the gateway, this
|
|
||||||
tool can be removed entirely.
|
|
||||||
|
|
||||||
Do not add new MCP features here — add them to `tools/trap-sandbox/src/modules/mcp`.
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import globals from "globals";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ["dist/**"] },
|
|
||||||
js.configs.recommended,
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.node,
|
|
||||||
parserOptions: {
|
|
||||||
projectService: {
|
|
||||||
allowDefaultProject: ["eslint.config.js"],
|
|
||||||
},
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["test/**/*.ts", "test-integration/**/*.ts"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-floating-promises": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
1833
tools/mcp-bridge/package-lock.json
generated
1833
tools/mcp-bridge/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mcp-bridge",
|
|
||||||
"version": "0.3.1",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"eslint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
|
|
||||||
"clean": "rm -rf node_modules/.cache/tsc node_modules/.cache/eslint",
|
|
||||||
"check": "npm run eslint",
|
|
||||||
"test:all": "npm run check && npm run build && npm run test",
|
|
||||||
"test:ci": "npm run test:all && npm run test:integration",
|
|
||||||
"dev": "tsx watch src/index.ts",
|
|
||||||
"start": "npm run build && node dist/src/index.js",
|
|
||||||
"test": "tsx --test test/*.test.ts",
|
|
||||||
"test:integration": "tsx --test --test-concurrency=1 test-integration/*.test.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"ws": "^8.17.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^10.0.1",
|
|
||||||
"@types/node": "^20.14.10",
|
|
||||||
"@types/ws": "^8.5.10",
|
|
||||||
"eslint": "^10.4.1",
|
|
||||||
"globals": "^17.6.0",
|
|
||||||
"tsx": "^4.16.2",
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"typescript-eslint": "^8.61.0"
|
|
||||||
},
|
|
||||||
"allowScripts": {
|
|
||||||
"esbuild@0.28.0": true,
|
|
||||||
"fsevents@2.3.3": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { LinkRegistry, startLinkServer } from "./link-server.js";
|
|
||||||
import { startMcpServer } from "./mcp-server.js";
|
|
||||||
import { startOpencodeProxy } from "./opencode-proxy.js";
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
mcpHost: process.env.MCP_HOST ?? "127.0.0.1",
|
|
||||||
mcpPort: readPort(process.env.MCP_PORT, 3000),
|
|
||||||
ccLinkHost: process.env.CC_LINK_HOST ?? "0.0.0.0",
|
|
||||||
ccLinkPort: readPort(process.env.CC_LINK_PORT, 3001),
|
|
||||||
probeTimeoutMs: readPort(process.env.CC_PROBE_TIMEOUT_MS, 2000),
|
|
||||||
opencodeProxyHost: process.env.OPENCODE_PROXY_HOST ?? "0.0.0.0",
|
|
||||||
opencodeProxyPort: readPort(process.env.OPENCODE_PROXY_PORT, 3002),
|
|
||||||
opencodeUrl: process.env.OPENCODE_URL,
|
|
||||||
opencodeUsername: process.env.OPENCODE_USERNAME,
|
|
||||||
opencodePassword: process.env.OPENCODE_PASSWORD,
|
|
||||||
};
|
|
||||||
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
startMcpServer({ host: config.mcpHost, port: config.mcpPort, probeTimeoutMs: config.probeTimeoutMs, registry });
|
|
||||||
startLinkServer({ host: config.ccLinkHost, port: config.ccLinkPort, registry });
|
|
||||||
|
|
||||||
console.log(`MCP bridge listening on http://${config.mcpHost}:${config.mcpPort}`);
|
|
||||||
console.log(`ComputerCraft link listening on ws://${config.ccLinkHost}:${config.ccLinkPort}`);
|
|
||||||
|
|
||||||
if (config.opencodeUrl) {
|
|
||||||
startOpencodeProxy({
|
|
||||||
host: config.opencodeProxyHost,
|
|
||||||
port: config.opencodeProxyPort,
|
|
||||||
opencodeUrl: config.opencodeUrl,
|
|
||||||
username: config.opencodeUsername,
|
|
||||||
password: config.opencodePassword,
|
|
||||||
});
|
|
||||||
console.log(`opencode proxy listening on ws://${config.opencodeProxyHost}:${config.opencodeProxyPort} -> ${config.opencodeUrl}`);
|
|
||||||
} else {
|
|
||||||
console.log("opencode proxy disabled (set OPENCODE_URL to enable)");
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPort(value: string | undefined, fallback: number): number {
|
|
||||||
if (!value) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
||||||
}
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { WebSocketServer, type WebSocket } from "ws";
|
|
||||||
import { formatComputer, parseJsonFrame, parseLinkMessage, type ResponseMessage } from "./protocol.js";
|
|
||||||
|
|
||||||
export type ComputerConnection = {
|
|
||||||
computerId: number;
|
|
||||||
label: string | null;
|
|
||||||
ws: WebSocket;
|
|
||||||
connectedAt: number;
|
|
||||||
lastSeenAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PendingRequest = {
|
|
||||||
computerId: number;
|
|
||||||
resolve: (message: ResponseMessage) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class LinkRegistry {
|
|
||||||
private readonly computers = new Map<number, ComputerConnection>();
|
|
||||||
private readonly pending = new Map<string, PendingRequest>();
|
|
||||||
|
|
||||||
register(connection: ComputerConnection): void {
|
|
||||||
const existing = this.computers.get(connection.computerId);
|
|
||||||
if (existing && existing.ws !== connection.ws) {
|
|
||||||
existing.ws.close();
|
|
||||||
}
|
|
||||||
this.computers.set(connection.computerId, connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
unregister(ws: WebSocket): void {
|
|
||||||
for (const [computerId, connection] of this.computers) {
|
|
||||||
if (connection.ws === ws) {
|
|
||||||
this.computers.delete(computerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
count(): number {
|
|
||||||
return this.computers.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot(): ComputerConnection[] {
|
|
||||||
return [...this.computers.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFrame(ws: WebSocket, data: unknown): void {
|
|
||||||
const message = parseLinkMessage(parseJsonFrame(data));
|
|
||||||
if (!message) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === "hello") {
|
|
||||||
const now = Date.now();
|
|
||||||
this.register({
|
|
||||||
computerId: message.computerId,
|
|
||||||
label: message.computerLabel,
|
|
||||||
ws,
|
|
||||||
connectedAt: now,
|
|
||||||
lastSeenAt: now,
|
|
||||||
});
|
|
||||||
ws.send(JSON.stringify({ type: "hello-ok" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = this.pending.get(message.id);
|
|
||||||
if (!pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = this.computers.get(pending.computerId);
|
|
||||||
if (connection) {
|
|
||||||
connection.lastSeenAt = Date.now();
|
|
||||||
}
|
|
||||||
this.pending.delete(message.id);
|
|
||||||
pending.resolve(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async probeComputers(timeoutMs: number): Promise<string> {
|
|
||||||
const computers = this.snapshot();
|
|
||||||
if (computers.length === 0) {
|
|
||||||
return "No computers connected.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = await Promise.all(computers.map((computer) => this.probeComputer(computer, timeoutMs)));
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async execLua(computerId: number, code: string, timeoutMs: number): Promise<string> {
|
|
||||||
const computer = this.computers.get(computerId);
|
|
||||||
if (!computer) {
|
|
||||||
return `No computer with id ${computerId} connected.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.requestComputer(computer, "exec-lua", { code }, timeoutMs);
|
|
||||||
return formatExecutionResult(computer, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async runFile(computerId: number, path: string, args: string[], timeoutMs: number): Promise<string> {
|
|
||||||
const computer = this.computers.get(computerId);
|
|
||||||
if (!computer) {
|
|
||||||
return `No computer with id ${computerId} connected.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.requestComputer(computer, "run-file", { path, args }, timeoutMs);
|
|
||||||
return formatExecutionResult(computer, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeFile(computerId: number, path: string, content: string, timeoutMs: number): Promise<string> {
|
|
||||||
const computer = this.computers.get(computerId);
|
|
||||||
if (!computer) {
|
|
||||||
return `No computer with id ${computerId} connected.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.requestComputer(computer, "write-file", { path, content }, timeoutMs);
|
|
||||||
return formatWriteFileResult(computer, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async probeComputer(computer: ComputerConnection, timeoutMs: number): Promise<string> {
|
|
||||||
const result = await this.requestComputer(computer, "ping", undefined, timeoutMs);
|
|
||||||
if (!result) {
|
|
||||||
return `timeout from ${formatComputer(computer.computerId, computer.label)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.ok && typeof result.result === "string") {
|
|
||||||
return result.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `error from ${formatComputer(computer.computerId, computer.label)}: ${result.error ?? "unknown error"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestComputer(
|
|
||||||
computer: ComputerConnection,
|
|
||||||
method: string,
|
|
||||||
params: Record<string, unknown> | undefined,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<ResponseMessage | null> {
|
|
||||||
const id = randomUUID();
|
|
||||||
const response = new Promise<ResponseMessage>((resolve) => {
|
|
||||||
this.pending.set(id, { computerId: computer.computerId, resolve });
|
|
||||||
computer.ws.send(JSON.stringify(params ? { type: "request", id, method, params } : { type: "request", id, method }));
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeout = new Promise<null>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.pending.delete(id);
|
|
||||||
resolve(null);
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.race([response, timeout]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExecutionResult(computer: ComputerConnection, response: ResponseMessage | null): string {
|
|
||||||
const computerText = formatComputer(computer.computerId, computer.label);
|
|
||||||
if (!response) {
|
|
||||||
return `computer: ${computerText}\nok: false\nerror: timeout`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = isRecord(response.result) ? response.result : {};
|
|
||||||
const output = typeof payload.output === "string" ? payload.output : "";
|
|
||||||
const ok = response.ok && payload.ok !== false;
|
|
||||||
const lines = [`computer: ${computerText}`, `ok: ${ok ? "true" : "false"}`];
|
|
||||||
|
|
||||||
if (ok) {
|
|
||||||
lines.push(`returns: ${JSON.stringify(Array.isArray(payload.returns) ? payload.returns : [])}`);
|
|
||||||
} else {
|
|
||||||
lines.push(`error: ${typeof payload.error === "string" ? payload.error : response.error ?? "unknown error"}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("output:");
|
|
||||||
if (output !== "") {
|
|
||||||
lines.push(output.endsWith("\n") ? output.slice(0, -1) : output);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWriteFileResult(computer: ComputerConnection, response: ResponseMessage | null): string {
|
|
||||||
const computerText = formatComputer(computer.computerId, computer.label);
|
|
||||||
if (!response) {
|
|
||||||
return `computer: ${computerText}\nok: false\nerror: timeout`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = isRecord(response.result) ? response.result : {};
|
|
||||||
const ok = response.ok && payload.ok !== false;
|
|
||||||
const lines = [`computer: ${computerText}`, `ok: ${ok ? "true" : "false"}`];
|
|
||||||
|
|
||||||
if (ok) {
|
|
||||||
lines.push(`path: ${typeof payload.path === "string" ? payload.path : ""}`);
|
|
||||||
lines.push(`bytes: ${typeof payload.bytes === "number" ? payload.bytes : 0}`);
|
|
||||||
} else {
|
|
||||||
lines.push(`error: ${typeof payload.error === "string" ? payload.error : response.error ?? "unknown error"}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startLinkServer(options: { host: string; port: number; registry: LinkRegistry }): WebSocketServer {
|
|
||||||
const server = new WebSocketServer({ host: options.host, port: options.port });
|
|
||||||
|
|
||||||
server.on("connection", (ws) => {
|
|
||||||
ws.on("message", (data) => options.registry.handleFrame(ws, data));
|
|
||||||
ws.on("close", () => options.registry.unregister(ws));
|
|
||||||
ws.on("error", () => options.registry.unregister(ws));
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
@ -1,276 +0,0 @@
|
|||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
||||||
import type { LinkRegistry } from "./link-server.js";
|
|
||||||
|
|
||||||
type JsonRpcRequest = {
|
|
||||||
jsonrpc?: unknown;
|
|
||||||
id?: unknown;
|
|
||||||
method?: unknown;
|
|
||||||
params?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function startMcpServer(options: {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
probeTimeoutMs: number;
|
|
||||||
registry: LinkRegistry;
|
|
||||||
}): Server {
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
void handleRequest(req, res, options.registry, options.probeTimeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(options.port, options.host);
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleMcpRequest(body: unknown, registry: LinkRegistry, probeTimeoutMs: number): Promise<unknown> {
|
|
||||||
if (Array.isArray(body)) {
|
|
||||||
return Promise.all(body.map((item) => handleSingleRequest(item as JsonRpcRequest, registry, probeTimeoutMs)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleSingleRequest(body as JsonRpcRequest, registry, probeTimeoutMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRequest(
|
|
||||||
req: IncomingMessage,
|
|
||||||
res: ServerResponse,
|
|
||||||
registry: LinkRegistry,
|
|
||||||
probeTimeoutMs: number,
|
|
||||||
): Promise<void> {
|
|
||||||
if (req.method === "GET" && req.url === "/health") {
|
|
||||||
writeJson(res, 200, { ok: true, computers: registry.count() });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
writeJson(res, 404, { error: "not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = await readBody(req);
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
body = JSON.parse(raw) as unknown;
|
|
||||||
} catch {
|
|
||||||
writeJson(res, 400, jsonRpcError(null, -32700, "Parse error"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJson(res, 200, await handleMcpRequest(body, registry, probeTimeoutMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegistry, probeTimeoutMs: number): Promise<unknown> {
|
|
||||||
const id = request && "id" in request ? request.id : null;
|
|
||||||
if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
|
|
||||||
return jsonRpcError(id, -32600, "Invalid Request");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method === "initialize") {
|
|
||||||
return jsonRpcResult(id, {
|
|
||||||
protocolVersion: "2024-11-05",
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
serverInfo: { name: "mcp-bridge", version: "0.3.1" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method === "notifications/initialized") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method === "tools/list") {
|
|
||||||
return jsonRpcResult(id, {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
name: "probe-computers",
|
|
||||||
description: "Probe all linked ComputerCraft computers.",
|
|
||||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exec-lua",
|
|
||||||
description: "Execute Lua code on a linked ComputerCraft computer.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to execute on." },
|
|
||||||
code: { type: "string", description: "Lua source code to execute." },
|
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
|
||||||
},
|
|
||||||
required: ["computerId", "code"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write-file",
|
|
||||||
description: "Write file content on a linked ComputerCraft computer, overwriting any existing file.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to write on." },
|
|
||||||
path: { type: "string", description: "Target path on the ComputerCraft computer." },
|
|
||||||
content: { type: "string", description: "File content to write. Empty strings are allowed." },
|
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
|
||||||
},
|
|
||||||
required: ["computerId", "path", "content"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "run-file",
|
|
||||||
description: "Run a Lua file on a linked ComputerCraft computer with captured print/write output.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
|
|
||||||
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
|
|
||||||
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
|
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
|
||||||
},
|
|
||||||
required: ["computerId", "path"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method === "tools/call") {
|
|
||||||
const params = isRecord(request.params) ? request.params : {};
|
|
||||||
if (params.name === "probe-computers") {
|
|
||||||
const text = await registry.probeComputers(probeTimeoutMs);
|
|
||||||
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.name === "exec-lua") {
|
|
||||||
const args = isRecord(params.arguments) ? params.arguments : {};
|
|
||||||
const parsed = parseExecLuaArgs(args, probeTimeoutMs);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
return jsonRpcError(id, -32602, parsed.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await registry.execLua(parsed.computerId, parsed.code, parsed.timeoutMs);
|
|
||||||
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.name === "write-file") {
|
|
||||||
const args = isRecord(params.arguments) ? params.arguments : {};
|
|
||||||
const parsed = parseWriteFileArgs(args, probeTimeoutMs);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
return jsonRpcError(id, -32602, parsed.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await registry.writeFile(parsed.computerId, parsed.path, parsed.content, parsed.timeoutMs);
|
|
||||||
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.name === "run-file") {
|
|
||||||
const args = isRecord(params.arguments) ? params.arguments : {};
|
|
||||||
const parsed = parseRunFileArgs(args, probeTimeoutMs);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
return jsonRpcError(id, -32602, parsed.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await registry.runFile(parsed.computerId, parsed.path, parsed.args, parsed.timeoutMs);
|
|
||||||
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonRpcError(id, -32602, "Unknown tool");
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonRpcError(id, -32601, "Method not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseExecLuaArgs(
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
defaultTimeoutMs: number,
|
|
||||||
): { ok: true; computerId: number; code: string; timeoutMs: number } | { ok: false; error: string } {
|
|
||||||
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
|
|
||||||
return { ok: false, error: "computerId must be a finite number" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof args.code !== "string" || args.code.trim() === "") {
|
|
||||||
return { ok: false, error: "code must be a non-empty string" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
||||||
return { ok: false, error: "timeoutMs must be a positive finite number" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
|
|
||||||
return { ok: true, computerId: args.computerId, code: args.code, timeoutMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWriteFileArgs(
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
defaultTimeoutMs: number,
|
|
||||||
): { ok: true; computerId: number; path: string; content: string; timeoutMs: number } | { ok: false; error: string } {
|
|
||||||
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
|
|
||||||
return { ok: false, error: "computerId must be a finite number" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof args.path !== "string" || args.path.trim() === "") {
|
|
||||||
return { ok: false, error: "path must be a non-empty string" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof args.content !== "string") {
|
|
||||||
return { ok: false, error: "content must be a string" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
||||||
return { ok: false, error: "timeoutMs must be a positive finite number" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
|
|
||||||
return { ok: true, computerId: args.computerId, path: args.path, content: args.content, timeoutMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRunFileArgs(
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
defaultTimeoutMs: number,
|
|
||||||
): { ok: true; computerId: number; path: string; args: string[]; timeoutMs: number } | { ok: false; error: string } {
|
|
||||||
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
|
|
||||||
return { ok: false, error: "computerId must be a finite number" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof args.path !== "string" || args.path.trim() === "") {
|
|
||||||
return { ok: false, error: "path must be a non-empty string" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.args !== undefined && !isStringArray(args.args)) {
|
|
||||||
return { ok: false, error: "args must be an array of strings" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
||||||
return { ok: false, error: "timeoutMs must be a positive finite number" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
|
|
||||||
return { ok: true, computerId: args.computerId, path: args.path, args: args.args ?? [], timeoutMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStringArray(value: unknown): value is string[] {
|
|
||||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonRpcResult(id: unknown, result: unknown): unknown {
|
|
||||||
return { jsonrpc: "2.0", id, result };
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonRpcError(id: unknown, code: number, message: string): unknown {
|
|
||||||
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJson(res: ServerResponse, statusCode: number, body: unknown): void {
|
|
||||||
res.writeHead(statusCode, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readBody(req: IncomingMessage): Promise<string> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of req) {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
||||||
}
|
|
||||||
return Buffer.concat(chunks).toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { WebSocketServer, type WebSocket } from "ws";
|
|
||||||
import { parseHttpRequestFrame, parseJsonFrame, type HttpResponseFrame } from "./protocol.js";
|
|
||||||
|
|
||||||
export type OpencodeProxyOptions = {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
opencodeUrl: string;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
maxRequestMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_MAX_REQUEST_MS = 600_000;
|
|
||||||
|
|
||||||
// WebSocket endpoint where the ComputerCraft client is the requester and the
|
|
||||||
// bridge is the responder: it performs the opencode http call server-side (no
|
|
||||||
// short timeout) and returns the raw response. This lets the in-game client use
|
|
||||||
// opencode's synchronous /message endpoint without hitting CC's ~60s http cap.
|
|
||||||
export function startOpencodeProxy(options: OpencodeProxyOptions): WebSocketServer {
|
|
||||||
const baseUrl = options.opencodeUrl.replace(/\/+$/, "");
|
|
||||||
const maxRequestMs = options.maxRequestMs ?? DEFAULT_MAX_REQUEST_MS;
|
|
||||||
const authHeader = options.password
|
|
||||||
? "Basic " + Buffer.from(`${options.username ?? "opencode"}:${options.password}`).toString("base64")
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const server = new WebSocketServer({ host: options.host, port: options.port });
|
|
||||||
|
|
||||||
server.on("connection", (ws) => {
|
|
||||||
ws.on("message", (data) => {
|
|
||||||
void handleFrame(ws, data);
|
|
||||||
});
|
|
||||||
ws.on("error", () => {
|
|
||||||
/* connection-level errors just drop the socket; nothing to clean up */
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleFrame(ws: WebSocket, data: unknown): Promise<void> {
|
|
||||||
const frame = parseHttpRequestFrame(parseJsonFrame(data));
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
};
|
|
||||||
if (authHeader) {
|
|
||||||
headers.Authorization = authHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(baseUrl + frame.path, {
|
|
||||||
method: frame.method,
|
|
||||||
headers,
|
|
||||||
body: frame.method === "POST" ? frame.body : undefined,
|
|
||||||
signal: AbortSignal.timeout(maxRequestMs),
|
|
||||||
});
|
|
||||||
const body = await res.text();
|
|
||||||
send(ws, { type: "http-response", id: frame.id, status: res.status, body });
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
send(ws, { type: "http-response", id: frame.id, status: 0, error: message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(ws: WebSocket, frame: HttpResponseFrame): void {
|
|
||||||
if (ws.readyState === ws.OPEN) {
|
|
||||||
ws.send(JSON.stringify(frame));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
export type HelloMessage = {
|
|
||||||
type: "hello";
|
|
||||||
computerId: number;
|
|
||||||
computerLabel: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResponseMessage = {
|
|
||||||
type: "response";
|
|
||||||
id: string;
|
|
||||||
ok: boolean;
|
|
||||||
result?: unknown;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LinkMessage = HelloMessage | ResponseMessage;
|
|
||||||
|
|
||||||
export type HttpRequestFrame = {
|
|
||||||
type: "http";
|
|
||||||
id: string;
|
|
||||||
method: "GET" | "POST";
|
|
||||||
path: string;
|
|
||||||
body?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HttpResponseFrame = {
|
|
||||||
type: "http-response";
|
|
||||||
id: string;
|
|
||||||
status: number;
|
|
||||||
body?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseHttpRequestFrame(value: unknown): HttpRequestFrame | null {
|
|
||||||
if (!isRecord(value) || value.type !== "http") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value.id !== "string" || value.id === "") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.method !== "GET" && value.method !== "POST") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value.path !== "string" || !value.path.startsWith("/")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.body !== undefined && typeof value.body !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "http",
|
|
||||||
id: value.id,
|
|
||||||
method: value.method,
|
|
||||||
path: value.path,
|
|
||||||
body: typeof value.body === "string" ? value.body : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJsonFrame(data: unknown): unknown {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
return parseJson(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(data)) {
|
|
||||||
return parseJson(data.toString("utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data instanceof ArrayBuffer) {
|
|
||||||
return parseJson(Buffer.from(data).toString("utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return parseJson(Buffer.concat(data).toString("utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseLinkMessage(value: unknown): LinkMessage | null {
|
|
||||||
if (!isRecord(value) || typeof value.type !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.type === "hello") {
|
|
||||||
if (typeof value.computerId !== "number" || !Number.isFinite(value.computerId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = typeof value.computerLabel === "string" && value.computerLabel.trim() !== ""
|
|
||||||
? value.computerLabel
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "hello",
|
|
||||||
computerId: value.computerId,
|
|
||||||
computerLabel: label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.type === "response") {
|
|
||||||
if (typeof value.id !== "string" || typeof value.ok !== "boolean") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "response",
|
|
||||||
id: value.id,
|
|
||||||
ok: value.ok,
|
|
||||||
result: value.result,
|
|
||||||
error: typeof value.error === "string" ? value.error : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatComputer(computerId: number, label: string | null): string {
|
|
||||||
return `${computerId} (Label: ${label ?? "null"})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJson(text: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
||||||
import type { WebSocketServer } from "ws";
|
|
||||||
import { formatFailure, startCraftos } from "./harness.js";
|
|
||||||
import { startOpencodeProxy } from "../src/opencode-proxy.js";
|
|
||||||
|
|
||||||
type RequestLog = {
|
|
||||||
method: string;
|
|
||||||
path: string;
|
|
||||||
body?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
test("ai CLI commands run through the opencode bridge proxy", async () => {
|
|
||||||
const requests: RequestLog[] = [];
|
|
||||||
let nextSession = 1;
|
|
||||||
|
|
||||||
const opencode = createServer((req, res) => {
|
|
||||||
void handleOpencodeRequest(req, res, requests, () => {
|
|
||||||
const id = `ses_${nextSession}`;
|
|
||||||
nextSession += 1;
|
|
||||||
return id;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => opencode.listen(0, "127.0.0.1", resolve));
|
|
||||||
const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`;
|
|
||||||
|
|
||||||
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl });
|
|
||||||
await waitForListening(proxy);
|
|
||||||
const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`;
|
|
||||||
|
|
||||||
const craftos = startCraftos("ai-cli-check.lua", {
|
|
||||||
mountRepo: true,
|
|
||||||
shellArgs: [proxyUrl],
|
|
||||||
timeoutMs: 20_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await craftos.done;
|
|
||||||
const output = result.output;
|
|
||||||
assert.equal(result.status, 0, formatFailure("expected craftos to exit cleanly", output));
|
|
||||||
assert.match(output, /ses_existing {2}Existing title/, formatFailure("expected sessions output", output));
|
|
||||||
assert.match(output, /\bpong\b/, formatFailure("expected ping reply", output));
|
|
||||||
assert.match(output, /new reply/, formatFailure("expected ai new reply", output));
|
|
||||||
assert.match(output, /plain reply/, formatFailure("expected plain ai reply", output));
|
|
||||||
assert.match(output, /SESSION_AFTER_PING=ses_1/, formatFailure("expected ping session to persist", output));
|
|
||||||
assert.match(output, /SESSION_AFTER_NEW=ses_2/, formatFailure("expected ai new to replace session", output));
|
|
||||||
assert.match(output, /SESSION_AFTER_ASK=ses_2/, formatFailure("expected plain ai to reuse new session", output));
|
|
||||||
|
|
||||||
assert.deepEqual(requests.map((r) => `${r.method} ${r.path}`), [
|
|
||||||
"GET /session",
|
|
||||||
"POST /session",
|
|
||||||
"POST /session/ses_1/message",
|
|
||||||
"POST /session",
|
|
||||||
"POST /session/ses_2/message",
|
|
||||||
"POST /session/ses_2/message",
|
|
||||||
]);
|
|
||||||
assert.match(promptText(requests[2].body), /reply with exactly: pong/);
|
|
||||||
assert.match(promptText(requests[4].body), /User prompt:\nfresh start/);
|
|
||||||
assert.match(promptText(requests[5].body), /User prompt:\ncontinue please/);
|
|
||||||
} finally {
|
|
||||||
craftos.abort();
|
|
||||||
await craftos.done.catch(() => undefined);
|
|
||||||
await closeWss(proxy);
|
|
||||||
await new Promise<void>((resolve) => opencode.close(() => resolve()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleOpencodeRequest(
|
|
||||||
req: IncomingMessage,
|
|
||||||
res: ServerResponse,
|
|
||||||
requests: RequestLog[],
|
|
||||||
createSessionId: () => string,
|
|
||||||
): Promise<void> {
|
|
||||||
const bodyText = await readBody(req);
|
|
||||||
const body: unknown = bodyText === "" ? undefined : JSON.parse(bodyText) as unknown;
|
|
||||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
||||||
requests.push({ method: req.method ?? "GET", path: url.pathname + url.search, body });
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/session") {
|
|
||||||
respondJson(res, [
|
|
||||||
{ id: "ses_existing", title: "Existing title", time: { updated: 10 } },
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/session") {
|
|
||||||
const id = createSessionId();
|
|
||||||
respondJson(res, { id, title: "cc-ai" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageMatch = url.pathname.match(/^\/session\/([^/]+)\/message$/);
|
|
||||||
if (req.method === "POST" && messageMatch) {
|
|
||||||
respondJson(res, {
|
|
||||||
info: { id: `msg_${requests.length}`, time: { completed: 1 } },
|
|
||||||
parts: [{ type: "text", text: replyForPrompt(promptText(body)) }],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJson(res, { error: "not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
function replyForPrompt(prompt: string): string {
|
|
||||||
if (prompt.includes("reply with exactly: pong")) {
|
|
||||||
return "pong";
|
|
||||||
}
|
|
||||||
if (prompt.includes("fresh start")) {
|
|
||||||
return "new reply";
|
|
||||||
}
|
|
||||||
if (prompt.includes("continue please")) {
|
|
||||||
return "plain reply";
|
|
||||||
}
|
|
||||||
return `unhandled prompt: ${prompt}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptText(body: unknown): string {
|
|
||||||
if (!isObject(body) || !Array.isArray(body.parts)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = body.parts as unknown[];
|
|
||||||
const first = parts[0];
|
|
||||||
if (!isObject(first) || typeof first.text !== "string") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return first.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBody(req: IncomingMessage): Promise<string> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
||||||
req.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondJson(res: ServerResponse, body: unknown, status = 200): void {
|
|
||||||
res.writeHead(status, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
function serverPort(server: Server): number {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("server has no TCP address");
|
|
||||||
}
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wssPort(proxy: WebSocketServer): number {
|
|
||||||
const address = proxy.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("proxy has no TCP address");
|
|
||||||
}
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForListening(proxy: WebSocketServer): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (proxy.address()) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
proxy.once("listening", () => resolve());
|
|
||||||
proxy.once("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeWss(proxy: WebSocketServer): Promise<void> {
|
|
||||||
for (const client of proxy.clients) {
|
|
||||||
client.terminate();
|
|
||||||
}
|
|
||||||
return new Promise<void>((resolve) => proxy.close(() => resolve()));
|
|
||||||
}
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import type { Server } from "node:http";
|
|
||||||
import type { WebSocketServer } from "ws";
|
|
||||||
import { LinkRegistry, startLinkServer } from "../src/link-server.js";
|
|
||||||
import { startMcpServer } from "../src/mcp-server.js";
|
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const LUA_DIR = join(HERE, "lua");
|
|
||||||
const REPO_ROOT = join(HERE, "../../..");
|
|
||||||
|
|
||||||
export type Bridge = {
|
|
||||||
registry: LinkRegistry;
|
|
||||||
mcpUrl: string;
|
|
||||||
linkUrl: string;
|
|
||||||
close: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function startBridge(probeTimeoutMs = 500): Promise<Bridge> {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const mcpServer = startMcpServer({ host: "127.0.0.1", port: 0, probeTimeoutMs, registry });
|
|
||||||
const linkServer = startLinkServer({ host: "127.0.0.1", port: 0, registry });
|
|
||||||
await Promise.all([waitForListening(mcpServer), waitForListening(linkServer)]);
|
|
||||||
return {
|
|
||||||
registry,
|
|
||||||
mcpUrl: `http://127.0.0.1:${serverPort(mcpServer)}`,
|
|
||||||
linkUrl: `ws://127.0.0.1:${serverPort(linkServer)}`,
|
|
||||||
close: () => closeBridge(mcpServer, linkServer),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForComputers(registry: LinkRegistry, count: number, timeoutMs: number): Promise<void> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (registry.count() >= count) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sleep(50);
|
|
||||||
}
|
|
||||||
throw new Error(`waitForComputers timed out (expected ${count}, got ${registry.count()})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function callProbeComputers(mcpUrl: string): Promise<string> {
|
|
||||||
const body = {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "tools/call",
|
|
||||||
params: { name: "probe-computers", arguments: {} },
|
|
||||||
};
|
|
||||||
const response = await fetch(mcpUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const payload = (await response.json()) as {
|
|
||||||
result?: { content?: { type: string; text: string }[] };
|
|
||||||
};
|
|
||||||
const text = payload.result?.content?.[0]?.text;
|
|
||||||
if (typeof text !== "string") {
|
|
||||||
throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function callExecLua(mcpUrl: string, computerId: number, code: string, timeoutMs?: number): Promise<string> {
|
|
||||||
const body = {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "tools/call",
|
|
||||||
params: { name: "exec-lua", arguments: { computerId, code, timeoutMs } },
|
|
||||||
};
|
|
||||||
const response = await fetch(mcpUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const payload = (await response.json()) as {
|
|
||||||
result?: { content?: { type: string; text: string }[] };
|
|
||||||
};
|
|
||||||
const text = payload.result?.content?.[0]?.text;
|
|
||||||
if (typeof text !== "string") {
|
|
||||||
throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function callWriteFile(
|
|
||||||
mcpUrl: string,
|
|
||||||
computerId: number,
|
|
||||||
path: string,
|
|
||||||
content: string,
|
|
||||||
timeoutMs?: number,
|
|
||||||
): Promise<string> {
|
|
||||||
const body = {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "tools/call",
|
|
||||||
params: { name: "write-file", arguments: { computerId, path, content, timeoutMs } },
|
|
||||||
};
|
|
||||||
const response = await fetch(mcpUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const payload = (await response.json()) as {
|
|
||||||
result?: { content?: { type: string; text: string }[] };
|
|
||||||
};
|
|
||||||
const text = payload.result?.content?.[0]?.text;
|
|
||||||
if (typeof text !== "string") {
|
|
||||||
throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CraftosResult = {
|
|
||||||
status: number | null;
|
|
||||||
signal: NodeJS.Signals | null;
|
|
||||||
output: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CraftosHandle = {
|
|
||||||
done: Promise<CraftosResult>;
|
|
||||||
abort: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function startCraftos(
|
|
||||||
luaName: string,
|
|
||||||
opts: { computerId?: number; computerLabel?: string; mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {},
|
|
||||||
): CraftosHandle {
|
|
||||||
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const done = (async (): Promise<CraftosResult> => {
|
|
||||||
const dataDir = await mkdtemp(join(tmpdir(), "mcp-bridge-it-"));
|
|
||||||
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const args: string[] = ["--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`];
|
|
||||||
if (opts.computerId !== undefined) {
|
|
||||||
args.push("--id", String(opts.computerId));
|
|
||||||
}
|
|
||||||
if (opts.mountRepo) {
|
|
||||||
if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {
|
|
||||||
throw new Error(
|
|
||||||
".stage/ is missing — run `just stage` (or use `just test-integration`) before the integration tests.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
args.push(
|
|
||||||
"--mount-ro", `/trapos=${REPO_ROOT}`,
|
|
||||||
"--mount-ro", `/apis=${join(REPO_ROOT, ".stage/apis")}`,
|
|
||||||
"--mount-ro", `/programs=${join(REPO_ROOT, ".stage/programs")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources");
|
|
||||||
}
|
|
||||||
args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel));
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
const child = spawn("craftos", args, { signal: controller.signal });
|
|
||||||
child.stdout.on("data", (d: Buffer) => chunks.push(d));
|
|
||||||
child.stderr.on("data", (d: Buffer) => chunks.push(d));
|
|
||||||
|
|
||||||
const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
|
||||||
child.once("close", (code, signal) => resolve({ status: code, signal }));
|
|
||||||
child.once("error", () => resolve({ status: null, signal: null }));
|
|
||||||
});
|
|
||||||
|
|
||||||
return { status: result.status, signal: result.signal, output: Buffer.concat(chunks).toString("utf8") };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(watchdog);
|
|
||||||
await rm(dataDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return { done, abort: () => controller.abort() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFailure(message: string, craftosOutput: string): string {
|
|
||||||
const lines = ["\x1b[31mFAIL\x1b[0m " + message, "--- craftos output ---", craftosOutput.trimEnd(), "----------------------"];
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string {
|
|
||||||
const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`;
|
|
||||||
const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)];
|
|
||||||
const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `;
|
|
||||||
return `${setup}shell.run(${parts.join(", ")})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function luaQuote(value: string): string {
|
|
||||||
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForListening(server: Server | WebSocketServer): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const addr = "address" in server ? server.address() : null;
|
|
||||||
if (addr) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
server.once("listening", () => resolve());
|
|
||||||
server.once("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function serverPort(server: Server | WebSocketServer): number {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("server does not have a TCP address");
|
|
||||||
}
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeBridge(mcpServer: Server, linkServer: WebSocketServer): Promise<void> {
|
|
||||||
for (const client of linkServer.clients) {
|
|
||||||
client.terminate();
|
|
||||||
}
|
|
||||||
await new Promise<void>((resolve) => linkServer.close(() => resolve()));
|
|
||||||
await new Promise<void>((resolve) => mcpServer.close(() => resolve()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
-- Runs the real /programs/ai.lua CLI against a bridge proxy URL.
|
|
||||||
-- Usage: ai-cli-check <ws-proxy-url>
|
|
||||||
local args = { ... };
|
|
||||||
local url = args[1];
|
|
||||||
|
|
||||||
settings.unset('opencc.server_url');
|
|
||||||
settings.unset('opencc.session_id');
|
|
||||||
settings.set('opencc.bridge_url', url);
|
|
||||||
settings.set('opencc.request_timeout_seconds', 10);
|
|
||||||
settings.save();
|
|
||||||
|
|
||||||
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')));
|
|
||||||
|
|
||||||
os.shutdown();
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
-- Integration-test helper: connect to the mcp-bridge link server, send a hello
|
|
||||||
-- frame, then reply to every `request` with a `pong` response until a deadline
|
|
||||||
-- expires. Args (via shell.run): id, label, urlBase, durationSeconds.
|
|
||||||
local args = {...};
|
|
||||||
|
|
||||||
local id = tonumber(args[1]) or 1;
|
|
||||||
local label = args[2] or "echo";
|
|
||||||
local urlBase = args[3] or "ws://127.0.0.1:2001";
|
|
||||||
local duration = tonumber(args[4]) or 5;
|
|
||||||
|
|
||||||
local url = urlBase .. "/?id=" .. id;
|
|
||||||
local ws, err = http.websocket(url);
|
|
||||||
if not ws then
|
|
||||||
print("websocket failed: " .. tostring(err));
|
|
||||||
os.shutdown();
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
|
|
||||||
ws.send(textutils.serializeJSON({
|
|
||||||
type = "hello",
|
|
||||||
computerId = id,
|
|
||||||
computerLabel = label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
local deadline = os.epoch("utc") + duration * 1000;
|
|
||||||
while os.epoch("utc") < deadline do
|
|
||||||
local msg = ws.receive(0.5);
|
|
||||||
if msg then
|
|
||||||
local frame = textutils.unserializeJSON(msg);
|
|
||||||
if frame and frame.type == "request" then
|
|
||||||
ws.send(textutils.serializeJSON({
|
|
||||||
type = "response",
|
|
||||||
id = frame.id,
|
|
||||||
ok = true,
|
|
||||||
result = "pong from " .. id .. " (Label: " .. label .. ")",
|
|
||||||
}));
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
pcall(function() ws.close(); end);
|
|
||||||
os.shutdown();
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
-- Integration-test helper: open two websocket connections in parallel, each
|
|
||||||
-- presenting itself as a distinct logical computer. Used by probe-multi.
|
|
||||||
local args = {...};
|
|
||||||
local urlBase = args[1] or "ws://127.0.0.1:2001";
|
|
||||||
|
|
||||||
local function connect(id, label, duration)
|
|
||||||
local url = urlBase .. "/?id=" .. id;
|
|
||||||
local ws, err = http.websocket(url);
|
|
||||||
if not ws then
|
|
||||||
print("websocket failed (" .. id .. "): " .. tostring(err));
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
|
|
||||||
ws.send(textutils.serializeJSON({
|
|
||||||
type = "hello",
|
|
||||||
computerId = id,
|
|
||||||
computerLabel = label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
local deadline = os.epoch("utc") + duration * 1000;
|
|
||||||
while os.epoch("utc") < deadline do
|
|
||||||
local msg = ws.receive(0.3);
|
|
||||||
if msg then
|
|
||||||
local frame = textutils.unserializeJSON(msg);
|
|
||||||
if frame and frame.type == "request" then
|
|
||||||
ws.send(textutils.serializeJSON({
|
|
||||||
type = "response",
|
|
||||||
id = frame.id,
|
|
||||||
ok = true,
|
|
||||||
result = "pong from " .. id .. " (Label: " .. label .. ")",
|
|
||||||
}));
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
pcall(function() ws.close(); end);
|
|
||||||
end
|
|
||||||
|
|
||||||
parallel.waitForAll(
|
|
||||||
function() connect(1001, "echo-A", 5); end,
|
|
||||||
function() connect(1002, "echo-B", 5); end
|
|
||||||
);
|
|
||||||
|
|
||||||
os.shutdown();
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
-- Drives the real libhttpws transport against the bridge opencode proxy.
|
|
||||||
-- Usage: opencode-proxy-check <ws-proxy-url>
|
|
||||||
local createHttpWs = require('/apis/libhttpws');
|
|
||||||
|
|
||||||
local args = { ... };
|
|
||||||
local url = args[1];
|
|
||||||
|
|
||||||
local client = createHttpWs({ bridgeUrl = url, receiveTimeout = 10 });
|
|
||||||
local body, code = client.postJson({ url = '' }, '/session/ses_x/message', {
|
|
||||||
parts = { { type = 'text', text = 'ping' } },
|
|
||||||
});
|
|
||||||
print('STATUS=' .. tostring(code));
|
|
||||||
print('BODY=' .. tostring(body));
|
|
||||||
client.close();
|
|
||||||
os.shutdown();
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
-- Integration-test helper: connect, send a hello frame, then ignore every
|
|
||||||
-- request until the deadline expires. Used to prove the bridge's per-computer
|
|
||||||
-- probe timeout path.
|
|
||||||
local args = {...};
|
|
||||||
|
|
||||||
local id = tonumber(args[1]) or 99;
|
|
||||||
local label = args[2] or "silent";
|
|
||||||
local urlBase = args[3] or "ws://127.0.0.1:2001";
|
|
||||||
local duration = tonumber(args[4]) or 5;
|
|
||||||
|
|
||||||
local url = urlBase .. "/?id=" .. id;
|
|
||||||
local ws, err = http.websocket(url);
|
|
||||||
if not ws then
|
|
||||||
print("websocket failed: " .. tostring(err));
|
|
||||||
os.shutdown();
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
|
|
||||||
ws.send(textutils.serializeJSON({
|
|
||||||
type = "hello",
|
|
||||||
computerId = id,
|
|
||||||
computerLabel = label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
sleep(duration);
|
|
||||||
|
|
||||||
pcall(function() ws.close(); end);
|
|
||||||
os.shutdown();
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createServer, type Server } from "node:http";
|
|
||||||
import type { WebSocketServer } from "ws";
|
|
||||||
import { formatFailure, startCraftos } from "./harness.js";
|
|
||||||
import { startOpencodeProxy } from "../src/opencode-proxy.js";
|
|
||||||
|
|
||||||
test("libhttpws drives a synchronous opencode call through the bridge proxy", async () => {
|
|
||||||
const opencode = createServer((_req, res) => {
|
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify({ info: { finish: "stop" }, parts: [{ type: "text", text: "pong" }] }));
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => opencode.listen(0, "127.0.0.1", resolve));
|
|
||||||
const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`;
|
|
||||||
|
|
||||||
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl });
|
|
||||||
await waitForListening(proxy);
|
|
||||||
const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`;
|
|
||||||
|
|
||||||
const craftos = startCraftos("opencode-proxy-check.lua", {
|
|
||||||
mountRepo: true,
|
|
||||||
shellArgs: [proxyUrl],
|
|
||||||
timeoutMs: 20_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await craftos.done;
|
|
||||||
assert.match(result.output, /STATUS=200/, formatFailure("expected STATUS=200", result.output));
|
|
||||||
assert.match(result.output, /pong/, formatFailure("expected reply body to contain pong", result.output));
|
|
||||||
} finally {
|
|
||||||
craftos.abort();
|
|
||||||
await craftos.done.catch(() => undefined);
|
|
||||||
await closeWss(proxy);
|
|
||||||
await new Promise<void>((resolve) => opencode.close(() => resolve()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function serverPort(server: Server): number {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("server has no TCP address");
|
|
||||||
}
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wssPort(proxy: WebSocketServer): number {
|
|
||||||
const address = proxy.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("proxy has no TCP address");
|
|
||||||
}
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForListening(proxy: WebSocketServer): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (proxy.address()) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
proxy.once("listening", () => resolve());
|
|
||||||
proxy.once("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeWss(proxy: WebSocketServer): Promise<void> {
|
|
||||||
for (const client of proxy.clients) {
|
|
||||||
client.terminate();
|
|
||||||
}
|
|
||||||
return new Promise<void>((resolve) => proxy.close(() => resolve()));
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { callProbeComputers, startBridge } from "./harness.js";
|
|
||||||
|
|
||||||
test("probe-computers returns the no-computers message when nothing is connected", async () => {
|
|
||||||
const bridge = await startBridge();
|
|
||||||
try {
|
|
||||||
assert.equal(await callProbeComputers(bridge.mcpUrl), "No computers connected.");
|
|
||||||
} finally {
|
|
||||||
await bridge.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
|
|
||||||
|
|
||||||
test("probe-computers aggregates a single CraftOS echo computer", async () => {
|
|
||||||
const bridge = await startBridge();
|
|
||||||
const craftos = startCraftos("echo-client.lua", { shellArgs: ["1", "echo-1", bridge.linkUrl, "8"] });
|
|
||||||
try {
|
|
||||||
await waitForComputers(bridge.registry, 1, 12_000);
|
|
||||||
const text = await callProbeComputers(bridge.mcpUrl);
|
|
||||||
assert.equal(text, "pong from 1 (Label: echo-1)");
|
|
||||||
} catch (error) {
|
|
||||||
craftos.abort();
|
|
||||||
const result = await craftos.done;
|
|
||||||
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), result.output), { cause: error });
|
|
||||||
} finally {
|
|
||||||
craftos.abort();
|
|
||||||
await craftos.done;
|
|
||||||
await bridge.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
|
|
||||||
|
|
||||||
test("probe-computers aggregates two CraftOS computers from the same headless process", async () => {
|
|
||||||
const bridge = await startBridge();
|
|
||||||
const craftos = startCraftos("multi-echo-client.lua", { shellArgs: [bridge.linkUrl], timeoutMs: 15_000 });
|
|
||||||
try {
|
|
||||||
await waitForComputers(bridge.registry, 2, 12_000);
|
|
||||||
const text = await callProbeComputers(bridge.mcpUrl);
|
|
||||||
const lines = text.split("\n").sort();
|
|
||||||
assert.deepEqual(lines, [
|
|
||||||
"pong from 1001 (Label: echo-A)",
|
|
||||||
"pong from 1002 (Label: echo-B)",
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
craftos.abort();
|
|
||||||
const result = await craftos.done;
|
|
||||||
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), result.output), { cause: error });
|
|
||||||
} finally {
|
|
||||||
craftos.abort();
|
|
||||||
await craftos.done;
|
|
||||||
await bridge.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
|
|
||||||
|
|
||||||
test("probe-computers reports timeout for a connected computer that never replies", async () => {
|
|
||||||
const bridge = await startBridge(300);
|
|
||||||
const craftos = startCraftos("silent-client.lua", { shellArgs: ["7", "silent-7", bridge.linkUrl, "8"] });
|
|
||||||
try {
|
|
||||||
await waitForComputers(bridge.registry, 1, 12_000);
|
|
||||||
const text = await callProbeComputers(bridge.mcpUrl);
|
|
||||||
assert.equal(text, "timeout from 7 (Label: silent-7)");
|
|
||||||
} catch (error) {
|
|
||||||
craftos.abort();
|
|
||||||
const result = await craftos.done;
|
|
||||||
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), result.output), { cause: error });
|
|
||||||
} finally {
|
|
||||||
craftos.abort();
|
|
||||||
await craftos.done;
|
|
||||||
await bridge.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import { WebSocket, type RawData, type WebSocketServer } from "ws";
|
|
||||||
import { parseHttpRequestFrame, parseJsonFrame } from "../src/protocol.js";
|
|
||||||
import { startOpencodeProxy } from "../src/opencode-proxy.js";
|
|
||||||
|
|
||||||
test("parseHttpRequestFrame accepts a valid frame", () => {
|
|
||||||
const frame = parseJsonFrame(
|
|
||||||
JSON.stringify({ type: "http", id: "req_1", method: "POST", path: "/session/x/message", body: "{}" }),
|
|
||||||
);
|
|
||||||
assert.deepEqual(parseHttpRequestFrame(frame), {
|
|
||||||
type: "http",
|
|
||||||
id: "req_1",
|
|
||||||
method: "POST",
|
|
||||||
path: "/session/x/message",
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseHttpRequestFrame omits absent body", () => {
|
|
||||||
const frame = parseHttpRequestFrame({ type: "http", id: "req_1", method: "GET", path: "/session" });
|
|
||||||
assert.deepEqual(frame, { type: "http", id: "req_1", method: "GET", path: "/session", body: undefined });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseHttpRequestFrame rejects invalid frames", () => {
|
|
||||||
assert.equal(parseHttpRequestFrame({ type: "http", id: "", method: "GET", path: "/x" }), null);
|
|
||||||
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "DELETE", path: "/x" }), null);
|
|
||||||
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "no-slash" }), null);
|
|
||||||
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "/x", body: 5 }), null);
|
|
||||||
assert.equal(parseHttpRequestFrame({ type: "other", id: "a", method: "GET", path: "/x" }), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("proxy forwards a request to opencode and returns the raw response", async () => {
|
|
||||||
const seen: { method?: string; url?: string; auth?: string; body?: string } = {};
|
|
||||||
const opencode = await startFakeOpencode((req, res, body) => {
|
|
||||||
seen.method = req.method;
|
|
||||||
seen.url = req.url;
|
|
||||||
seen.auth = req.headers.authorization;
|
|
||||||
seen.body = body;
|
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify({ info: { finish: "stop" }, parts: [{ type: "text", text: "pong" }] }));
|
|
||||||
});
|
|
||||||
const proxy = startOpencodeProxy({
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 0,
|
|
||||||
opencodeUrl: opencode.url,
|
|
||||||
username: "opencode",
|
|
||||||
password: "secret",
|
|
||||||
});
|
|
||||||
await waitForListening(proxy);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await roundtrip(proxyUrl(proxy), {
|
|
||||||
type: "http",
|
|
||||||
id: "req_42",
|
|
||||||
method: "POST",
|
|
||||||
path: "/session/ses_1/message",
|
|
||||||
body: JSON.stringify({ parts: [{ type: "text", text: "ping" }] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(response.type, "http-response");
|
|
||||||
assert.equal(response.id, "req_42");
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
const parsed = JSON.parse(response.body as string) as { parts: { text: string }[] };
|
|
||||||
assert.equal(parsed.parts[0].text, "pong");
|
|
||||||
|
|
||||||
assert.equal(seen.method, "POST");
|
|
||||||
assert.equal(seen.url, "/session/ses_1/message");
|
|
||||||
assert.equal(seen.auth, "Basic " + Buffer.from("opencode:secret").toString("base64"));
|
|
||||||
assert.equal(seen.body, JSON.stringify({ parts: [{ type: "text", text: "ping" }] }));
|
|
||||||
} finally {
|
|
||||||
await closeWss(proxy);
|
|
||||||
await opencode.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("proxy maps a fetch failure to status 0", async () => {
|
|
||||||
// Point at a port with nothing listening so fetch rejects.
|
|
||||||
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl: "http://127.0.0.1:1" });
|
|
||||||
await waitForListening(proxy);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await roundtrip(proxyUrl(proxy), {
|
|
||||||
type: "http",
|
|
||||||
id: "req_err",
|
|
||||||
method: "GET",
|
|
||||||
path: "/session",
|
|
||||||
});
|
|
||||||
assert.equal(response.id, "req_err");
|
|
||||||
assert.equal(response.status, 0);
|
|
||||||
assert.equal(typeof response.error, "string");
|
|
||||||
} finally {
|
|
||||||
await closeWss(proxy);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type HttpResponse = { type: string; id: string; status: number; body?: string; error?: string };
|
|
||||||
|
|
||||||
function rawToString(data: RawData): string {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return Buffer.concat(data).toString("utf8");
|
|
||||||
}
|
|
||||||
if (Buffer.isBuffer(data)) {
|
|
||||||
return data.toString("utf8");
|
|
||||||
}
|
|
||||||
return Buffer.from(data).toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundtrip(url: string, frame: unknown): Promise<HttpResponse> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const ws = new WebSocket(url);
|
|
||||||
ws.on("open", () => ws.send(JSON.stringify(frame)));
|
|
||||||
ws.on("message", (data: RawData) => {
|
|
||||||
ws.close();
|
|
||||||
resolve(JSON.parse(rawToString(data)) as HttpResponse);
|
|
||||||
});
|
|
||||||
ws.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startFakeOpencode(
|
|
||||||
handler: (req: IncomingMessage, res: ServerResponse, body: string) => void,
|
|
||||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on("data", (c: Buffer) => chunks.push(c));
|
|
||||||
req.on("end", () => handler(req, res, Buffer.concat(chunks).toString("utf8")));
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
||||||
const port = (server.address() as AddressInfo).port;
|
|
||||||
return {
|
|
||||||
url: `http://127.0.0.1:${port}`,
|
|
||||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function proxyUrl(proxy: WebSocketServer): string {
|
|
||||||
const address = proxy.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("proxy has no TCP address");
|
|
||||||
}
|
|
||||||
return `ws://127.0.0.1:${address.port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForListening(server: WebSocketServer | Server): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (server.address()) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
server.once("listening", () => resolve());
|
|
||||||
server.once("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeWss(proxy: WebSocketServer): Promise<void> {
|
|
||||||
for (const client of proxy.clients) {
|
|
||||||
client.terminate();
|
|
||||||
}
|
|
||||||
return new Promise<void>((resolve) => proxy.close(() => resolve()));
|
|
||||||
}
|
|
||||||
@ -1,377 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { WebSocket } from "ws";
|
|
||||||
import { LinkRegistry } from "../src/link-server.js";
|
|
||||||
import { handleMcpRequest } from "../src/mcp-server.js";
|
|
||||||
|
|
||||||
test("probe-computers returns no computers message when registry is empty", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
assert.equal(await registry.probeComputers(10), "No computers connected.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("probe-computers aggregates multiple successful responses", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer1 = new FakeSocket();
|
|
||||||
const computer2 = new FakeSocket();
|
|
||||||
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer1 as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
registry.register({ computerId: 13, label: "miner-1", ws: computer2 as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.probeComputers(50);
|
|
||||||
computer1.respond(registry, 12, "pong from 12 (Label: base-turtle)");
|
|
||||||
computer2.respond(registry, 13, "pong from 13 (Label: miner-1)");
|
|
||||||
|
|
||||||
assert.equal(await promise, "pong from 12 (Label: base-turtle)\npong from 13 (Label: miner-1)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("probe-computers reports timeout for a connected computer that does not answer", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
registry.register({ computerId: 14, label: "farm-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
assert.equal(await registry.probeComputers(5), "timeout from 14 (Label: farm-turtle)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP tool call returns text content", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "probe-computers", arguments: {} } },
|
|
||||||
registry,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(response, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
result: { content: [{ type: "text", text: "No computers connected." }] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP tools/list includes exec-lua schema", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" }, registry, 10);
|
|
||||||
|
|
||||||
assert.deepEqual(response, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
result: {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
name: "probe-computers",
|
|
||||||
description: "Probe all linked ComputerCraft computers.",
|
|
||||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exec-lua",
|
|
||||||
description: "Execute Lua code on a linked ComputerCraft computer.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to execute on." },
|
|
||||||
code: { type: "string", description: "Lua source code to execute." },
|
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
|
||||||
},
|
|
||||||
required: ["computerId", "code"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write-file",
|
|
||||||
description: "Write file content on a linked ComputerCraft computer, overwriting any existing file.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to write on." },
|
|
||||||
path: { type: "string", description: "Target path on the ComputerCraft computer." },
|
|
||||||
content: { type: "string", description: "File content to write. Empty strings are allowed." },
|
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
|
||||||
},
|
|
||||||
required: ["computerId", "path", "content"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "run-file",
|
|
||||||
description: "Run a Lua file on a linked ComputerCraft computer with captured print/write output.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
|
|
||||||
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
|
|
||||||
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
|
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
|
||||||
},
|
|
||||||
required: ["computerId", "path"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exec-lua sends code to the selected computer", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.execLua(12, "print('hi') return 2 + 3", 50);
|
|
||||||
assert.deepEqual(computer.lastRequest(), {
|
|
||||||
type: "request",
|
|
||||||
id: computer.lastRequest().id,
|
|
||||||
method: "exec-lua",
|
|
||||||
params: { code: "print('hi') return 2 + 3" },
|
|
||||||
});
|
|
||||||
|
|
||||||
computer.respond(registry, 12, { returns: [{ type: "number", value: 5 }], output: "hi\n" });
|
|
||||||
assert.equal(
|
|
||||||
await promise,
|
|
||||||
'computer: 12 (Label: base-turtle)\nok: true\nreturns: [{"type":"number","value":5}]\noutput:\nhi',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exec-lua reports unknown computer", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
|
|
||||||
assert.equal(await registry.execLua(99, "return 1", 50), "No computer with id 99 connected.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exec-lua formats computer error responses", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.execLua(12, "print('before'); error('boom')", 50);
|
|
||||||
computer.respondError(registry, 12, "boom", { output: "before\n" });
|
|
||||||
|
|
||||||
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: boom\noutput:\nbefore");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exec-lua formats ok false execution results", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.execLua(12, "print('before'); error('boom')", 50);
|
|
||||||
computer.respond(registry, 12, { ok: false, error: "boom", output: "before\n" });
|
|
||||||
|
|
||||||
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: boom\noutput:\nbefore");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exec-lua reports timeouts", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
assert.equal(await registry.execLua(12, "while true do end", 5), "computer: 12 (Label: base-turtle)\nok: false\nerror: timeout");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("run-file sends path to the selected computer", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.runFile(12, "/helloworld.lua", ["install", "pkg"], 50);
|
|
||||||
assert.deepEqual(computer.lastRequest(), {
|
|
||||||
type: "request",
|
|
||||||
id: computer.lastRequest().id,
|
|
||||||
method: "run-file",
|
|
||||||
params: { path: "/helloworld.lua", args: ["install", "pkg"] },
|
|
||||||
});
|
|
||||||
|
|
||||||
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
|
|
||||||
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: true\nreturns: []\noutput:\nBlah!");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("run-file reports unknown computer", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
|
|
||||||
assert.equal(await registry.runFile(99, "/helloworld.lua", [], 50), "No computer with id 99 connected.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP run-file validates required arguments", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "run-file", arguments: { computerId: 1 } } },
|
|
||||||
registry,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(response, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
error: { code: -32602, message: "path must be a non-empty string" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP run-file returns text content", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const responsePromise = handleMcpRequest(
|
|
||||||
{
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
method: "tools/call",
|
|
||||||
params: { name: "run-file", arguments: { computerId: 12, path: "/helloworld.lua", args: ["a", "b"] } },
|
|
||||||
},
|
|
||||||
registry,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
|
|
||||||
|
|
||||||
assert.deepEqual(computer.lastRequest().params, { path: "/helloworld.lua", args: ["a", "b"] });
|
|
||||||
|
|
||||||
assert.deepEqual(await responsePromise, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
result: { content: [{ type: "text", text: "computer: 12 (Label: base-turtle)\nok: true\nreturns: []\noutput:\nBlah!" }] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP exec-lua validates required arguments", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "exec-lua", arguments: { computerId: 1 } } },
|
|
||||||
registry,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(response, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
error: { code: -32602, message: "code must be a non-empty string" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP exec-lua validates timeoutMs", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "exec-lua", arguments: { computerId: 1, code: "return 1", timeoutMs: 0 } } },
|
|
||||||
registry,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(response, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
error: { code: -32602, message: "timeoutMs must be a positive finite number" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("write-file sends path and content to the selected computer", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.writeFile(12, "notes/todo.txt", "hello\nworld", 50);
|
|
||||||
assert.deepEqual(computer.lastRequest(), {
|
|
||||||
type: "request",
|
|
||||||
id: computer.lastRequest().id,
|
|
||||||
method: "write-file",
|
|
||||||
params: { path: "notes/todo.txt", content: "hello\nworld" },
|
|
||||||
});
|
|
||||||
|
|
||||||
computer.respond(registry, 12, { path: "notes/todo.txt", bytes: 11 });
|
|
||||||
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: true\npath: notes/todo.txt\nbytes: 11");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("write-file reports unknown computer", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
|
|
||||||
assert.equal(await registry.writeFile(99, "note.txt", "hello", 50), "No computer with id 99 connected.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("write-file formats computer error responses", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.writeFile(12, "missing/note.txt", "hello", 50);
|
|
||||||
computer.respondError(registry, 12, "No such file", {});
|
|
||||||
|
|
||||||
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: No such file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("write-file formats ok false operation results", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const promise = registry.writeFile(12, "missing/note.txt", "hello", 50);
|
|
||||||
computer.respond(registry, 12, { ok: false, error: "No such file" });
|
|
||||||
|
|
||||||
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: No such file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("write-file reports timeouts", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
assert.equal(await registry.writeFile(12, "note.txt", "hello", 5), "computer: 12 (Label: base-turtle)\nok: false\nerror: timeout");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP write-file validates required arguments", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "write-file", arguments: { computerId: 1, content: "hello" } } },
|
|
||||||
registry,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(response, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
error: { code: -32602, message: "path must be a non-empty string" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP write-file allows empty content", async () => {
|
|
||||||
const registry = new LinkRegistry();
|
|
||||||
const computer = new FakeSocket();
|
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
|
||||||
|
|
||||||
const responsePromise = handleMcpRequest(
|
|
||||||
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "write-file", arguments: { computerId: 12, path: "empty.txt", content: "" } } },
|
|
||||||
registry,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
computer.respond(registry, 12, { path: "empty.txt", bytes: 0 });
|
|
||||||
|
|
||||||
assert.deepEqual(await responsePromise, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
result: { content: [{ type: "text", text: "computer: 12 (Label: base-turtle)\nok: true\npath: empty.txt\nbytes: 0" }] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
class FakeSocket {
|
|
||||||
sent: unknown[] = [];
|
|
||||||
|
|
||||||
send(data: unknown): void {
|
|
||||||
this.sent.push(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastRequest(): { id: string; type: string; method: string; params?: unknown } {
|
|
||||||
const last = this.sent.at(-1);
|
|
||||||
assert.equal(typeof last, "string");
|
|
||||||
return JSON.parse(last as string) as { id: string; type: string; method: string; params?: unknown };
|
|
||||||
}
|
|
||||||
|
|
||||||
respond(registry: LinkRegistry, computerId: number, result: unknown): void {
|
|
||||||
const request = this.lastRequest();
|
|
||||||
registry.handleFrame(this as unknown as WebSocket, JSON.stringify({ type: "response", id: request.id, ok: true, result }));
|
|
||||||
|
|
||||||
assert.equal(computerId > 0, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
respondError(registry: LinkRegistry, computerId: number, error: string, result: unknown): void {
|
|
||||||
const request = this.lastRequest();
|
|
||||||
registry.handleFrame(this as unknown as WebSocket, JSON.stringify({ type: "response", id: request.id, ok: false, error, result }));
|
|
||||||
|
|
||||||
assert.equal(computerId > 0, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { parseJsonFrame, parseLinkMessage } from "../src/protocol.js";
|
|
||||||
|
|
||||||
test("protocol validation accepts valid hello", () => {
|
|
||||||
const frame = parseJsonFrame(JSON.stringify({ type: "hello", computerId: 12, computerLabel: "base-turtle" }));
|
|
||||||
assert.deepEqual(parseLinkMessage(frame), { type: "hello", computerId: 12, computerLabel: "base-turtle" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("protocol validation normalizes empty label", () => {
|
|
||||||
const frame = parseJsonFrame(JSON.stringify({ type: "hello", computerId: 12, computerLabel: "" }));
|
|
||||||
assert.deepEqual(parseLinkMessage(frame), { type: "hello", computerId: 12, computerLabel: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("protocol validation rejects invalid frames", () => {
|
|
||||||
assert.equal(parseJsonFrame("not json"), null);
|
|
||||||
assert.equal(parseLinkMessage({ type: "hello", computerId: "12" }), null);
|
|
||||||
assert.equal(parseLinkMessage({ type: "response", id: 1, ok: true }), null);
|
|
||||||
});
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": ".",
|
|
||||||
"declaration": true,
|
|
||||||
"incremental": true,
|
|
||||||
"tsBuildInfoFile": "node_modules/.cache/tsc/tsconfig.tsbuildinfo"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "test/**/*.ts", "test-integration/**/*.ts"]
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user