## Problem
Two issues the external-workspace path was silently dropping:
1. `knownRuntimes` was a hardcoded Go map that drifted from
manifest.json — e.g. `gemini-cli` was in manifest but missing
from the Go allowlist, so any workspace provisioning with
runtime=gemini-cli got silently coerced to langgraph.
2. No end-to-end "bring your own compute" story. The canvas UI
had no way to pick runtime=external; the partial backend code
required the operator to already have a URL ready (chicken-and-
egg with the agent that doesn't exist yet), and no workspace_auth
_token was minted so the external agent couldn't authenticate its
register call.
## Change
### Runtime registry driven by manifest.json
- New `runtime_registry.go` reads `manifest.json` at service init.
Each `workspace_templates[].name` becomes a runtime identifier
(with the `-default` suffix stripped so `claude-code-default`
and `claude-code` resolve to the same runtime).
- `external` is always injected (no template repo exists for it).
- Falls back to a static map on manifest load failure so tests /
dev containers keep working.
- 5 new tests including a real-manifest sanity check.
### First-class external workspace flow
When `POST /workspaces` is called with `runtime: "external"` AND
no URL supplied:
1. Workspace row inserted with `status='awaiting_agent'`
(distinct from `provisioning` so canvas doesn't trip its
provisioning-timeout UX).
2. A workspace_auth_token is minted via `wsauth.IssueToken`.
3. Response body includes a `connection` object with:
- `workspace_id`, `platform_url`, `auth_token`
- `registry_endpoint`, `heartbeat_endpoint`
- `curl_register_template` — zero-dep one-shot register snippet
- `python_snippet` — full SDK setup w/ heartbeat loop,
paired with molecule-sdk-python PR #13's A2AServer
4. The platform URL is resolved from `EXTERNAL_PLATFORM_URL` env
(ops-configurable per tenant) or falls back to request headers.
The legacy `payload.External` + `payload.URL` path is preserved —
org-import and other callers that already have a URL still work.
### Canvas UI
- New "External agent (bring your own compute)" checkbox in
CreateWorkspaceDialog.
- When checked, template/model/hermes-provider fields are hidden
and the POST body includes `runtime: "external"`.
- New `ExternalConnectModal` component: shown once after create,
renders Python / curl / raw-fields tabs with copy-to-clipboard
buttons. Stays mounted as a sibling of the create dialog so the
token survives the create dialog unmount.
- `auth_token` is interpolated into the snippet client-side so the
copied block is truly ready to run — operator only has to fill
in their agent's public URL.
## Tests
- Go: 5 new runtime_registry tests (happy path, -default strip,
external always injected, missing file, malformed JSON, real
manifest sanity). All existing handler tests still pass.
- TypeScript: no type errors on my files; pre-existing
canvas-batch-partial-failure type drift is on main already and
tracked on the #2061 branch.
## Follow-ups (filed separately)
- Cut molecule-sdk-python v0.y to PyPI so the snippet can use
`pip install molecule-ai-sdk` instead of `git+main`.
- Add a `runtime: string` field per template in manifest.json so
one template can declare its runtime explicitly (instead of
deriving it from name conventions). Unblocks N-templates-per-
runtime (e.g. hermes-minimax, hermes-anthropic both runtime=hermes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
3.8 KiB
Go
107 lines
3.8 KiB
Go
package handlers
|
|
|
|
// external_connection.go — copy-paste connection payload shown once to
|
|
// the operator when they create a runtime="external" workspace.
|
|
//
|
|
// The canvas UI surfaces these in a single modal so the operator can
|
|
// hand the block to whoever runs their external agent without having
|
|
// to piece together workspace_id + platform_url + auth_token + API
|
|
// shape from the docs. curl snippet has zero dependencies; Python
|
|
// snippet pairs with molecule-sdk-python's A2AServer + RemoteAgentClient.
|
|
|
|
import (
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// externalPlatformURL returns the public URL at which this workspace-
|
|
// server instance is reachable by the operator's external agent. This
|
|
// is NOT necessarily the caller's Host header (which could be an
|
|
// internal CF tunnel hostname). Prefer the EXTERNAL_PLATFORM_URL env
|
|
// that Railway/ops sets for the tenant; fall back to the request's
|
|
// Host + scheme if unset.
|
|
func externalPlatformURL(c *gin.Context) string {
|
|
if v := os.Getenv("EXTERNAL_PLATFORM_URL"); v != "" {
|
|
return v
|
|
}
|
|
scheme := "https"
|
|
if xf := c.Request.Header.Get("X-Forwarded-Proto"); xf != "" {
|
|
scheme = xf
|
|
} else if c.Request.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
host := c.Request.Host
|
|
if xh := c.Request.Header.Get("X-Forwarded-Host"); xh != "" {
|
|
host = xh
|
|
}
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
// externalCurlTemplate — zero-dependency register snippet. Placeholders:
|
|
// - {{PLATFORM_URL}}, {{WORKSPACE_ID}} — filled server-side
|
|
// - $WORKSPACE_AUTH_TOKEN — env var, operator sets
|
|
// - $AGENT_URL — env var, operator's public HTTPS endpoint
|
|
//
|
|
// SSRF filter rejects private IPs at register time, so AGENT_URL must
|
|
// resolve to a public host.
|
|
//
|
|
// Heartbeat loop is NOT included here — curl is fine for one-shot
|
|
// register; keeping the workspace alive wants a real loop, so point
|
|
// operators at the Python snippet for long-lived setups.
|
|
const externalCurlTemplate = `# Replace AGENT_URL with YOUR agent's public HTTPS endpoint, then run:
|
|
export WORKSPACE_AUTH_TOKEN="<paste from create response>"
|
|
export AGENT_URL="https://your-agent.example.com"
|
|
|
|
curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
|
|
-H "Authorization: Bearer $WORKSPACE_AUTH_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"id": "{{WORKSPACE_ID}}",
|
|
"url": "'"$AGENT_URL"'",
|
|
"agent_card": {
|
|
"name": "My External Agent",
|
|
"description": "",
|
|
"version": "0.1.0"
|
|
}
|
|
}'
|
|
`
|
|
|
|
// externalPythonTemplate uses molecule-sdk-python's RemoteAgentClient +
|
|
// A2AServer (PR #13 in that repo). Until the SDK cuts a v0.y release
|
|
// to PyPI the snippet pins git+main.
|
|
const externalPythonTemplate = `# pip install 'git+https://github.com/Molecule-AI/molecule-sdk-python.git@main'
|
|
|
|
import asyncio
|
|
from molecule_agent import RemoteAgentClient, A2AServer
|
|
|
|
WORKSPACE_ID = "{{WORKSPACE_ID}}"
|
|
PLATFORM_URL = "{{PLATFORM_URL}}"
|
|
AUTH_TOKEN = "<paste from create response>"
|
|
INBOUND_URL = "https://your-agent.example.com/a2a/inbound" # your public HTTPS endpoint
|
|
|
|
async def handle(request: dict) -> dict:
|
|
# request has parts, message, task_id, idempotency_key
|
|
text = "".join(p.get("text", "") for p in request.get("parts", []) if p.get("type") == "text")
|
|
return {"parts": [{"type": "text", "text": f"echo: {text}"}]}
|
|
|
|
async def main():
|
|
client = RemoteAgentClient(
|
|
workspace_id=WORKSPACE_ID,
|
|
platform_url=PLATFORM_URL,
|
|
auth_token=AUTH_TOKEN,
|
|
)
|
|
server = A2AServer(
|
|
agent_id=client.workspace_id,
|
|
inbound_url=INBOUND_URL,
|
|
message_handler=handle,
|
|
)
|
|
server.start_in_background()
|
|
client.reported_url = INBOUND_URL
|
|
client.register() # one-shot announcement
|
|
await client.run_heartbeat_loop_async() # keeps the workspace online
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|
|
`
|