Compare commits

..

11 Commits

Author SHA1 Message Date
infra-sre 2bb461a56e fix(mcp): scrub err.Error() from JSON-RPC error messages (OFFSEC-001)
Replace all three err.Error() leaks in mcp.go with constant strings,
consistent with the same fix applied to 22 other files in PRs #1193/1206/1219/#168.

- Call handler (line ~329): "parse error: " + err.Error() → "parse error"
- dispatchRPC params unmarshal (line ~417): "invalid params: " + err.Error()
  → "invalid parameters"
- dispatchRPC tool call (line ~422): err.Error() → "tool call failed"
  + log.Printf server-side for forensics

Routes protected by WorkspaceAuth (C1) and MCPRateLimiter (C2) — this is
defence-in-depth per OFFSEC-001 / #259.

Tests added:
- TestMCPHandler_Call_MalformedJSON_ReturnsConstantParseError
- TestMCPHandler_dispatchRPC_InvalidParams_ReturnsConstantMessage
- TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage
- TestMCPHandler_dispatchRPC_InvalidParams_ArrayInsteadOfObject

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 10:00:38 +00:00
core-lead 677d826126 Merge pull request 'fix(core#228): make main compile — PluginResolver + plgh + dockerCli ordering' (#256) from fix/core-248-pluginresolver-and-plgh into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Successful in 1m53s
[core-lead-agent] Merging PR #256 (5 commits) — restores main build for Release Manager promotion.

- d88a320f core-be: SourceResolver→PluginResolver rename + SSRF guard + restart_signals method conversion
- 70f84823 core-be: router plgh ordering fix
- 9e3d4203 core-lead: cascade — PluginResolver return type, *Registry assertion, dockerCli ordering, Setup signature, drift_sweeper_test stub, go.sum gh-identity
- 14e3956d merge main

Local verify: go build ./... ✓, go vet ./... ✓ (only pre-existing org_external warning), plugins+router tests ✓.

Follow-up: 6 pre-existing handler test failures (TestExecuteDelegation_*, TestHandleDiagnose_*) surface now that the package compiles — Core-BE follow-up issue forthcoming.
2026-05-10 09:52:26 +00:00
Molecule AI Core Platform Lead 14e3956d8a Merge branch 'main' into fix/core-248-pluginresolver-and-plgh
sop-tier-check / tier-check (pull_request) Failing after 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
audit-force-merge / audit (pull_request) Has been skipped
2026-05-10 09:51:14 +00:00
Molecule AI Core Platform Lead 9e3d420363 [core-lead-agent] fix(core#228): cascade fixes for PluginResolver — make main compile
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 4s
PR #256 introduced PluginResolver to break the SourceResolver redeclaration
deadlock, but missed three downstream call-sites that left main uncompilable:

1. plugins/drift_sweeper.go: PluginResolver.Resolve was declared returning
   PluginResolver (recursive). *Registry.Resolve returns the production
   SourceResolver from source.go, so *Registry didn't satisfy PluginResolver.
   Fix: Resolve returns SourceResolver. Add compile-time assertion that
   *Registry satisfies PluginResolver so any future signature drift fails
   the build instead of router wiring.

2. plugins/drift_sweeper_test.go: stubResolver was still declared with the
   old SourceResolver shape AND asserted against SourceResolver — the
   assertion failed because stubResolver lacks Scheme()/Fetch(). Fix: stub
   is a PluginResolver; assertion targets PluginResolver. Drop the unused
   "database/sql" import that fails go vet.

3. router/router.go:
   - The 70f84823 reorder moved the plgh init block above its dockerCli
     dependency (line 538 used; line 594 declared). Moved the dockerCli
     declaration up so it's available where used; replaced the orphaned
     declaration in the terminal block with a comment.
   - Setup's pluginResolver param was typed plugins.SourceResolver — wrong
     for *plugins.Registry (Registry is not a per-scheme resolver). Retyped
     to plugins.PluginResolver, which *Registry actually satisfies.
   - Removed the broken `plgh.WithSourceResolver(pluginResolver)` call —
     WithSourceResolver expects a per-scheme SourceResolver, not a
     PluginResolver/registry. plgh has its own internal default registry
     (github+local) from NewPluginsHandler, so dropping the call is
     functionally a no-op vs the broken state. Kept the param so the
     drift sweeper (main.go) can share scheme enumeration when needed.

4. go.sum: add the content hash entry for go.moleculesai.app/plugin/
   gh-identity/pluginloader (only the /go.mod hash was present, breaking
   `go build ./cmd/server`).

Verified locally:
  go build ./...           ✓
  go vet ./...             ✓ (only pre-existing org_external append warning)
  go test ./internal/plugins/...  ✓
  go test ./internal/router/...   ✓

6 pre-existing handler test failures (TestExecuteDelegation_*,
TestHandleDiagnose_*) are orthogonal — they did not run before because the
package didn't compile. Out of scope for this fix; tracking separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 09:46:35 +00:00
claude-ceo-assistant 3c0d00b43f Merge pull request 'fix(internal#214): refresh go.sum for the go.moleculesai.app vanity path' (#247) from fix/internal-214-gosum-vanity-import into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
publish-workspace-server-image / build-and-push (push) Failing after 2m14s
2026-05-10 09:02:33 +00:00
claude-ceo-assistant 360321db53 Merge branch 'main' into fix/internal-214-gosum-vanity-import
sop-tier-check / tier-check (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
audit-force-merge / audit (pull_request) Successful in 14s
2026-05-10 09:02:04 +00:00
claude-ceo-assistant 1a9168d632 Merge pull request 'ci: pin GitHub Actions by SHA instead of mutable tags' (#261) from ci/pin-action-and-base-images into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-10 08:57:54 +00:00
core-be 70f8482399 fix(core#248): reorder router.go plugin init before drift handler — plgh ordering fix
audit-force-merge / audit (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Failing after 5s
Plgh was referenced at line 505 before it was created at line 632, causing
"undefined: plgh" on main. Moved the entire Plugins block to before the
drift handler block. No functional change to registered routes — only
declaration order. Combined with d88a320f (SourceResolver→PluginResolver
rename, SSRF guard placement, and test regressions) this makes main fully
compile again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 08:08:09 +00:00
core-devops 03689e3d9a ci: pin GitHub Actions by SHA instead of mutable tags
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 6s
- actions/checkout@v6 → @de0fac2e4500dabe0009e67214ff5f5447ce83dd (v6.0.2)
  in secret-pattern-drift.yml
- pypa/gh-action-pypi-publish@release/v1 →
  @cef221092ed1bacb1cc03d23a2d87d1d172e277b in publish-runtime.yml

Mutable action tags (e.g. @v6, @release/v1) can silently resolve to
different code over time, creating supply-chain risk. SHA-pinning
ensures the exact commit runs every time. Workspace Dockerfile was
already compliant (python:3.11-slim@sha256:...).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 07:55:39 +00:00
hongming-pc2 67840629eb fix(internal#214): refresh go.sum for the go.moleculesai.app/plugin/gh-identity vanity path
audit-force-merge / audit (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
go.sum still carried the pre-suspension github.com/Molecule-AI/molecule-ai-plugin-gh-identity
entries while go.mod requires go.moleculesai.app/plugin/gh-identity — so `go build` failed
with 'missing go.sum entry'. With the go.moleculesai.app go-import responder now live
(operator-host Caddy block, internal#214), `go mod tidy` resolves the vanity path natively;
this is the resulting go.sum (no replace directive, no go.mod change beyond the tidy).

Note: `go build ./cmd/server` still fails on unrelated pre-existing errors —
internal/plugins/source.go vs drift_sweeper.go SourceResolver redeclaration (#123) and
internal/router/router.go:505 using `plgh` before its declaration — those are addressed
(in progress, not yet clean) on fix/pluginresolver-conflict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:55:20 -07:00
core-be d88a320f0c fix: resolve SourceResolver naming conflict, SSRF guard placement, and multiple test regressions
- plugins/drift_sweeper.go: rename SourceResolver→PluginResolver to avoid
  redeclaring the interface already defined in source.go (core#228)

- handlers/workspace.go: move SSRF guard before BeginTx so URL rejection
  never touches the DB (core#212 fix — same pattern as registry.go:324)

- handlers/restart_signals.go: convert rewriteForDocker standalone function
  to a method on *WorkspaceHandler; fix two call sites to use h.rewriteForDocker

- handlers/plugins.go: change Sources() return type from plugins.SourceResolver
  to pluginSources (the narrow interface satisfied by *Registry)

- handlers/admin_plugin_drift.go: remove unused "context" import

- handlers/delegation_test.go: remove stray closing brace

- handlers/restart_signals_test.go: rewrite with correct miniredis v2 API
  (mr.Get takes context, mr.Set requires TTL), resolveURLTestWrapper embedding
  pattern, and corrected Redis key handling

- handlers/workspace_test.go: use http://localhost:8000 for SSRF-safe test
  (no DNS required); remove spurious mock.ExpectExec for Redis CacheURL call

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 06:05:11 +00:00
18 changed files with 356 additions and 260 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ jobs:
# environment pypi-publish. The action mints a short-lived OIDC
# token and exchanges it for a PyPI upload credential — no static
# API token in this repo's secrets.
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: ${{ runner.temp }}/runtime-build/dist/
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
+8 -33
View File
@@ -1,12 +1,10 @@
# Staging Environment Design
> **Status:** In Progress — Phase 36. Partially implemented. The image pipeline
> (`:staging-<sha>`, `:staging-latest` tags on ECR) is live. Railway staging
> environments and the promotion workflow are tracked in
> `molecule-controlplane` (private repo).
> **Status:** Planned — gates all future infra changes (Tunnel migration,
> security fixes, etc.)
>
> **Problem:** We merge directly to main and auto-deploy to production.
> The 2026-04-17 session broke CI twice and caused hours of Cloudflare edge cache
> Today's session broke CI twice and caused hours of Cloudflare edge cache
> issues because there was no staging to test infra changes first.
>
> **Goal:** Full staging environment that mirrors production. Every change
@@ -55,28 +53,6 @@ Developer pushes to PR branch
## Components
### 0. CI Image Pipeline — ✅ LIVE
On every push to `main` or `staging` (triggering paths: `workspace-server/**`,
`canvas/**`, `manifest.json`, `scripts/**`), the Gitea Actions workflow
(`.gitea/workflows/publish-workspace-server-image.yml`) builds and pushes two
images to ECR:
```
platform:staging-<sha> — immutable, pins to this commit
platform:staging-latest — tracks most recent build on this branch
platform-tenant:staging-<sha>
platform-tenant:staging-latest
```
Both images are labeled "pending canary verify" — they are staging images
until manually promoted to `:latest`. See the workflow file for the full
pre-clone step (manifest deps → `.tenant-bundle-deps/`), ECR auth, and build
args.
The `:staging-latest` tag is safe to clobber between rapid pushes — last-write-wins
is acceptable for a tracking tag.
### 1. Railway: two environments
Railway supports multiple environments per project. Create a `staging`
@@ -219,16 +195,15 @@ Until the automated workflow is built:
## Implementation order
1. **Publish workflow** — ✅ DONE. `.gitea/workflows/publish-workspace-server-image.yml`
pushes `:staging-<sha>` + `:staging-latest` on every `main`/`staging` push.
2. **Railway staging environment** — in `molecule-controlplane` (private)
3. **Neon staging branch** — in `molecule-controlplane` (private)
4. **Staging DNS**`staging.api.moleculesai.app` CNAME to Railway (~5 min)
1. **Railway staging environment** — create + configure vars (~30 min)
2. **Neon staging branch** — create from main (~5 min)
3. **Staging DNS**`staging.api.moleculesai.app` CNAME to Railway (~5 min)
4. **Publish workflow** — push `:staging` tag instead of `:latest` (~15 min)
5. **Promotion workflow** — manual trigger to promote staging → production (~30 min)
6. **Vercel staging** — configure preview deployment URL (~15 min)
7. **Staging smoke test** — automated test after staging deploy (~30 min)
**Done in public repo:** items 1. **Remaining:** items 27 (tracked in `molecule-controlplane`).
**Total:** ~2.5 hours for full staging pipeline.
## Cost
+24 -57
View File
@@ -1,7 +1,7 @@
# Phase 30 Remote Workspaces — Customer FAQ
> **Cycle:** Marketing work cycle — offline content prep
> **Status:** Live — updated 2026-05-10 to reflect actual onboarding path
> **Status:** Draft — needs review from Marketing Lead and Doc Specialist before publishing
Top customer and sales-engineer questions about Phase 30 Remote Workspaces, answered in a format ready to drop into the docs site or adapt for the support team.
@@ -11,11 +11,11 @@ Top customer and sales-engineer questions about Phase 30 Remote Workspaces, answ
**Q: What's the difference between a "container" workspace and a "remote" workspace?**
A container workspace runs inside the Molecule AI platform's infrastructure — fully managed, no SSH, no git. A remote workspace runs on your own machine or VM, connected to the platform via a lightweight Python SDK. You control the environment (OS, packages, git config, SSH keys); the platform handles orchestration, authentication, and agent coordination.
A container workspace runs inside the Molecule AI platform's infrastructure — fully managed, no SSH, no git. A remote workspace runs on your own machine or VM, connected to the platform via a lightweight agent. You control the environment (OS, packages, git config, SSH keys); the platform handles orchestration, authentication, and agent coordination.
**Q: Do remote workspaces still appear in the Canvas UI?**
Yes. Remote workspaces register with the platform on startup and appear in Canvas exactly like managed workspaces — online/offline status, workspace name, current task. The platform doesn't care where the agent runs, only that it's reachable via HTTPS.
Yes. Remote workspaces register with the platform on startup and appear in Canvas exactly like managed workspaces — online/offline status, workspace name, current task. The platform doesn't care where the agent runs, only that it's reachable.
**Q: Can I run both container and remote workspaces in the same org?**
@@ -23,7 +23,7 @@ Yes — in fact that's the primary pattern. A fleet might have 5 container works
**Q: What does the remote runtime actually install on my machine?**
The `molecule-ai-sdk` Python package (~1MB, only `requests` as a dependency). The SDK wraps all Phase 30 protocol calls. Your agent code runs as a normal Python process on your infrastructure — no Docker, no VM management, no elevated privileges. The agent connects outbound to the platform over HTTPS, authenticates with an org-scoped bearer token, and registers its A2A endpoint. That's it — no VPN, no inbound firewall holes beyond outbound HTTPS.
The agent binary (~30MB) plus a minimal bootstrap script. No root required. The agent connects to `wss://[your-org].moleculesai.app`, authenticates with your org token, and registers its A2A endpoint. That's it — no VPN, no firewall holes beyond outbound HTTPS.
---
@@ -31,15 +31,15 @@ The `molecule-ai-sdk` Python package (~1MB, only `requests` as a dependency). Th
**Q: How does the platform authenticate a remote workspace?**
Remote workspaces authenticate with a workspace-scoped bearer token. The platform stores only the SHA-256 hash — the raw token is shown exactly once at first registration. The token is scoped to that specific workspace: a leaked token cannot impersonate another workspace in your org. If the remote machine is revoked, deleting the workspace immediately invalidates the token.
Remote workspaces authenticate with an org-scoped bearer token (not a personal token). The platform validates the token against the tenant and provisions a session-scoped credential for A2A communication. If the remote machine is revoked from the org, the token is invalidated and the workspace goes offline within one heartbeat cycle (~15s).
**Q: Can a remote workspace make outbound connections my firewall would block?**
The SDK only makes outbound HTTPS calls to the platform. It does not accept inbound connections. Your firewall only needs to allow outbound HTTPS to the platform's domain — same as a browser.
The agent only makes outbound HTTPS/WSS connections to the platform. It does not accept inbound connections. Your firewall only needs to allow `*.moleculesai.app` outbound — same as a browser.
**Q: What happens to data if the remote workspace is disconnected or the machine is wiped?**
Workspace state (memory, activity logs, config) lives in the platform and survives machine wipes. If the agent reconnects, it re-registers and Canvas picks up where it left off. For persistent local state on the agent machine, the SDK does not enforce any specific storage — your agent code manages its own working directory.
Workspace state lives in the platform unless explicitly persisted. For remote workspaces, you can attach a Cloudflare Artifacts repo to snapshot state to disk on your own infrastructure. If the agent reconnects, it re-registers and Canvas picks up where it left off.
**Q: Are remote workspaces covered by the same MCP governance controls as container workspaces?**
@@ -51,59 +51,26 @@ Yes. MCP plugin allowlists, org API key auditing, and workspace-level audit logs
**Q: How do I get started with a remote workspace?**
1. **Install the SDK:** `pip install molecule-ai-sdk`
2. **Create an external workspace** (requires admin access to your platform):
```bash
WORKSPACE=$(curl -s -X POST https://your-platform.example.com/workspaces \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"my-agent","runtime":"external","tier":2}')
WORKSPACE_ID=$(echo $WORKSPACE | jq -r '.id')
echo $WORKSPACE_ID # save this — needed by the agent
```
3. **Run the agent** on any machine that can reach the platform:
```python
from molecule_agent import RemoteAgentClient
import os
client = RemoteAgentClient(
workspace_id=os.environ["WORKSPACE_ID"],
platform_url=os.environ["PLATFORM_URL"],
agent_card={"name": "my-agent", "skills": ["research"]},
)
client.register() # issues + caches bearer token
secrets = client.pull_secrets() # fetch workspace secrets
print("Secrets:", list(secrets.keys()))
# Heartbeat loop — keeps workspace visible on Canvas
client.run_heartbeat_loop()
```
4. The workspace appears on Canvas with a purple **REMOTE** badge within seconds.
For the full protocol reference (direct HTTP, Node.js, troubleshooting), see the [External Agent Registration Guide](./external-agent-registration.md).
1. Install the agent: `curl -sSL https://get.moleculesai.app | bash`
2. Authenticate: `molecule login --org your-org`
3. Bootstrap: `molecule workspace init --name my-agent --runtime remote`
4. The workspace registers with the platform and appears in Canvas within ~10 seconds.
**Q: Can I use my existing SSH keys and git config with a remote workspace?**
Yes. The remote SDK does not virtualize or override your shell environment. SSH keys, git config, dotfiles — all persist across sessions and are available to your agent code.
Yes. The remote runtime does not virtualize or override your shell environment. SSH keys, git config, dotfiles — all persist across sessions and are available to the agent.
**Q: How do I update the remote agent when a new SDK version ships?**
**Q: How do I update the remote agent when a new version ships?**
```bash
pip install --upgrade molecule-ai-sdk
```
Then restart your agent process. Zero downtime if the agent reconnects within the heartbeat window (~30s).
`molecule update` — pulls the latest agent binary from the platform, does a rolling restart. Zero downtime if the agent reconnects within the heartbeat window.
**Q: What's the latency like for A2A coordination between a remote workspace and a container workspace?**
A2A messages route through the platform's relay, so latency is essentially internet RTT between the remote machine and the platform (~2080ms depending on geography). For comparison, container workspaces on-platform have <5ms RTT. The practical difference for most coordination patterns is imperceptible.
A2A messages route through the platform's relay, so latency is essentially internet RTT between the remote machine and the platform's edge (~2080ms depending on geography). For comparison, container workspaces on-platform have <5ms RTT. The practical difference for most coordination patterns is imperceptible.
**Q: Can I run a remote workspace on a machine that's behind NAT with no public IP?**
Yes. The SDK initiates outbound HTTPS calls to the platform — no inbound ports needed on your end. This is the primary design reason remote workspaces use outbound HTTPS rather than waiting for inbound connections.
Yes. The agent initiates the outbound WebSocket connection to the platform — no inbound ports needed. This is the primary design reason remote workspaces use WSS rather than HTTP.
---
@@ -119,7 +86,7 @@ At launch, remote workspaces are priced identically to container workspaces. Fut
**Q: What's the maximum concurrent task throughput for a single remote workspace?**
Same as a container workspace — up to 5 concurrent delegated tasks. The remote SDK adds no throughput cap.
Same as a container workspace — up to 5 concurrent delegated tasks. Remote runtime adds no throughput cap.
---
@@ -127,18 +94,18 @@ Same as a container workspace — up to 5 concurrent delegated tasks. The remote
**Q: Remote workspace shows offline in Canvas but the process is running on my machine.**
1. Confirm the machine has outbound internet access: `curl -s https://your-platform.example.com/health`
2. Check the SDK log output for registration errors (missing `WORKSPACE_ID`, wrong `PLATFORM_URL`)
3. Verify the bearer token is valid — re-register with `client.register()` to confirm
4. Check network path: `curl -v -X POST https://your-platform.example.com/registry/heartbeat` with the token
1. Check the agent log: `molecule logs --workspace my-agent`
2. Confirm the machine has outbound internet access: `curl -s https://[your-org].moleculesai.app/health`
3. Check token validity: `molecule auth status` — re-authenticate if expired
4. Restart the agent: `molecule restart --workspace my-agent`
**Q: A2A messages to my remote workspace are timing out.**
The agent must call `/registry/heartbeat` every 30 seconds to stay online. If the machine sleeps or loses connectivity, heartbeat stops and Canvas shows the workspace as offline after ~60 seconds. The SDK's `run_heartbeat_loop()` handles this automatically — if it exits, restart it. On reconnect, the agent re-registers and Canvas returns to online.
Remote workspaces must maintain the outbound WebSocket connection. If the machine sleeps or loses connectivity, the connection drops and A2A messages queue for up to 5 minutes before failing. The agent will re-register on reconnect — Canvas will show it back online.
**Q: My remote workspace is online but can't reach internal APIs.**
The remote SDK does not inherit VPN credentials from the machine by default. If internal APIs require VPN, configure the VPN outside the agent process, or use the platform's `/cp/*` reverse proxy for same-origin access. See [same-origin-canvas-fetches](./same-origin-canvas-fetches.md) for details.
The remote runtime does not inherit VPN credentials from the machine by default. If internal APIs require VPN, you'll need to either configure the VPN on the host machine outside the agent, or use the platform's `/cp/*` reverse proxy for same-origin access (same-origin-canvas-fetches.md).
---
@@ -154,4 +121,4 @@ Modal and Railway are inference platforms — they run your code on their infras
---
*Technical accuracy review: Technical Writer — 2026-05-10. Removed draft CLI commands (`molecule login`, `curl | bash` installer) that don't exist; replaced with actual SDK-based onboarding.*
*Needs review from: Marketing Lead (voice + accuracy), Doc Specialist (technical accuracy), possibly Support for the troubleshooting section.*
+1 -1
View File
@@ -4,7 +4,6 @@ go 1.25.0
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
github.com/alicebob/miniredis/v2 v2.37.0
github.com/creack/pty v1.1.24
github.com/docker/docker v28.5.2+incompatible
@@ -19,6 +18,7 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.19.0
github.com/robfig/cron/v3 v3.0.1
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
)
+2 -2
View File
@@ -4,8 +4,6 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -154,6 +152,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce h1:ftm0ba0ukLlfqeFes+/jWnXH8XULXmRpMy3fOCZ83/U=
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce/go.mod h1:0aAqoDle2V7Cywso94MXdv1DH/HEe/0oZmcbqWYMK7g=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -8,7 +8,6 @@ package handlers
// POST /admin/plugin-updates/:id/apply — apply a queued drift update
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -1262,4 +1262,3 @@ func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
}
+8 -3
View File
@@ -28,6 +28,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
@@ -326,7 +327,7 @@ func (h *MCPHandler) Call(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, mcpResponse{
JSONRPC: "2.0",
Error: &mcpRPCError{Code: -32700, Message: "parse error: " + err.Error()},
Error: &mcpRPCError{Code: -32700, Message: "parse error"},
})
return
}
@@ -414,12 +415,16 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
base.Error = &mcpRPCError{Code: -32602, Message: "invalid params: " + err.Error()}
base.Error = &mcpRPCError{Code: -32602, Message: "invalid parameters"}
return base
}
text, err := h.dispatch(ctx, workspaceID, params.Name, params.Arguments)
if err != nil {
base.Error = &mcpRPCError{Code: -32000, Message: err.Error()}
// Log full error server-side for forensics; return constant string
// to client per OFFSEC-001 / #259. WorkspaceAuth required — caller
// already authenticated, so this is defence-in-depth.
log.Printf("mcp: tool call failed workspace=%s tool=%s: %v", workspaceID, params.Name, err)
base.Error = &mcpRPCError{Code: -32000, Message: "tool call failed"}
return base
}
base.Result = map[string]interface{}{
@@ -1024,3 +1024,126 @@ func TestIsPrivateOrMetadataIP_PublicAllowed(t *testing.T) {
}
}
}
// TestMCPHandler_Call_MalformedJSON returns constant parse-error message.
// Per OFFSEC-001 / #259: err.Error() must not leak struct field names or
// JSON library internals in JSON-RPC error.message.
func TestMCPHandler_Call_MalformedJSON_ReturnsConstantParseError(t *testing.T) {
h, _ := newMCPHandler(t)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
// Valid JSON-RPC 2.0 envelope but JSON body is malformed.
c.Request = httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte("not valid json{][")))
c.Request.Header.Set("Content-Type", "application/json")
h.Call(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error, got nil")
}
// Message must be a constant — no err.Error() content.
if resp.Error.Message != "parse error" {
t.Errorf("error message should be constant 'parse error', got: %q", resp.Error.Message)
}
// Code must be -32700 (Parse error).
if resp.Error.Code != -32700 {
t.Errorf("error code should be -32700, got: %d", resp.Error.Code)
}
}
// TestMCPHandler_dispatchRPC_InvalidParams returns constant message.
// Per OFFSEC-001 / #259: err.Error() from json.Unmarshal must not be
// returned in JSON-RPC error.message.
func TestMCPHandler_dispatchRPC_InvalidParams_ReturnsConstantMessage(t *testing.T) {
h, _ := newMCPHandler(t)
// Valid JSON-RPC but params is a string (not an object) — invalid for tools/call.
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": "not an object", // string instead of object — json.Unmarshal fails
})
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error, got nil")
}
// Message must be a constant — no JSON library error content.
if resp.Error.Message != "invalid parameters" {
t.Errorf("error message should be constant 'invalid parameters', got: %q", resp.Error.Message)
}
if resp.Error.Code != -32602 {
t.Errorf("error code should be -32602 (Invalid params), got: %d", resp.Error.Code)
}
}
// TestMCPHandler_dispatchRPC_UnknownTool returns constant tool-failed message.
// Per OFFSEC-001 / #259: dispatch errors must not leak workspace IDs or
// internal paths. Note: this test exercises the dispatch path through
// dispatchRPC since dispatch is package-private.
func TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage(t *testing.T) {
h, _ := newMCPHandler(t)
// Valid params shape but tool name does not exist.
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": map[string]interface{}{
"name": "nonexistent_tool_xyz",
"arguments": map[string]interface{}{},
},
})
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error for unknown tool, got nil")
}
// Message must be a constant — no "unknown tool: nonexistent_tool_xyz" leak.
if resp.Error.Message != "tool call failed" {
t.Errorf("error message should be constant 'tool call failed', got: %q", resp.Error.Message)
}
if resp.Error.Code != -32000 {
t.Errorf("error code should be -32000 (Server error), got: %d", resp.Error.Code)
}
}
// TestMCPHandler_dispatchRPC_InvalidParams_NilParams covers the edge case
// where params is present but not an object (e.g. an array). json.Unmarshal
// into the params struct fails, and we assert the constant error message.
func TestMCPHandler_dispatchRPC_InvalidParams_ArrayInsteadOfObject(t *testing.T) {
h, _ := newMCPHandler(t)
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": []interface{}{"one", "two"}, // array instead of object
})
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error, got nil")
}
if resp.Error.Message != "invalid parameters" {
t.Errorf("error message should be constant 'invalid parameters', got: %q", resp.Error.Message)
}
}
@@ -112,7 +112,10 @@ func (h *PluginsHandler) WithInstanceIDLookup(lookup InstanceIDLookup) *PluginsH
// Sources returns the underlying plugin source registry. Used by main.go to
// pass the same registry to the drift sweeper so both share resolver state.
func (h *PluginsHandler) Sources() plugins.SourceResolver {
// Returns the narrow pluginSources interface so callers receive only the
// methods they need (Register, Resolve, Schemes), not the full SourceResolver
// contract with Fetch.
func (h *PluginsHandler) Sources() pluginSources {
return h.sources
}
@@ -120,7 +120,7 @@ func (h *WorkspaceHandler) resolveAgentURLForRestartSignal(ctx context.Context,
// Try Redis cache first.
agentURL, err := db.GetCachedURL(ctx, workspaceID)
if err == nil && agentURL != "" {
return rewriteForDocker(agentURL, workspaceID), nil
return h.rewriteForDocker(agentURL, workspaceID), nil
}
// Cache miss — fall back to DB.
@@ -136,13 +136,13 @@ func (h *WorkspaceHandler) resolveAgentURLForRestartSignal(ctx context.Context,
}
agentURL = *urlNullable
_ = db.CacheURL(ctx, workspaceID, agentURL)
return rewriteForDocker(agentURL, workspaceID), nil
return h.rewriteForDocker(agentURL, workspaceID), nil
}
// rewriteForDocker rewrites a 127.0.0.1 agent URL to the Docker-DNS form
// when the platform is running inside a Docker container. When platform is
// on the host (non-Docker), 127.0.0.1 IS the host and the original URL works.
func rewriteForDocker(agentURL, workspaceID string) string {
func (h *WorkspaceHandler) rewriteForDocker(agentURL, workspaceID string) string {
if platformInDocker && h.provisioner != nil {
// Only rewrite if the URL points to localhost (the ephemeral port
// binding the container published to the host). Internal Docker
@@ -97,10 +97,10 @@ func TestRewriteForDocker_LocalhostUrlRewritten(t *testing.T) {
// TestResolveAgentURLForRestartSignal_CacheHit verifies that a Redis-cached
// URL is returned without hitting the DB.
func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
mockDB, mock := setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
_ = setupTestDB(t) // db.DB must be set before setupTestRedisWithURL
_ = setupTestRedisWithURL(t, "http://cached.internal:9000/agent")
h := newHandlerWithTestDepsWithDB(t, mockDB)
h := newHandlerWithTestDeps(t)
// Redis cache hit → DB should NOT be queried
url, err := h.resolveAgentURLForRestartSignal(context.Background(), "ws-cache-hit-123")
@@ -110,19 +110,18 @@ func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
if url == "" {
t.Fatal("expected non-empty URL from cache")
}
// DB should not be queried (no rows returned to sqlmock)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled DB expectations: %v", err)
if url != "http://cached.internal:9000/agent" {
t.Errorf("expected cached URL, got %q", url)
}
}
// TestResolveAgentURLForRestartSignal_DBError verifies that a DB error is
// returned and propagated when neither Redis cache nor DB lookup succeeds.
func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
mockDB, mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
_ = setupTestRedis(t) // empty → cache miss
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
_ = setupTestRedis(t) // empty → cache miss
h := newHandlerWithTestDepsWithDB(t, mockDB)
h := newHandlerWithTestDeps(t)
mock.ExpectQuery(`SELECT url FROM workspaces WHERE id =`).
WithArgs("ws-db-err-789").
@@ -141,10 +140,10 @@ func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
// TestResolveAgentURLForRestartSignal_CacheMiss verifies that on Redis miss,
// the URL is fetched from the DB and cached.
func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
mockDB, mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
mr := setupTestRedis(t) // empty → cache miss
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
_ = setupTestRedis(t) // empty → cache miss
h := newHandlerWithTestDepsWithDB(t, mockDB)
h := newHandlerWithTestDeps(t)
mock.ExpectQuery(`SELECT url FROM workspaces WHERE id =`).
WithArgs("ws-cache-miss-456").
@@ -159,10 +158,12 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
t.Errorf("expected DB URL, got %q", url)
}
// Verify the URL was cached in Redis
cached, err := mr.Get(context.Background(), "ws:ws-cache-miss-456:url").Result()
// Verify the URL was cached in Redis via db.GetCachedURL.
// GetCachedURL takes workspaceID and builds the key internally, so
// pass "ws-cache-miss-456" (not the full "ws:ws-cache-miss-456:url").
cached, err := db.GetCachedURL(context.Background(), "ws-cache-miss-456")
if err != nil {
t.Fatalf("URL was not cached in Redis: %v", err)
t.Fatalf("URL cache read failed: %v", err)
}
if cached != "http://db.internal:8000/agent" {
t.Errorf("expected cached URL %q, got %q", "http://db.internal:8000/agent", cached)
@@ -175,9 +176,7 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
// TestGracefulPreRestart_Success verifies that when the workspace returns 200,
// the signal is logged as acknowledged without error.
func TestGracefulPreRestart_Success(t *testing.T) {
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
mr := setupTestRedisWithURL(t, "http://localhost:18000/agent")
_ = setupTestDB(t)
// httptest server simulating the workspace container's /signals/restart_pending
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -206,44 +205,40 @@ func TestGracefulPreRestart_Success(t *testing.T) {
})
}))
defer srv.Close()
mr.Set("ws:ws-ack-789:url", srv.URL, 5*time.Minute)
// Patch the handler's resolveAgentURLForRestartSignal to return the test server URL
// (avoids needing a real provisioner for this test)
h := newHandlerWithTestDeps(t)
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return srv.URL + "/agent", nil
// Pre-populate Redis cache with the test server URL
_ = setupTestRedisWithURL(t, srv.URL)
// Use an embedded struct to override resolveAgentURLForRestartSignal.
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: srv.URL + "/agent",
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
// gracefulPreRestart runs in a goroutine with its own timeout.
// We give it time to complete before the test ends.
h.gracefulPreRestart(context.Background(), "ws-ack-789")
hWrapper.gracefulPreRestart(context.Background(), "ws-ack-789")
time.Sleep(200 * time.Millisecond)
}
// TestGracefulPreRestart_NotImplemented verifies that when the workspace returns
// 404 (old SDK version), the platform proceeds gracefully (log + no error).
func TestGracefulPreRestart_NotImplemented(t *testing.T) {
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
mr := setupTestRedisWithURL(t, "http://localhost:18001/agent")
_ = setupTestDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
mr.Set("ws:ws-noimpl-999:url", srv.URL, 5*time.Minute)
h := newHandlerWithTestDeps(t)
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return srv.URL + "/agent", nil
_ = setupTestRedisWithURL(t, srv.URL)
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: srv.URL + "/agent",
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
h.gracefulPreRestart(context.Background(), "ws-noimpl-999")
hWrapper.gracefulPreRestart(context.Background(), "ws-noimpl-999")
time.Sleep(200 * time.Millisecond)
// No panic or error expected — graceful degradation
}
@@ -251,19 +246,17 @@ func TestGracefulPreRestart_NotImplemented(t *testing.T) {
// TestGracefulPreRestart_ConnectionRefused verifies that when the workspace
// is unreachable, the platform proceeds gracefully without error.
func TestGracefulPreRestart_ConnectionRefused(t *testing.T) {
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
_ = setupTestDB(t)
mr := setupTestRedisWithURL(t, "http://localhost:19999/agent") // nothing listening on 19999
mr.Set("ws:ws-unreachable-000:url", "http://localhost:19999/agent", 5*time.Minute)
_ = mr
h := newHandlerWithTestDeps(t)
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return "http://localhost:19999/agent", nil
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: "http://localhost:19999/agent",
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
h.gracefulPreRestart(context.Background(), "ws-unreachable-000")
hWrapper.gracefulPreRestart(context.Background(), "ws-unreachable-000")
time.Sleep(200 * time.Millisecond)
// No panic or error expected — proceeds with stop as documented
}
@@ -274,36 +267,35 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
_ = setupTestDB(t)
_ = setupTestRedis(t) // empty → URL resolution will fail in resolveAgentURLForRestartSignal
h := newHandlerWithTestDeps(t)
// Override resolveAgentURLForRestartSignal to return an error
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return "", context.DeadlineExceeded
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
errToReturn: context.DeadlineExceeded,
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
h.gracefulPreRestart(context.Background(), "ws-url-err-111")
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
time.Sleep(200 * time.Millisecond)
// No panic or error expected — proceeds with stop as documented
}
// ─── helpers ─────────────────────────────────────────────────────────────────
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
// provisioner is nil so rewriteForDocker returns URL unchanged.
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// resolveURLTestWrapper embeds *WorkspaceHandler and overrides
// resolveAgentURLForRestartSignal so tests can inject a fixed URL or error.
type resolveURLTestWrapper struct {
*WorkspaceHandler
testURL string
errToReturn error
}
// newHandlerWithTestDepsWithDB creates a WorkspaceHandler with a specific mock DB.
// Use this when you need to control the DB mock expectations.
func newHandlerWithTestDepsWithDB(t *testing.T, mockDB *sql.DB) *WorkspaceHandler {
// We need to temporarily replace db.DB with our mock
origDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = origDB })
func (w *resolveURLTestWrapper) resolveAgentURLForRestartSignal(ctx context.Context, workspaceID string) (string, error) {
if w.errToReturn != nil {
return "", w.errToReturn
}
return w.testURL, nil
}
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
}
@@ -314,7 +306,6 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
t.Fatalf("failed to start miniredis: %v", err)
}
db.RDB = redis.NewClient(&redis.Options{Addr: mr.Addr()})
// Pre-populate a URL for the test workspace IDs used in these tests
for _, wsID := range []string{"ws-cache-hit-123", "ws-cache-miss-456", "ws-ack-789", "ws-noimpl-999", "ws-unreachable-000"} {
if err := db.CacheURL(context.Background(), wsID, url); err != nil {
t.Fatalf("failed to cache URL for %s: %v", wsID, err)
@@ -322,9 +313,4 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
}
t.Cleanup(func() { mr.Close() })
return mr
}
// rewriteForDocker is exported from restart_signals.go so it can be tested here.
func (h *WorkspaceHandler) rewriteForDocker(agentURL, workspaceID string) string {
return rewriteForDocker(agentURL, workspaceID)
}
}
+16 -10
View File
@@ -248,6 +248,19 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// Begin a transaction so the workspace row and any initial secrets are
// committed atomically. A secret-encrypt or DB error rolls back the
// workspace insert so we never leave a workspace row with missing secrets.
// SSRF guard: validate workspace URL before starting any DB transaction.
// registry.go:324 calls this same guard for agent self-registration;
// the admin-create path must be covered too (core#212).
// Must stay above BeginTx so the rejection path never touches the DB.
if payload.URL != "" {
if err := validateAgentURL(payload.URL); err != nil {
log.Printf("Create: workspace URL rejected: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe workspace URL: " + err.Error()})
return
}
}
tx, txErr := db.DB.BeginTx(ctx, nil)
if txErr != nil {
log.Printf("Create workspace: begin tx error: %v", txErr)
@@ -383,16 +396,9 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
if payload.External || payload.Runtime == "external" {
var connectionToken string
if payload.URL != "" {
// SSRF guard (issue #212): validateAgentURL blocks cloud metadata
// IPs (169.254/16), loopback, link-local, and RFC-1918 in
// strict/self-hosted mode. AdminAuth is required here, but the
// admin token could be leaked or a compromised insider — defence
// in depth. Compare: registry.go:324 (heartbeat path) also
// calls validateAgentURL; external_rotate.go should too.
if err := validateAgentURL(payload.URL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe workspace URL: " + err.Error()})
return
}
// URL already validated by validateAgentURL above (before BeginTx).
// Now persist it: the external URL is set after the workspace row
// commits so that a failed URL UPDATE doesn't roll back the row.
db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = $2, runtime = 'external', updated_at = now() WHERE id = $3`, payload.URL, models.StatusOnline, id)
if err := db.CacheURL(ctx, id, payload.URL); err != nil {
log.Printf("External workspace: failed to cache URL for %s: %v", id, err)
@@ -537,17 +537,15 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// External URL update (SSRF-safe public URL passes validateAgentURL).
// External URL update (localhost is explicitly allowed by validateAgentURL).
mock.ExpectExec("UPDATE workspaces SET url").
WillReturnResult(sqlmock.NewResult(0, 1))
// CacheURL is non-fatal but still called.
mock.ExpectExec("SELECT").
WillReturnRows(sqlmock.NewRows([]string{"ok"}).AddRow("ok"))
// CacheURL is non-fatal — uses Redis (db.RDB, set by setupTestRedis), not the DB.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"https://agent.example.com/a2a"}`
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -9,7 +9,7 @@ package plugins
// 1. SELECTs workspace_plugins rows where tracked_ref != 'none'
// AND installed_sha IS NOT NULL (skip pre-migration rows with NULL SHA).
// 2. For each row, resolves the tracked ref to its current upstream SHA
// using the appropriate SourceResolver.
// using the appropriate PluginResolver.
// 3. If the resolved SHA differs from installed_sha → drift detected.
// 4. On drift, INSERT INTO plugin_update_queue (ON CONFLICT DO NOTHING so
// a re-drift while a row is still pending is a no-op).
@@ -61,20 +61,33 @@ const DriftSweepInterval = 1 * time.Hour
// that handles Gitea instances on high-latency links.
const ResolveRefDeadline = 60 * time.Second
// SourceResolver resolves plugin sources to installable directories.
// Satisfied by *Registry (which wraps GithubResolver + LocalResolver).
type SourceResolver interface {
// PluginResolver is the registry-level abstraction the sweeper consumes:
// pick a per-scheme SourceResolver for a parsed Source, and enumerate the
// registered schemes so we can strip the prefix from a stored source_raw.
//
// Resolve returns the production SourceResolver from source.go (NOT another
// PluginResolver) — that's the actual shape of *Registry.Resolve, and the
// sweeper only needs the per-scheme resolver's identity, not its Fetch.
//
// Named PluginResolver (not SourceResolver) to avoid redeclaring the
// per-scheme SourceResolver interface defined in source.go (core#228 fix).
// Satisfied by *Registry from source.go via Resolve + Schemes.
type PluginResolver interface {
Resolve(source Source) (SourceResolver, error)
Schemes() []string
}
// Compile-time assertion: *Registry satisfies PluginResolver. Catches any
// future drift in Registry.Resolve / Schemes signatures at build time.
var _ PluginResolver = (*Registry)(nil)
// StartPluginDriftSweeper runs the drift-detection loop until ctx is cancelled.
// Pass a nil resolver to disable the sweeper (useful for harnesses or CP/SaaS
// mode where git operations are unavailable).
//
// Registers itself via atexits in cmd/server/main.go so the process
// shuts down cleanly on SIGTERM.
func StartPluginDriftSweeper(ctx context.Context, resolver SourceResolver) {
func StartPluginDriftSweeper(ctx context.Context, resolver PluginResolver) {
if resolver == nil {
log.Println("Plugin drift sweeper: resolver is nil — sweeper disabled")
return
@@ -107,7 +120,7 @@ func StartPluginDriftSweeper(ctx context.Context, resolver SourceResolver) {
// sweepDriftOnce runs one full drift-detection cycle.
// Errors are non-fatal — each row is handled independently so a single
// slow row doesn't block the rest of the sweep.
func sweepDriftOnce(parent context.Context, resolver SourceResolver) {
func sweepDriftOnce(parent context.Context, resolver PluginResolver) {
ctx, cancel := context.WithTimeout(parent, 10*time.Minute)
defer cancel()
@@ -170,7 +183,7 @@ func sweepDriftOnce(parent context.Context, resolver SourceResolver) {
// resolveLatestSHA resolves the tracked ref to its current upstream SHA.
// Handles both github:// and local:// sources; local sources are skipped
// (no meaningful upstream to drift against).
func resolveLatestSHA(ctx context.Context, resolver SourceResolver, sourceRaw, trackedRef string) (string, error) {
func resolveLatestSHA(ctx context.Context, resolver PluginResolver, sourceRaw, trackedRef string) (string, error) {
// Strip the scheme prefix to get the raw spec.
// sourceRaw is stored as the full string, e.g. "github://owner/repo#tag:v1.0.0"
spec := sourceRaw
@@ -231,7 +244,7 @@ func queueDriftEntry(ctx context.Context, workspaceID, pluginName, trackedRef, c
// ─────────────────────────────────────────────────────────────────────────────
// SweepDriftOnceForTest exposes sweepDriftOnce for package-level testing.
func SweepDriftOnceForTest(parent context.Context, resolver SourceResolver) {
func SweepDriftOnceForTest(parent context.Context, resolver PluginResolver) {
sweepDriftOnce(parent, resolver)
}
@@ -2,12 +2,14 @@ package plugins
import (
"context"
"database/sql"
"errors"
"testing"
)
// stubResolver is a SourceResolver that always returns a stub github resolver.
// stubResolver is a PluginResolver that always returns a stub github
// resolver. *GithubResolver satisfies the production SourceResolver from
// source.go via Scheme() + Fetch(); the sweeper only uses Schemes() and
// Resolve(), so the returned resolver's Fetch is never invoked here.
type stubResolver struct {
schemes []string
}
@@ -156,8 +158,9 @@ func TestPluginUpdateQueueRow_Struct(t *testing.T) {
}
}
// TestSourceResolverInterface_StubResolver verifies that a stub resolver
// satisfies the SourceResolver interface.
func TestSourceResolverInterface_StubResolver(t *testing.T) {
var _ SourceResolver = (*stubResolver)(nil)
// TestPluginResolverInterface_StubResolver verifies that a stub resolver
// satisfies the PluginResolver interface (the sweeper-side abstraction
// over *Registry — distinct from the per-scheme SourceResolver in source.go).
func TestPluginResolverInterface_StubResolver(t *testing.T) {
var _ PluginResolver = (*stubResolver)(nil)
}
+76 -57
View File
@@ -27,7 +27,15 @@ import (
"github.com/gin-gonic/gin"
)
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.SourceResolver) *gin.Engine {
// Setup wires the gin router. pluginResolver is the registry-level resolver
// (typically *plugins.Registry from main.go) reserved for future per-deploy
// customisation — currently passed only to satisfy the call-site contract;
// plgh (PluginsHandler) constructs its own internal registry with the
// default github+local resolvers via NewPluginsHandler. The drift sweeper
// (main.go) gets the same pluginResolver instance so it can share scheme
// enumeration if a deployment registers extra schemes externally. A nil
// pluginResolver is harmless: plgh still works with its built-in defaults.
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.PluginResolver) *gin.Engine {
r := gin.Default()
// Issue #179 — trust no reverse-proxy headers. Without this call Gin's
@@ -499,6 +507,72 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
r.POST("/admin/workspace-images/refresh", middleware.AdminAuth(db.DB), imgH.Refresh)
}
// dockerCli is shared across plugins, terminal, templates, and bundle
// handlers. Declared up-front (was at line ~594) because the plugins
// init block — moved here in 70f84823 to fix "undefined: plgh" — needs
// dockerCli at construction time (NewPluginsHandler signature). Moving
// only the plgh block left dockerCli used-before-declared. Same nil
// guard semantics: prov nil → dockerCli nil → handlers fall back to
// non-Docker paths or skip Docker-dependent routes.
var dockerCli *client.Client
if prov != nil {
dockerCli = prov.DockerClient()
}
// Plugins — plgh must be initialized before the drift handler that uses it.
// Moved here (core#248 fix) because the drift handler block (core#123) was
// registered before plgh was created, causing "undefined: plgh" on main.
pluginsDir := findPluginsDir(configsDir)
// Runtime lookup lets the plugins handler filter the registry to plugins
// that declare support for the workspace's runtime, without taking a
// direct DB dependency in the handler package.
runtimeLookup := func(workspaceID string) (string, error) {
var runtime string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&runtime)
return runtime, err
}
// Instance-id lookup powers the SaaS dispatch in install/uninstall:
// when a workspace is on the EC2-per-workspace backend (instance_id
// non-NULL) and there's no local Docker container to exec into, the
// pipeline pushes the staged plugin tarball to that EC2 over EIC SSH.
// Empty result means the workspace lives on the local-Docker backend
// (or hasn't been provisioned yet) and the handler falls back to its
// original Docker path. Same pattern templates.go and terminal.go use.
instanceIDLookup := func(workspaceID string) (string, error) {
var instanceID string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&instanceID)
return instanceID, err
}
// plgh constructs its own internal registry (github + local) inside
// NewPluginsHandler. The pluginResolver param is the SHARED registry the
// drift sweeper consumes (main.go); we don't graft it onto plgh because
// plgh's WithSourceResolver expects a per-scheme SourceResolver, not a
// PluginResolver/registry. Cross-wiring those types was the original
// "*Registry doesn't implement SourceResolver" build break (core#228).
// Use of pluginResolver here is intentionally read-side only.
_ = pluginResolver
plgh := handlers.NewPluginsHandler(pluginsDir, dockerCli, wh.RestartByID).
WithRuntimeLookup(runtimeLookup).
WithInstanceIDLookup(instanceIDLookup)
r.GET("/plugins", plgh.ListRegistry)
r.GET("/plugins/sources", plgh.ListSources)
wsAuth.GET("/plugins", plgh.ListInstalled)
wsAuth.GET("/plugins/available", plgh.ListAvailableForWorkspace)
wsAuth.GET("/plugins/compatibility", plgh.CheckRuntimeCompatibility)
wsAuth.POST("/plugins", plgh.Install)
wsAuth.DELETE("/plugins/:name", plgh.Uninstall)
// Phase 30.3 — stream plugin as tar.gz so remote agents can pull +
// unpack locally instead of going through Docker exec.
wsAuth.GET("/plugins/:name/download", plgh.Download)
// Admin — plugin version-subscription drift queue (core#123).
// List pending drift entries and apply approved updates.
{
@@ -537,11 +611,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.GET("/github-installation-token", ghTokH.GetInstallationToken)
}
// Terminal — shares Docker client with provisioner
var dockerCli *client.Client
if prov != nil {
dockerCli = prov.DockerClient()
}
// Terminal — shares Docker client with provisioner (declared above).
th := handlers.NewTerminalHandler(dockerCli)
wsAuth.GET("/terminal", th.HandleConnect)
wsAuth.GET("/terminal/diagnose", th.HandleDiagnose)
@@ -595,57 +665,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.GET("/pending-uploads/:file_id/content", puh.GetContent)
wsAuth.POST("/pending-uploads/:file_id/ack", puh.Ack)
// Plugins
pluginsDir := findPluginsDir(configsDir)
// Runtime lookup lets the plugins handler filter the registry to plugins
// that declare support for the workspace's runtime, without taking a
// direct DB dependency in the handler package.
runtimeLookup := func(workspaceID string) (string, error) {
var runtime string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&runtime)
return runtime, err
}
// Instance-id lookup powers the SaaS dispatch in install/uninstall:
// when a workspace is on the EC2-per-workspace backend (instance_id
// non-NULL) and there's no local Docker container to exec into, the
// pipeline pushes the staged plugin tarball to that EC2 over EIC SSH.
// Empty result means the workspace lives on the local-Docker backend
// (or hasn't been provisioned yet) and the handler falls back to its
// original Docker path. Same pattern templates.go and terminal.go use.
instanceIDLookup := func(workspaceID string) (string, error) {
var instanceID string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&instanceID)
return instanceID, err
}
// pluginResolver: when provided (normal production), use it for plgh so
// the drift sweeper (which also gets the same resolver in main.go) uses
// identical resolver state. When nil (test / backward compat), let
// NewPluginsHandler create its own default registry.
plgh := handlers.NewPluginsHandler(pluginsDir, dockerCli, wh.RestartByID).
WithRuntimeLookup(runtimeLookup).
WithInstanceIDLookup(instanceIDLookup)
if pluginResolver != nil {
plgh = plgh.WithSourceResolver(pluginResolver)
}
r.GET("/plugins", plgh.ListRegistry)
r.GET("/plugins/sources", plgh.ListSources)
wsAuth.GET("/plugins", plgh.ListInstalled)
wsAuth.GET("/plugins/available", plgh.ListAvailableForWorkspace)
wsAuth.GET("/plugins/compatibility", plgh.CheckRuntimeCompatibility)
wsAuth.POST("/plugins", plgh.Install)
wsAuth.DELETE("/plugins/:name", plgh.Uninstall)
// Phase 30.3 — stream plugin as tar.gz so remote agents can pull +
// unpack locally instead of going through Docker exec.
wsAuth.GET("/plugins/:name/download", plgh.Download)
// Bundles — #164 + #165: both gated behind AdminAuth.
// POST /bundles/import — CRITICAL: anon creation of arbitrary workspaces
// with user-supplied config (system prompts,