From e3c58159d483e85377ce825b90a8a125df72cc7c Mon Sep 17 00:00:00 2001 From: Andrey Gruzdev Date: Fri, 22 May 2026 11:07:19 +0200 Subject: [PATCH 01/26] feat: scaffold pi project harness --- README.md | 29 +++++-- deploy/pi-version | 1 + deploy/system-setup.sh | 9 ++ deploy/tools-install.sh | 24 +++++- deploy/verify-installation.sh | 14 +++ internal/project/manager.go | 11 ++- internal/project/manager_test.go | 32 +++++++ internal/project/pi_harness.go | 63 ++++++++++++++ internal/project/templates/pi/AGENTS.md | 33 +++++++ .../pi/extensions/appx-guardrails.ts | 86 +++++++++++++++++++ internal/project/templates/pi/settings.json | 4 + .../templates/pi/skills/appx-egress/SKILL.md | 26 ++++++ .../pi/skills/appx-egress/request_egress.py | 55 ++++++++++++ 13 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 deploy/pi-version create mode 100644 internal/project/pi_harness.go create mode 100644 internal/project/templates/pi/AGENTS.md create mode 100644 internal/project/templates/pi/extensions/appx-guardrails.ts create mode 100644 internal/project/templates/pi/settings.json create mode 100644 internal/project/templates/pi/skills/appx-egress/SKILL.md create mode 100644 internal/project/templates/pi/skills/appx-egress/request_egress.py diff --git a/README.md b/README.md index f670a69..5035648 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ If you want to use a persistent volume for storage (e.g. Hetzner Cloud Volumes), The config is saved to `/etc/appx/appx.env` and reused on subsequent runs. To change it later: `sudo nano /etc/appx/appx.env && sudo systemctl restart appx`. -Bootstrap then creates OS users with proper isolation, installs tools (Node.js, OpenCode, Claude Code, uv), sets up systemd services, starts everything, and runs a verification suite. +Bootstrap then creates OS users with proper isolation, installs tools (Node.js, OpenCode, Pi, Claude Code, uv), sets up systemd services, starts everything, and runs a verification suite. During Opencode installation you might be prompted "opencode is installed to /usr/local/bin/opencode and may be managed by a package manager". Select `Install anyways? Yes` @@ -94,6 +94,7 @@ Bootstrap installs these tools system-wide so agents can use them in the termina - **Node.js 24 / npm** — JavaScript/TypeScript projects (installed via nvm, pinned to major version 24) - **uv** — Python version and package management (self-update: `uv self update`) - **OpenCode** — AI agent backend (pinned version in `deploy/opencode-version`) +- **Pi** — AI coding agent CLI/SDK for the Pi migration path (pinned version in `deploy/pi-version`) - **Claude Code** — Claude CLI for terminal use (self-update: `sudo npm update -g @anthropic-ai/claude-code`) ### Updating appx @@ -105,7 +106,7 @@ cd /srv/appx task server:deploy ``` -Pulls latest code, rebuilds, installs the binary, updates OpenCode to the pinned version, and restarts both services. +Pulls latest code, rebuilds, installs the binary, updates OpenCode and Pi to the pinned versions, and restarts both services. ### Updating OpenCode version @@ -116,6 +117,15 @@ cd /srv/appx task server:deploy ``` +### Updating Pi version + +Edit `deploy/pi-version` to the new version, then: + +```bash +cd /srv/appx +task server:deploy +``` + ### Updating Claude Code ```bash @@ -144,10 +154,11 @@ journalctl -u opencode -f # opencode logs | File / Script | When | What | | ------------------------------- | ---------------- | ---------------------------------------------------------- | | `deploy/bootstrap.sh` | Day 1 | Full setup: users, dirs, tools, build, start, verify | -| `deploy/system-setup.sh` | Infra changes | Users, groups, directories, service files, opencode config | -| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, OpenCode (pinned), Claude Code, uv | +| `deploy/system-setup.sh` | Infra changes | Users, groups, directories, service files, agent config | +| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, OpenCode, Pi, Claude Code, uv | | `deploy/opencode.json` | Model changes | Default OpenCode model config (copied to opencode home) | | `deploy/opencode-version` | Version pin | Pinned OpenCode version installed by tools-install | +| `deploy/pi-version` | Version pin | Pinned Pi version installed by tools-install | | `deploy/verify-installation.sh` | After any change | Full system verification | ## Local development @@ -179,6 +190,12 @@ All state lives in the data directory (configured during bootstrap, default `/va | SQLite DB, TLS certs, secrets | `{data}/.appx-internals/` | appx only | | Project directories | `{data}/projects/` | shared | +Each new project is scaffolded with a project-local Pi harness under +`{data}/projects//.pi/`: an Appx-specific prompt, first-party guardrail +extension, egress skill helper, and `settings.json` for reviewed/pinned Pi +packages. Third-party Pi packages are not installed by default because they run +inside the agent process. + To use a mounted volume, specify the path when bootstrap prompts for "Data directory". Bootstrap automatically creates the subdirectories with correct permissions. ## Subdomain routing without a domain (sslip.io) @@ -232,11 +249,11 @@ Bootstrap creates two OS users with a shared `projects` group: ``` appx — runs the appx server, owns DB and TLS certs -opencode — runs OpenCode, cannot access appx data +opencode — runs OpenCode and Pi agent tooling, cannot access appx data projects — shared group, both users read/write project directories ``` -Directory permissions prevent OpenCode (and any agent it spawns) from accessing the appx database, TLS keys, or binary. Project directories use setgid so files created by either user are accessible to both. +Directory permissions prevent agent tooling from accessing the appx database, TLS keys, or binary. Project directories use setgid so files created by either user are accessible to both. ## Development diff --git a/deploy/pi-version b/deploy/pi-version new file mode 100644 index 0000000..62474aa --- /dev/null +++ b/deploy/pi-version @@ -0,0 +1 @@ +0.75.4 diff --git a/deploy/system-setup.sh b/deploy/system-setup.sh index f5c2274..5a9474c 100755 --- a/deploy/system-setup.sh +++ b/deploy/system-setup.sh @@ -127,6 +127,15 @@ else echo "agents rules already exist: $OC_AGENTS_FILE" fi +# Pi agent config/auth/cache dir. Pi is project-local for prompts, skills, and +# extensions, but auth/models/settings that should not live in project repos go +# under the agent user's private home directory. +PI_AGENT_DIR="/home/opencode/.pi/agent" +install -d -o opencode -g opencode -m 700 "$PI_AGENT_DIR" +install -d -o opencode -g opencode -m 700 "$PI_AGENT_DIR/npm" +install -d -o opencode -g opencode -m 700 "$PI_AGENT_DIR/git" +echo "directory ready: $PI_AGENT_DIR (opencode:opencode 700)" + # --------------------------------------------------------------------------- # Appx binary permissions (if binary already deployed) # --------------------------------------------------------------------------- diff --git a/deploy/tools-install.sh b/deploy/tools-install.sh index 92cd7ed..e055e93 100755 --- a/deploy/tools-install.sh +++ b/deploy/tools-install.sh @@ -8,7 +8,8 @@ # - Go (version pinned to go.mod — build tool) # - Task (taskfile.dev build runner — build tool) # - Node.js 24 (via nvm, pinned to major version — runtime + agents) -# - OpenCode (AI agent backend, version pinned to deploy/opencode-version) +# - OpenCode (legacy AI agent backend, version pinned to deploy/opencode-version) +# - Pi (AI coding agent CLI/SDK, version pinned to deploy/pi-version) # - Claude Code (Claude CLI for terminal use — self-update: npm update -g @anthropic-ai/claude-code) # - uv (Python version/package manager — self-update: uv self update) # @@ -139,6 +140,26 @@ else echo "opencode installed: $(/usr/local/bin/opencode --version 2>/dev/null)" fi +# --------------------------------------------------------------------------- +# Pi coding agent (installed via npm, pinned to deploy/pi-version) +# --------------------------------------------------------------------------- + +PI_VERSION="" +if [ -f "$SCRIPT_DIR/pi-version" ]; then + PI_VERSION=$(cat "$SCRIPT_DIR/pi-version" | tr -d '[:space:]') +fi + +CURRENT_PI=$(/usr/local/bin/pi --version 2>/dev/null || echo "") + +if [ -n "$PI_VERSION" ] && [ "$CURRENT_PI" = "$PI_VERSION" ]; then + echo "pi already at $PI_VERSION" +else + echo "installing pi${PI_VERSION:+ $PI_VERSION} via npm..." + npm install -g "@earendil-works/pi-coding-agent@${PI_VERSION:-latest}" + ln -sf "$NODE_BIN_DIR/pi" /usr/local/bin/pi + echo "pi installed: $(/usr/local/bin/pi --version 2>/dev/null)" +fi + # --------------------------------------------------------------------------- # Claude Code (self-update: sudo npm update -g @anthropic-ai/claude-code) # --------------------------------------------------------------------------- @@ -185,4 +206,5 @@ echo " go: $(go version 2>/dev/null || echo 'not found')" echo " node: $(/usr/local/bin/node --version 2>/dev/null || echo 'not found')" echo " uv: $(/usr/local/bin/uv --version 2>/dev/null || echo 'not found')" echo " opencode: $(/usr/local/bin/opencode --version 2>/dev/null || echo 'not found')" +echo " pi: $(/usr/local/bin/pi --version 2>/dev/null || echo 'not found')" echo " claude: $(claude --version 2>/dev/null || echo 'not found')" diff --git a/deploy/verify-installation.sh b/deploy/verify-installation.sh index 91f520d..f7d6954 100755 --- a/deploy/verify-installation.sh +++ b/deploy/verify-installation.sh @@ -119,6 +119,9 @@ expect_ok "opencode config sets anthropic model" \ grep -q '"anthropic/' /home/opencode/.config/opencode/opencode.json expect_ok "opencode AGENTS.md exists" \ test -f /home/opencode/.config/opencode/AGENTS.md +expect_ok "pi agent dir exists" test -d /home/opencode/.pi/agent +expect_eq "pi agent dir is opencode:opencode 700" \ + "$(stat -c '%U:%G %a' /home/opencode/.pi/agent 2>/dev/null)" "opencode:opencode 700" # --------------------------------------------------------------------------- echo "" @@ -187,6 +190,7 @@ ACTUAL_NODE_MAJOR=$(/usr/local/bin/node --version 2>/dev/null | sed 's/^v//' | c expect_eq "node major version is $EXPECTED_NODE_MAJOR" \ "$ACTUAL_NODE_MAJOR" "$EXPECTED_NODE_MAJOR" expect_ok "opencode binary in /usr/local/bin" test -x /usr/local/bin/opencode +expect_ok "pi binary in /usr/local/bin" test -x /usr/local/bin/pi expect_ok "uv binary in /usr/local/bin" test -x /usr/local/bin/uv EXPECTED_OC_VERSION="" @@ -200,6 +204,16 @@ if [ -n "$EXPECTED_OC_VERSION" ]; then "$ACTUAL_OC_VERSION" "$EXPECTED_OC_VERSION" fi +EXPECTED_PI_VERSION="" +if [ -f "$SCRIPT_DIR/pi-version" ]; then + EXPECTED_PI_VERSION=$(cat "$SCRIPT_DIR/pi-version" | tr -d '[:space:]') +fi +if [ -n "$EXPECTED_PI_VERSION" ]; then + ACTUAL_PI_VERSION=$(/usr/local/bin/pi --version 2>/dev/null || echo "unknown") + expect_eq "pi version matches deploy/pi-version" \ + "$ACTUAL_PI_VERSION" "$EXPECTED_PI_VERSION" +fi + # Claude is optional (requires Node.js) — report status without failing. if [ -x /usr/local/bin/claude ]; then echo " INFO claude installed: $(/usr/local/bin/claude --version 2>/dev/null || echo 'unknown')" diff --git a/internal/project/manager.go b/internal/project/manager.go index e4ee30b..82137fa 100644 --- a/internal/project/manager.go +++ b/internal/project/manager.go @@ -8,8 +8,10 @@ import ( "strings" ) -// agentsTemplate is the AGENTS.md content scaffolded into every new project -// directory. Placeholders {{name}}, {{port}}, {{subdomain}} are replaced at creation. +// agentsTemplate is the root AGENTS.md content scaffolded into every new +// project directory for the legacy OpenCode runtime. The Pi runtime reads the +// richer project-local harness at .pi/AGENTS.md; keep this root file small and +// compatible with both runtimes. const agentsTemplate = `# Project: {{name}} ## App Port @@ -23,6 +25,7 @@ Your app will be accessible at {{subdomain}}. - Use this port for ALL dev servers (Vite, Next.js, Express, etc.) - Do not change the port — it is mapped to a subdomain by the appx proxy - The project directory is the working directory for all commands +- Pi-specific prompt, skill, and extension assets are in .pi/ ` // Manager provides project lifecycle operations. It delegates to the Store for @@ -129,6 +132,10 @@ func (m *Manager) scaffoldProject(dir string, proj *Project) error { return fmt.Errorf("write AGENTS.md: %w", err) } + if err := scaffoldPiHarness(dir, proj, domain); err != nil { + return fmt.Errorf("write .pi harness: %w", err) + } + if err := runGit(dir, "init"); err != nil { return fmt.Errorf("git init: %w", err) } diff --git a/internal/project/manager_test.go b/internal/project/manager_test.go index 3a07fa0..9ab34cc 100644 --- a/internal/project/manager_test.go +++ b/internal/project/manager_test.go @@ -96,6 +96,29 @@ func TestManagerCreate_CreatesDirectory(t *testing.T) { t.Errorf("AGENTS.md missing project name, content: %s", content) } + // Pi harness exists with prompt, settings, first-party extension, and skill helper. + piAgentsPath := filepath.Join(projectDir, ".pi", "AGENTS.md") + piAgents, err := os.ReadFile(piAgentsPath) + if err != nil { + t.Fatalf(".pi/AGENTS.md not created: %v", err) + } + if !strings.Contains(string(piAgents), "10000") { + t.Errorf(".pi/AGENTS.md missing port number, content: %s", piAgents) + } + if !strings.Contains(string(piAgents), "my-app") { + t.Errorf(".pi/AGENTS.md missing project name, content: %s", piAgents) + } + for _, path := range []string{ + filepath.Join(projectDir, ".pi", "settings.json"), + filepath.Join(projectDir, ".pi", "extensions", "appx-guardrails.ts"), + filepath.Join(projectDir, ".pi", "skills", "appx-egress", "SKILL.md"), + filepath.Join(projectDir, ".pi", "skills", "appx-egress", "request_egress.py"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("Pi harness file not created: %s: %v", path, err) + } + } + // Project has correct assigned port if p.AssignedPort != 10000 { t.Errorf("expected port 10000, got %d", p.AssignedPort) @@ -206,6 +229,15 @@ func TestManagerCreate_AGENTSmdUsesBaseDomain(t *testing.T) { if strings.Contains(string(content), ".localhost") { t.Errorf("expected AGENTS.md to NOT contain '.localhost' when baseDomain is set, got:\n%s", content) } + + piAgentsPath := filepath.Join(mgr.ProjectRoot, "my-app", ".pi", "AGENTS.md") + piContent, err := os.ReadFile(piAgentsPath) + if err != nil { + t.Fatalf(".pi/AGENTS.md not created: %v", err) + } + if !strings.Contains(string(piContent), "my-app.user.appx.app") { + t.Errorf("expected .pi/AGENTS.md to contain 'my-app.user.appx.app', got:\n%s", piContent) + } } func TestManagerProjectDir_ReturnsPath(t *testing.T) { diff --git a/internal/project/pi_harness.go b/internal/project/pi_harness.go new file mode 100644 index 0000000..296d632 --- /dev/null +++ b/internal/project/pi_harness.go @@ -0,0 +1,63 @@ +package project + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// piHarnessFS contains the project-local Pi prompt, skills, extensions, and +// settings that Appx scaffolds into every project under .pi/. +// +//go:embed templates/pi +var piHarnessFS embed.FS + +func scaffoldPiHarness(projectDir string, proj *Project, baseDomain string) error { + replacements := map[string]string{ + "{{name}}": proj.Name, + "{{port}}": fmt.Sprintf("%d", proj.AssignedPort), + "{{subdomain}}": fmt.Sprintf("%s.%s", proj.Name, baseDomain), + } + + return fs.WalkDir(piHarnessFS, "templates/pi", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel("templates/pi", path) + if err != nil { + return err + } + if rel == "." { + return nil + } + + target := filepath.Join(projectDir, ".pi", filepath.FromSlash(rel)) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + + content, err := piHarnessFS.ReadFile(path) + if err != nil { + return err + } + text := string(content) + for old, next := range replacements { + text = strings.ReplaceAll(text, old, next) + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + mode := fs.FileMode(0644) + if strings.HasSuffix(target, ".py") { + mode = 0755 + } + if err := os.WriteFile(target, []byte(text), mode); err != nil { + return err + } + return nil + }) +} diff --git a/internal/project/templates/pi/AGENTS.md b/internal/project/templates/pi/AGENTS.md new file mode 100644 index 0000000..0b02ff4 --- /dev/null +++ b/internal/project/templates/pi/AGENTS.md @@ -0,0 +1,33 @@ +# Project: {{name}} + +You are the Appx project agent for this workspace. + +## App Port + +Run the app on port {{port}}. This port is assigned by Appx and is already +proxied to: + +{{subdomain}} + +Use this port for every dev server, preview server, API server, or WebSocket +server started for this project. + +## Workflow + +- Build the actual app or tool requested by the user as the first screen. +- Prefer simple, inspectable project structure and commands that work from this + workspace root. +- Keep generated secrets, tokens, certificates, and local data out of commits. +- Do not modify Appx internals, system service files, or files outside this + project unless the user explicitly asks. +- If a command needs network access and fails because Appx egress blocked it, + use the `appx-egress` skill helper to request only the exact host and port + needed, then retry after approval. + +## UI + +- Match the product being built. Operational tools should be dense, calm, and + scannable. Games and creative apps can be more expressive. +- Make controls feature-complete and usable; do not ship a marketing page when + the user asked for an app or tool. +- Keep text inside its containers at mobile and desktop sizes. diff --git a/internal/project/templates/pi/extensions/appx-guardrails.ts b/internal/project/templates/pi/extensions/appx-guardrails.ts new file mode 100644 index 0000000..1632ea4 --- /dev/null +++ b/internal/project/templates/pi/extensions/appx-guardrails.ts @@ -0,0 +1,86 @@ +/** + * Appx guardrails for Pi sessions. + * + * These are intentionally first-party and project-local. They avoid silent + * third-party code execution while still giving Appx a UI-mediated approval + * path for destructive commands through agent-server's extension UI bridge. + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const destructiveBashPatterns: Array<{ pattern: RegExp; label: string }> = [ + { pattern: /\brm\s+(-[^\s]*r[^\s]*f|-rf|-fr|--recursive)\b/i, label: "recursive delete" }, + { pattern: /\bsudo\b/i, label: "sudo" }, + { pattern: /\b(chmod|chown)\b\s+(-R|--recursive)\b/i, label: "recursive permission/owner change" }, + { pattern: /\bchmod\b[^\n;&|]*\b777\b/i, label: "world-writable permissions" }, + { pattern: /\b(dd|mkfs|mount|umount|fdisk|parted)\b/i, label: "disk/system command" }, + { pattern: /\bkill\s+-9\b/i, label: "force kill" }, +]; + +const protectedPathFragments = [ + "/.appx-internals/", + "/etc/appx/", + "/usr/local/bin/appx", + "/home/opencode/.pi/agent/auth.json", + "/home/opencode/.config/opencode/", + "~/.pi/agent/auth.json", + ".git/", + "node_modules/", + ".env", + ".env.", + ".pem", + ".key", + ".p12", +]; + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function commandRisk(command: string): string | undefined { + return destructiveBashPatterns.find((entry) => entry.pattern.test(command))?.label; +} + +function pathRisk(filePath: string): string | undefined { + const normalized = filePath.replaceAll("\\", "/"); + return protectedPathFragments.find((fragment) => normalized.includes(fragment)); +} + +export default function appxGuardrails(pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + ctx.ui.setStatus("appx", "Appx guardrails active"); + }); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash") { + const command = asString(event.input.command); + const risk = commandRisk(command); + if (!risk) return undefined; + + if (!ctx.hasUI) { + return { block: true, reason: `Blocked ${risk} command because no UI approval channel is available.` }; + } + + const ok = await ctx.ui.confirm( + "Approve command?", + `${risk}\n\n${command}`, + { timeout: 60_000 }, + ); + if (!ok) return { block: true, reason: `Blocked ${risk} command by user decision.` }; + return undefined; + } + + if (event.toolName === "write" || event.toolName === "edit") { + const filePath = asString(event.input.path); + const risk = pathRisk(filePath); + if (!risk) return undefined; + + if (ctx.hasUI) { + ctx.ui.notify(`Blocked write to protected path: ${filePath}`, "warning"); + } + return { block: true, reason: `Path is protected by Appx guardrails (${risk}).` }; + } + + return undefined; + }); +} diff --git a/internal/project/templates/pi/settings.json b/internal/project/templates/pi/settings.json new file mode 100644 index 0000000..57e3333 --- /dev/null +++ b/internal/project/templates/pi/settings.json @@ -0,0 +1,4 @@ +{ + "enableSkillCommands": true, + "packages": [] +} diff --git a/internal/project/templates/pi/skills/appx-egress/SKILL.md b/internal/project/templates/pi/skills/appx-egress/SKILL.md new file mode 100644 index 0000000..f7bed39 --- /dev/null +++ b/internal/project/templates/pi/skills/appx-egress/SKILL.md @@ -0,0 +1,26 @@ +--- +name: appx-egress +description: Use when a command or package manager needs external network access and Appx reports that the destination is not in the egress allowlist. +--- + +# appx-egress + +Request narrowly-scoped outbound network access through Appx's local egress +approval flow. + +## Usage + +```bash +python3 .pi/skills/appx-egress/request_egress.py "" +``` + +Examples: + +```bash +python3 .pi/skills/appx-egress/request_egress.py registry.npmjs.org 443 "install Vite dependency" +python3 .pi/skills/appx-egress/request_egress.py api.github.com 443 "fetch GitHub release metadata" +``` + +Only request hosts that are strictly required for the user's task. If the user +denies or the request times out, explain the blocker and stop instead of trying +to bypass Appx's egress policy. diff --git a/internal/project/templates/pi/skills/appx-egress/request_egress.py b/internal/project/templates/pi/skills/appx-egress/request_egress.py new file mode 100644 index 0000000..720360d --- /dev/null +++ b/internal/project/templates/pi/skills/appx-egress/request_egress.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Request Appx egress access from the local agent-facing listener.""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +def main() -> int: + parser = argparse.ArgumentParser(description="Request Appx egress access") + parser.add_argument("host", help="Destination hostname") + parser.add_argument("port", type=int, help="Destination port") + parser.add_argument("reason", help="Brief reason shown to the Appx user") + parser.add_argument( + "--url", + default=os.environ.get("APPX_EGRESS_URL", "http://127.0.0.1:9081/egress/request"), + help="Appx internal egress request URL", + ) + args = parser.parse_args() + + payload = json.dumps( + {"host": args.host, "port": args.port, "reason": args.reason} + ).encode("utf-8") + req = urllib.request.Request( + args.url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=70) as res: + body = json.loads(res.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + print(f"egress request failed: HTTP {exc.code} {exc.reason}", file=sys.stderr) + return 2 + except Exception as exc: + print(f"egress request failed: {exc}", file=sys.stderr) + return 2 + + if body.get("allowed"): + print(f"approved: {args.host}:{args.port}") + return 0 + if body.get("timeout"): + print(f"timed out: {args.host}:{args.port}", file=sys.stderr) + return 1 + print(f"denied: {args.host}:{args.port}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 365b09c779390e2a016cb62f3ad8d296a7eb3d28 Mon Sep 17 00:00:00 2001 From: Andrey Gruzdev Date: Fri, 22 May 2026 11:07:24 +0200 Subject: [PATCH 02/26] chore(deps): resolve frontend audit advisories --- web/package-lock.json | 274 +++++++++++++++++++++++------------------- 1 file changed, 148 insertions(+), 126 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index d17e3c3..fdd7102 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -275,21 +275,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -298,9 +298,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -568,20 +568,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@opencode-ai/sdk": { @@ -594,9 +596,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -604,9 +606,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -621,9 +623,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -638,9 +640,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -655,9 +657,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -672,9 +674,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -689,13 +691,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -706,13 +711,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -723,13 +731,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -740,13 +751,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -757,13 +771,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -774,13 +791,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -791,9 +811,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -808,9 +828,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -818,16 +838,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -842,9 +864,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -866,9 +888,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1136,9 +1158,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1363,9 +1385,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1559,9 +1581,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2401,9 +2423,9 @@ } }, "node_modules/marked": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", - "integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -2433,9 +2455,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -2555,9 +2577,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2568,9 +2590,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2588,7 +2610,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2686,14 +2708,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2702,27 +2724,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -2806,14 +2828,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2943,17 +2965,17 @@ } }, "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -2969,8 +2991,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", From 1d5fa7cd4aa79df6e671ca2b0088baae5be1871c Mon Sep 17 00:00:00 2001 From: Andrey Gruzdev Date: Sat, 23 May 2026 19:47:26 +0200 Subject: [PATCH 03/26] feat: support pi-only appx runtime --- README.md | 28 +++++---- Taskfile.yml | 8 ++- cmd/appx/main.go | 36 ++++++++---- deploy/appx.service | 2 +- deploy/bootstrap.sh | 20 +++++-- deploy/system-setup.sh | 81 +++++++++++++++++---------- deploy/tools-install.sh | 43 ++++++++------ deploy/verify-installation.sh | 68 ++++++++++++++-------- internal/server/router.go | 3 +- internal/server/router_test.go | 16 ++++-- internal/server/server.go | 14 ++--- internal/server/settings_handlers.go | 14 +++-- web/src/api/client.ts | 1 + web/src/components/OpenCodeStatus.tsx | 16 ++++-- web/src/pages/Dashboard.tsx | 8 ++- 15 files changed, 226 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 5035648..d91a2c5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Appx -Agentic Application Proxy — self-hostable tool to build and host personal apps with AI agents powered by [OpenCode](https://github.com/anomalyco/opencode). +Agentic Application Proxy — self-hostable tool to build and host personal apps with AI agents. New installs default to Pi; the legacy OpenCode backend remains available with `APPX_AGENT_BACKEND=opencode`. ## What it does -Appx is a management shell for running OpenCode agents on a remote server. It provides authentication, TLS termination, a web dashboard, and a reverse proxy — so you can manage projects, chat with agents, and access agent-built apps from a browser over HTTPS. +Appx is a management shell for running coding agents on a remote server. It provides authentication, TLS termination, a web dashboard, and a reverse proxy — so you can manage projects, chat with agents, and access agent-built apps from a browser over HTTPS. ## Architecture @@ -13,13 +13,13 @@ Browser └── HTTPS (single port) ├── / React SPA (embedded in binary) ├── /api/* REST API (auth, projects, settings) - ├── /api/opencode/* Reverse proxy → OpenCode server + ├── /api/opencode/* Legacy reverse proxy → OpenCode server └── . Reverse proxy → agent-built apps ``` Everything is a single Go binary. The React frontend is compiled and embedded at build time. State lives in a SQLite database on disk. -OpenCode runs as a **separate process** on `localhost:4096` and handles all AI agent work (sessions, tool execution, file editing, terminal). Appx proxies requests to it and adds auth + TLS on top. +Pi is installed as the default agent runtime. Legacy OpenCode mode runs OpenCode as a separate process on `localhost:4096`; Appx proxies requests to it and adds auth + TLS on top. **Auth model**: single user, password login, session cookie. On first run a random password is generated and printed to stdout. @@ -81,9 +81,7 @@ If you want to use a persistent volume for storage (e.g. Hetzner Cloud Volumes), The config is saved to `/etc/appx/appx.env` and reused on subsequent runs. To change it later: `sudo nano /etc/appx/appx.env && sudo systemctl restart appx`. -Bootstrap then creates OS users with proper isolation, installs tools (Node.js, OpenCode, Pi, Claude Code, uv), sets up systemd services, starts everything, and runs a verification suite. - -During Opencode installation you might be prompted "opencode is installed to /usr/local/bin/opencode and may be managed by a package manager". Select `Install anyways? Yes` +Bootstrap then creates OS users with proper isolation, installs tools (Node.js, Pi, Claude Code, uv, and OpenCode only when `APPX_AGENT_BACKEND=opencode`), sets up systemd services, starts everything, and runs a verification suite. On first run, a random password is written to `{data-dir}/initial_password`. Delete the file after saving your password. @@ -93,8 +91,8 @@ Bootstrap installs these tools system-wide so agents can use them in the termina - **Go** — compiled from the version in `go.mod` - **Node.js 24 / npm** — JavaScript/TypeScript projects (installed via nvm, pinned to major version 24) - **uv** — Python version and package management (self-update: `uv self update`) -- **OpenCode** — AI agent backend (pinned version in `deploy/opencode-version`) -- **Pi** — AI coding agent CLI/SDK for the Pi migration path (pinned version in `deploy/pi-version`) +- **Pi** — AI coding agent CLI/SDK (pinned version in `deploy/pi-version`) +- **OpenCode** — optional legacy AI agent backend when `APPX_AGENT_BACKEND=opencode` (pinned version in `deploy/opencode-version`) - **Claude Code** — Claude CLI for terminal use (self-update: `sudo npm update -g @anthropic-ai/claude-code`) ### Updating appx @@ -106,7 +104,7 @@ cd /srv/appx task server:deploy ``` -Pulls latest code, rebuilds, installs the binary, updates OpenCode and Pi to the pinned versions, and restarts both services. +Pulls latest code, rebuilds, installs the binary, updates the active backend tools to the pinned versions, and restarts the needed services. ### Updating OpenCode version @@ -155,15 +153,15 @@ journalctl -u opencode -f # opencode logs | ------------------------------- | ---------------- | ---------------------------------------------------------- | | `deploy/bootstrap.sh` | Day 1 | Full setup: users, dirs, tools, build, start, verify | | `deploy/system-setup.sh` | Infra changes | Users, groups, directories, service files, agent config | -| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, OpenCode, Pi, Claude Code, uv | -| `deploy/opencode.json` | Model changes | Default OpenCode model config (copied to opencode home) | -| `deploy/opencode-version` | Version pin | Pinned OpenCode version installed by tools-install | +| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, Pi, optional OpenCode, Claude Code, uv | +| `deploy/opencode.json` | Model changes | Default OpenCode model config for legacy mode | +| `deploy/opencode-version` | Version pin | Pinned OpenCode version for legacy mode | | `deploy/pi-version` | Version pin | Pinned Pi version installed by tools-install | | `deploy/verify-installation.sh` | After any change | Full system verification | ## Local development -OpenCode must be running before starting appx: +For legacy OpenCode mode, OpenCode must be running before starting appx: ```bash opencode serve --hostname 127.0.0.1 --port 4096 @@ -249,7 +247,7 @@ Bootstrap creates two OS users with a shared `projects` group: ``` appx — runs the appx server, owns DB and TLS certs -opencode — runs OpenCode and Pi agent tooling, cannot access appx data +opencode — isolated agent user for Pi tooling and legacy OpenCode, cannot access appx data projects — shared group, both users read/write project directories ``` diff --git a/Taskfile.yml b/Taskfile.yml index f69f37c..11cc744 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -66,7 +66,13 @@ tasks: - sudo install -m 750 -o root -g appx ./appx /usr/local/bin/appx - sudo ./deploy/tools-install.sh - sudo ./deploy/system-setup.sh - - sudo systemctl restart opencode appx + - | + if grep -q '^APPX_AGENT_BACKEND=opencode' /etc/appx/appx.env 2>/dev/null; then + sudo systemctl restart opencode appx + else + sudo systemctl stop opencode 2>/dev/null || true + sudo systemctl restart appx + fi - sudo ./deploy/verify-installation.sh server:verify: diff --git a/cmd/appx/main.go b/cmd/appx/main.go index 74744b3..2130840 100644 --- a/cmd/appx/main.go +++ b/cmd/appx/main.go @@ -9,6 +9,7 @@ import ( "log" "os" "path/filepath" + "strings" "time" "strconv" @@ -159,9 +160,19 @@ func main() { pm := project.NewManager(projectStore, projectRoot) pm.BaseDomain = baseDomain - // Initialize OpenCode client. OpenCode runs as a separate process on - // localhost:4096. Poll until healthy, then inject the Anthropic API key. - ocClient := opencode.NewClient("http://127.0.0.1:4096") + agentBackend := strings.ToLower(envOr("APPX_AGENT_BACKEND", "opencode")) + if agentBackend != "opencode" && agentBackend != "pi" { + log.Fatalf("unsupported APPX_AGENT_BACKEND=%q (expected opencode or pi)", agentBackend) + } + + var ocClient *opencode.Client + if agentBackend == "opencode" { + // Initialize OpenCode client. OpenCode runs as a separate process on + // localhost:4096. Poll until healthy, then inject the Anthropic API key. + ocClient = opencode.NewClient("http://127.0.0.1:4096") + } else { + log.Printf("agent backend: pi (OpenCode disabled)") + } // Resolve Anthropic API key: DB setting takes priority, then env var. anthropicKey, _ := authStore.GetSetting("anthropic_api_key") @@ -169,14 +180,16 @@ func main() { anthropicKey = os.Getenv("ANTHROPIC_API_KEY") } - // Start OpenCode polling in background — does not block server startup. - go func() { - pollCtx, pollCancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer pollCancel() - if err := ocClient.InjectAPIKey(pollCtx, 2*time.Second, anthropicKey); err != nil { - log.Printf("opencode: startup polling failed: %v", err) - } - }() + if ocClient != nil { + // Start OpenCode polling in background — does not block server startup. + go func() { + pollCtx, pollCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer pollCancel() + if err := ocClient.InjectAPIKey(pollCtx, 2*time.Second, anthropicKey); err != nil { + log.Printf("opencode: startup polling failed: %v", err) + } + }() + } webFS, err := fs.Sub(webEmbed, "web/dist") if err != nil { @@ -203,6 +216,7 @@ func main() { HTTPMode: *httpMode, BaseDomain: baseDomain, HostAliases: hosts, + AgentBackend: agentBackend, OpenCodeClient: ocClient, EgressStore: egressStore, EgressPending: pendingRegistry, diff --git a/deploy/appx.service b/deploy/appx.service index 7ea34f5..ce5cace 100644 --- a/deploy/appx.service +++ b/deploy/appx.service @@ -1,7 +1,7 @@ [Unit] Description=Appx — Agentic Application Proxy Documentation=https://github.com/neuromaxer/appx -After=network.target opencode.service +After=network.target [Service] User=appx diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index f1543ba..3ffa1a4 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -88,12 +88,14 @@ else # APPX_HOST — server hostname for TLS cert and routing (default: .sslip.io) # APPX_DATA — data directory: DB, TLS certs, projects (default: /var/lib/appx) # APPX_PORT — listen port (default: 443). MUST be open in firewall +# APPX_AGENT_BACKEND — agent backend: pi or opencode (default: pi) # APPX_DOMAIN — domain for Let's Encrypt via Cloudflare DNS-01 (optional) # CLOUDFLARE_API_TOKEN — Cloudflare API token for DNS-01 challenge (optional) APPX_HOST=$APPX_HOST APPX_DATA=$APPX_DATA APPX_PORT=$APPX_PORT +APPX_AGENT_BACKEND=pi # APPX_DOMAIN= # CLOUDFLARE_API_TOKEN= EOF @@ -166,16 +168,22 @@ echo "" # --------------------------------------------------------------------------- STEP="restart-services" +APPX_AGENT_BACKEND=$(grep '^APPX_AGENT_BACKEND=' "$ENV_FILE" | cut -d= -f2- || true) +APPX_AGENT_BACKEND="${APPX_AGENT_BACKEND:-pi}" echo "stopping services..." systemctl stop opencode appx 2>/dev/null || true sleep 2 echo "starting services..." -systemctl start opencode appx -echo "waiting for services to be ready..." -for i in $(seq 1 10); do - curl -sf http://127.0.0.1:4096/health >/dev/null 2>&1 && break - sleep 2 -done +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + systemctl start opencode appx + echo "waiting for OpenCode to be ready..." + for i in $(seq 1 10); do + curl -sf http://127.0.0.1:4096/health >/dev/null 2>&1 && break + sleep 2 + done +else + systemctl start appx +fi echo "services started" echo "" diff --git a/deploy/system-setup.sh b/deploy/system-setup.sh index 5a9474c..25acd97 100755 --- a/deploy/system-setup.sh +++ b/deploy/system-setup.sh @@ -30,14 +30,20 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # --------------------------------------------------------------------------- DATA_DIR="/var/lib/appx" +APPX_AGENT_BACKEND="pi" if [ -f /etc/appx/appx.env ]; then # shellcheck source=/dev/null _APPX_DATA=$(grep '^APPX_DATA=' /etc/appx/appx.env | cut -d= -f2- || true) if [ -n "$_APPX_DATA" ]; then DATA_DIR="${_APPX_DATA%/}" fi + _APPX_AGENT_BACKEND=$(grep '^APPX_AGENT_BACKEND=' /etc/appx/appx.env | cut -d= -f2- || true) + if [ -n "$_APPX_AGENT_BACKEND" ]; then + APPX_AGENT_BACKEND="$_APPX_AGENT_BACKEND" + fi fi echo "data directory: $DATA_DIR" +echo "agent backend: $APPX_AGENT_BACKEND" # --------------------------------------------------------------------------- # OS users and groups @@ -102,29 +108,31 @@ echo "directory ready: $DATA_DIR/projects (appx:projects 2770)" install -d -o opencode -g opencode -m 700 /home/opencode echo "directory ready: /home/opencode (opencode:opencode 700)" -# OpenCode config: pin the default model to the Anthropic BYOK provider so -# that API calls go directly to api.anthropic.com using the injected key, -# rather than routing through the opencode.ai zen proxy (which requires a -# separate OpenCode account key). -OC_CONFIG_DIR="/home/opencode/.config/opencode" -OC_CONFIG_FILE="$OC_CONFIG_DIR/opencode.json" -install -d -o opencode -g opencode -m 700 "$OC_CONFIG_DIR" -if [ ! -f "$OC_CONFIG_FILE" ]; then - install -m 600 -o opencode -g opencode "$SCRIPT_DIR/opencode.json" "$OC_CONFIG_FILE" - echo "wrote opencode config → $OC_CONFIG_FILE" -else - echo "opencode config already exists: $OC_CONFIG_FILE" -fi +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + # OpenCode config: pin the default model to the Anthropic BYOK provider so + # that API calls go directly to api.anthropic.com using the injected key, + # rather than routing through the opencode.ai zen proxy (which requires a + # separate OpenCode account key). + OC_CONFIG_DIR="/home/opencode/.config/opencode" + OC_CONFIG_FILE="$OC_CONFIG_DIR/opencode.json" + install -d -o opencode -g opencode -m 700 "$OC_CONFIG_DIR" + if [ ! -f "$OC_CONFIG_FILE" ]; then + install -m 600 -o opencode -g opencode "$SCRIPT_DIR/opencode.json" "$OC_CONFIG_FILE" + echo "wrote opencode config → $OC_CONFIG_FILE" + else + echo "opencode config already exists: $OC_CONFIG_FILE" + fi -# AGENTS.md: global rules for the OpenCode agent, including egress access -# request instructions. Copied only on first setup — user customizations -# are preserved on subsequent runs. -OC_AGENTS_FILE="$OC_CONFIG_DIR/AGENTS.md" -if [ ! -f "$OC_AGENTS_FILE" ]; then - install -m 600 -o opencode -g opencode "$SCRIPT_DIR/AGENTS.md" "$OC_AGENTS_FILE" - echo "wrote agents rules → $OC_AGENTS_FILE" -else - echo "agents rules already exist: $OC_AGENTS_FILE" + # AGENTS.md: global rules for the OpenCode agent, including egress access + # request instructions. Copied only on first setup — user customizations + # are preserved on subsequent runs. + OC_AGENTS_FILE="$OC_CONFIG_DIR/AGENTS.md" + if [ ! -f "$OC_AGENTS_FILE" ]; then + install -m 600 -o opencode -g opencode "$SCRIPT_DIR/AGENTS.md" "$OC_AGENTS_FILE" + echo "wrote agents rules → $OC_AGENTS_FILE" + else + echo "agents rules already exist: $OC_AGENTS_FILE" + fi fi # Pi agent config/auth/cache dir. Pi is project-local for prompts, skills, and @@ -152,19 +160,30 @@ fi cp "$SCRIPT_DIR/appx.service" /etc/systemd/system/appx.service -# OpenCode needs WorkingDirectory set to the shared projects dir. -# Since systemd can't expand env vars in WorkingDirectory, we substitute -# the resolved path into the service file before installing it. -sed "s|WorkingDirectory=.*|WorkingDirectory=$DATA_DIR/projects|" \ - "$SCRIPT_DIR/opencode.service" > /etc/systemd/system/opencode.service -echo "copied service files to /etc/systemd/system/" -echo "opencode WorkingDirectory → $DATA_DIR/projects" +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + # OpenCode needs WorkingDirectory set to the shared projects dir. + # Since systemd can't expand env vars in WorkingDirectory, we substitute + # the resolved path into the service file before installing it. + sed "s|WorkingDirectory=.*|WorkingDirectory=$DATA_DIR/projects|" \ + "$SCRIPT_DIR/opencode.service" > /etc/systemd/system/opencode.service + echo "copied service files to /etc/systemd/system/" + echo "opencode WorkingDirectory → $DATA_DIR/projects" +else + systemctl disable --now opencode 2>/dev/null || true + rm -f /etc/systemd/system/opencode.service + echo "copied appx.service; opencode.service disabled for $APPX_AGENT_BACKEND backend" +fi systemctl daemon-reload echo "systemd reloaded" -systemctl enable appx opencode -echo "services enabled: appx, opencode" +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + systemctl enable appx opencode + echo "services enabled: appx, opencode" +else + systemctl enable appx + echo "services enabled: appx" +fi # --------------------------------------------------------------------------- # Summary diff --git a/deploy/tools-install.sh b/deploy/tools-install.sh index e055e93..e6ad04c 100755 --- a/deploy/tools-install.sh +++ b/deploy/tools-install.sh @@ -24,6 +24,11 @@ fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +APPX_AGENT_BACKEND="${APPX_AGENT_BACKEND:-}" +if [ -z "$APPX_AGENT_BACKEND" ] && [ -f /etc/appx/appx.env ]; then + APPX_AGENT_BACKEND=$(grep '^APPX_AGENT_BACKEND=' /etc/appx/appx.env | cut -d= -f2- || true) +fi +APPX_AGENT_BACKEND="${APPX_AGENT_BACKEND:-pi}" # Detect architecture. ARCH=$(dpkg --print-architecture 2>/dev/null || echo "amd64") @@ -121,23 +126,27 @@ NODE_BIN_DIR="$(dirname "$(readlink -f /usr/local/bin/node)")" # OpenCode (installed via npm, pinned to deploy/opencode-version) # --------------------------------------------------------------------------- -OPENCODE_VERSION="" -if [ -f "$SCRIPT_DIR/opencode-version" ]; then - OPENCODE_VERSION=$(cat "$SCRIPT_DIR/opencode-version" | tr -d '[:space:]') -fi +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + OPENCODE_VERSION="" + if [ -f "$SCRIPT_DIR/opencode-version" ]; then + OPENCODE_VERSION=$(cat "$SCRIPT_DIR/opencode-version" | tr -d '[:space:]') + fi -# Strip leading 'v' for npm version syntax. -OPENCODE_NPM_VERSION=$(echo "$OPENCODE_VERSION" | sed 's/^v//') + # Strip leading 'v' for npm version syntax. + OPENCODE_NPM_VERSION=$(echo "$OPENCODE_VERSION" | sed 's/^v//') -CURRENT=$(/usr/local/bin/opencode --version 2>/dev/null || echo "") + CURRENT=$(/usr/local/bin/opencode --version 2>/dev/null || echo "") -if [ -n "$OPENCODE_NPM_VERSION" ] && [ "$CURRENT" = "$OPENCODE_NPM_VERSION" ]; then - echo "opencode already at $OPENCODE_NPM_VERSION" + if [ -n "$OPENCODE_NPM_VERSION" ] && [ "$CURRENT" = "$OPENCODE_NPM_VERSION" ]; then + echo "opencode already at $OPENCODE_NPM_VERSION" + else + echo "installing opencode${OPENCODE_NPM_VERSION:+ $OPENCODE_NPM_VERSION} via npm..." + npm install -g "opencode-ai@${OPENCODE_NPM_VERSION:-latest}" + ln -sf "$NODE_BIN_DIR/opencode" /usr/local/bin/opencode + echo "opencode installed: $(/usr/local/bin/opencode --version 2>/dev/null)" + fi else - echo "installing opencode${OPENCODE_NPM_VERSION:+ $OPENCODE_NPM_VERSION} via npm..." - npm install -g "opencode-ai@${OPENCODE_NPM_VERSION:-latest}" - ln -sf "$NODE_BIN_DIR/opencode" /usr/local/bin/opencode - echo "opencode installed: $(/usr/local/bin/opencode --version 2>/dev/null)" + echo "skipping opencode install (APPX_AGENT_BACKEND=$APPX_AGENT_BACKEND)" fi # --------------------------------------------------------------------------- @@ -149,7 +158,7 @@ if [ -f "$SCRIPT_DIR/pi-version" ]; then PI_VERSION=$(cat "$SCRIPT_DIR/pi-version" | tr -d '[:space:]') fi -CURRENT_PI=$(/usr/local/bin/pi --version 2>/dev/null || echo "") +CURRENT_PI=$(/usr/local/bin/pi --version 2>&1 || echo "") if [ -n "$PI_VERSION" ] && [ "$CURRENT_PI" = "$PI_VERSION" ]; then echo "pi already at $PI_VERSION" @@ -157,7 +166,7 @@ else echo "installing pi${PI_VERSION:+ $PI_VERSION} via npm..." npm install -g "@earendil-works/pi-coding-agent@${PI_VERSION:-latest}" ln -sf "$NODE_BIN_DIR/pi" /usr/local/bin/pi - echo "pi installed: $(/usr/local/bin/pi --version 2>/dev/null)" + echo "pi installed: $(/usr/local/bin/pi --version 2>&1)" fi # --------------------------------------------------------------------------- @@ -205,6 +214,6 @@ echo " task: $(task --version 2>/dev/null || echo 'not found')" echo " go: $(go version 2>/dev/null || echo 'not found')" echo " node: $(/usr/local/bin/node --version 2>/dev/null || echo 'not found')" echo " uv: $(/usr/local/bin/uv --version 2>/dev/null || echo 'not found')" -echo " opencode: $(/usr/local/bin/opencode --version 2>/dev/null || echo 'not found')" -echo " pi: $(/usr/local/bin/pi --version 2>/dev/null || echo 'not found')" +echo " opencode: $(/usr/local/bin/opencode --version 2>/dev/null || echo "skipped ($APPX_AGENT_BACKEND)")" +echo " pi: $(/usr/local/bin/pi --version 2>&1 || echo 'not found')" echo " claude: $(claude --version 2>/dev/null || echo 'not found')" diff --git a/deploy/verify-installation.sh b/deploy/verify-installation.sh index f7d6954..7a6aeda 100755 --- a/deploy/verify-installation.sh +++ b/deploy/verify-installation.sh @@ -20,14 +20,20 @@ FAIL=0 # Read data directory from env file, fall back to default. DATA_DIR="/var/lib/appx" +APPX_AGENT_BACKEND="pi" ENV_FILE="/etc/appx/appx.env" if [ -f "$ENV_FILE" ]; then _APPX_DATA=$(grep '^APPX_DATA=' "$ENV_FILE" | cut -d= -f2- || true) if [ -n "$_APPX_DATA" ]; then DATA_DIR="${_APPX_DATA%/}" fi + _APPX_AGENT_BACKEND=$(grep '^APPX_AGENT_BACKEND=' "$ENV_FILE" | cut -d= -f2- || true) + if [ -n "$_APPX_AGENT_BACKEND" ]; then + APPX_AGENT_BACKEND="$_APPX_AGENT_BACKEND" + fi fi echo "data directory: $DATA_DIR" +echo "agent backend: $APPX_AGENT_BACKEND" echo "" # expect_ok: command should succeed @@ -115,10 +121,12 @@ expect_eq "projects dir is appx:projects 2770" \ expect_ok "opencode home exists" test -d /home/opencode expect_eq "opencode home is opencode:opencode 700" \ "$(stat -c '%U:%G %a' /home/opencode 2>/dev/null)" "opencode:opencode 700" -expect_ok "opencode config sets anthropic model" \ - grep -q '"anthropic/' /home/opencode/.config/opencode/opencode.json -expect_ok "opencode AGENTS.md exists" \ - test -f /home/opencode/.config/opencode/AGENTS.md +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + expect_ok "opencode config sets anthropic model" \ + grep -q '"anthropic/' /home/opencode/.config/opencode/opencode.json + expect_ok "opencode AGENTS.md exists" \ + test -f /home/opencode/.config/opencode/AGENTS.md +fi expect_ok "pi agent dir exists" test -d /home/opencode/.pi/agent expect_eq "pi agent dir is opencode:opencode 700" \ "$(stat -c '%U:%G %a' /home/opencode/.pi/agent 2>/dev/null)" "opencode:opencode 700" @@ -165,17 +173,23 @@ expect_ok "env file exists" test -f /etc/appx/appx.env expect_eq "env file is root:root 600" \ "$(stat -c '%U:%G %a' /etc/appx/appx.env 2>/dev/null)" "root:root 600" expect_ok "appx.service exists" test -f /etc/systemd/system/appx.service -expect_ok "opencode.service exists" test -f /etc/systemd/system/opencode.service expect_ok "appx service enabled" systemctl is-enabled appx -expect_ok "opencode service enabled" systemctl is-enabled opencode -expect_ok "opencode ExecStart is /usr/local/bin" \ - grep -q "ExecStart=/usr/local/bin/opencode" /etc/systemd/system/opencode.service +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + expect_ok "opencode.service exists" test -f /etc/systemd/system/opencode.service + expect_ok "opencode service enabled" systemctl is-enabled opencode + expect_ok "opencode ExecStart is /usr/local/bin" \ + grep -q "ExecStart=/usr/local/bin/opencode" /etc/systemd/system/opencode.service +else + expect_deny "opencode.service absent for pi backend" test -f /etc/systemd/system/opencode.service +fi expect_ok "appx ExecStart is /usr/local/bin" \ grep -q "ExecStart=/usr/local/bin/appx" /etc/systemd/system/appx.service expect_ok "appx runs as appx user" \ grep -q "User=appx" /etc/systemd/system/appx.service -expect_ok "opencode runs as opencode user" \ - grep -q "User=opencode" /etc/systemd/system/opencode.service +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + expect_ok "opencode runs as opencode user" \ + grep -q "User=opencode" /etc/systemd/system/opencode.service +fi # --------------------------------------------------------------------------- echo "" @@ -189,13 +203,17 @@ EXPECTED_NODE_MAJOR="24" ACTUAL_NODE_MAJOR=$(/usr/local/bin/node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1 || echo "0") expect_eq "node major version is $EXPECTED_NODE_MAJOR" \ "$ACTUAL_NODE_MAJOR" "$EXPECTED_NODE_MAJOR" -expect_ok "opencode binary in /usr/local/bin" test -x /usr/local/bin/opencode +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + expect_ok "opencode binary in /usr/local/bin" test -x /usr/local/bin/opencode +else + echo " INFO opencode binary not required for pi backend" +fi expect_ok "pi binary in /usr/local/bin" test -x /usr/local/bin/pi expect_ok "uv binary in /usr/local/bin" test -x /usr/local/bin/uv EXPECTED_OC_VERSION="" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -if [ -f "$SCRIPT_DIR/opencode-version" ]; then +if [ "$APPX_AGENT_BACKEND" = "opencode" ] && [ -f "$SCRIPT_DIR/opencode-version" ]; then EXPECTED_OC_VERSION=$(cat "$SCRIPT_DIR/opencode-version" | tr -d '[:space:]' | sed 's/^v//') fi if [ -n "$EXPECTED_OC_VERSION" ]; then @@ -209,7 +227,7 @@ if [ -f "$SCRIPT_DIR/pi-version" ]; then EXPECTED_PI_VERSION=$(cat "$SCRIPT_DIR/pi-version" | tr -d '[:space:]') fi if [ -n "$EXPECTED_PI_VERSION" ]; then - ACTUAL_PI_VERSION=$(/usr/local/bin/pi --version 2>/dev/null || echo "unknown") + ACTUAL_PI_VERSION=$(/usr/local/bin/pi --version 2>&1 || echo "unknown") expect_eq "pi version matches deploy/pi-version" \ "$ACTUAL_PI_VERSION" "$EXPECTED_PI_VERSION" fi @@ -226,18 +244,22 @@ echo "" echo "=== 8. Runtime (if services are running) ===" # --------------------------------------------------------------------------- -if systemctl is-active --quiet opencode 2>/dev/null; then - expect_ok "opencode is running" systemctl is-active opencode - expect_ok "opencode responds on :4096" \ - curl -sf --max-time 3 http://127.0.0.1:4096/health - # Verify it's actually running as the opencode user. - OC_PID=$(systemctl show opencode --property=MainPID --value 2>/dev/null) - if [ -n "$OC_PID" ] && [ "$OC_PID" != "0" ]; then - OC_USER=$(ps -o user= -p "$OC_PID" 2>/dev/null || echo "unknown") - expect_eq "opencode process runs as opencode user" "$OC_USER" "opencode" +if [ "$APPX_AGENT_BACKEND" = "opencode" ]; then + if systemctl is-active --quiet opencode 2>/dev/null; then + expect_ok "opencode is running" systemctl is-active opencode + expect_ok "opencode responds on :4096" \ + curl -sf --max-time 3 http://127.0.0.1:4096/health + # Verify it's actually running as the opencode user. + OC_PID=$(systemctl show opencode --property=MainPID --value 2>/dev/null) + if [ -n "$OC_PID" ] && [ "$OC_PID" != "0" ]; then + OC_USER=$(ps -o user= -p "$OC_PID" 2>/dev/null || echo "unknown") + expect_eq "opencode process runs as opencode user" "$OC_USER" "opencode" + fi + else + echo " SKIP opencode not running (start with: systemctl start opencode)" fi else - echo " SKIP opencode not running (start with: systemctl start opencode)" + expect_deny "opencode service inactive in pi backend" systemctl is-active opencode fi if systemctl is-active --quiet appx 2>/dev/null; then diff --git a/internal/server/router.go b/internal/server/router.go index 20be451..aa9fd36 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -26,6 +26,7 @@ type RouterConfig struct { HTTPMode bool // true = plain HTTP dev mode, affects security headers BaseDomain string // base domain for subdomain routing HostAliases []string // additional hostnames/IPs that also serve the dashboard (e.g. server IP) + AgentBackend string // opencode or pi OpenCodeURL string // URL of the OpenCode server (default "http://localhost:4096") } @@ -54,7 +55,7 @@ func NewRouter(a *auth.Auth, pm *project.Manager, webFS fs.FS, rcfg RouterConfig api.HandleFunc("DELETE /api/settings/api-key", handleDeleteAPIKey(a.Store, oc)) api.HandleFunc("GET /api/settings/terminal-buffer-size", handleGetTerminalBufferSize(a.Store)) api.HandleFunc("PUT /api/settings/terminal-buffer-size", handleSetTerminalBufferSize(a.Store)) - api.HandleFunc("GET /api/config", handleGetConfig(rcfg.BaseDomain)) + api.HandleFunc("GET /api/config", handleGetConfig(rcfg.BaseDomain, rcfg.AgentBackend)) api.HandleFunc("DELETE /api/session", handleLogout(a)) api.HandleFunc("GET /api/egress/log", handleGetEgressLog(es)) api.HandleFunc("GET /api/egress/allowlist", handleGetAllowlist(es)) diff --git a/internal/server/router_test.go b/internal/server/router_test.go index c14588b..d2a74fc 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -3,7 +3,6 @@ package server import ( "bufio" "crypto/sha1" - "github.com/neuromaxer/appx/internal/terminal" "database/sql" "encoding/base64" "encoding/json" @@ -21,8 +20,9 @@ import ( "github.com/neuromaxer/appx/internal/egress" "github.com/neuromaxer/appx/internal/opencode" "github.com/neuromaxer/appx/internal/project" - _ "modernc.org/sqlite" + "github.com/neuromaxer/appx/internal/terminal" "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" ) // testSchema is the minimal in-memory SQLite schema used by all server tests. @@ -1433,8 +1433,8 @@ func TestPutAllowlist_InvalidFormat(t *testing.T) { // --- Config endpoint tests --- -func TestGetConfig_ReturnsDomain(t *testing.T) { - handler, store, _ := setupTestWithConfig(t, RouterConfig{BaseDomain: "example.com"}) +func TestGetConfig_ReturnsRuntimeConfig(t *testing.T) { + handler, store, _ := setupTestWithConfig(t, RouterConfig{BaseDomain: "example.com", AgentBackend: "pi"}) req := authedRequest(t, store, "GET", "/api/config", "") req.Host = "example.com" w := httptest.NewRecorder() @@ -1443,12 +1443,16 @@ func TestGetConfig_ReturnsDomain(t *testing.T) { t.Fatalf("expected 200, got %d", w.Code) } var resp struct { - BaseDomain string `json:"baseDomain"` + BaseDomain string `json:"baseDomain"` + AgentBackend string `json:"agentBackend"` } json.NewDecoder(w.Body).Decode(&resp) if resp.BaseDomain != "example.com" { t.Errorf("expected example.com, got %q", resp.BaseDomain) } + if resp.AgentBackend != "pi" { + t.Errorf("expected pi, got %q", resp.AgentBackend) + } } func TestGetConfig_RequiresAuth(t *testing.T) { @@ -1550,7 +1554,7 @@ func TestStripPort(t *testing.T) { {"localhost", "localhost"}, {"example.com:443", "example.com"}, {"[::1]:8080", "::1"}, - {"[::1]", "[::1]"}, // no port — returned as-is + {"[::1]", "[::1]"}, // no port — returned as-is {"127.0.0.1:443", "127.0.0.1"}, {"127.0.0.1", "127.0.0.1"}, } diff --git a/internal/server/server.go b/internal/server/server.go index a463b6a..5b4d4db 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,9 +37,10 @@ type Config struct { TLSHosts []string Domain string CloudflareToken string - HTTPMode bool // true = plain HTTP, locked to localhost - BaseDomain string // "localhost" in HTTP mode, Domain value in production + HTTPMode bool // true = plain HTTP, locked to localhost + BaseDomain string // "localhost" in HTTP mode, Domain value in production HostAliases []string // additional hosts that serve the dashboard (e.g. server IP or hostname) + AgentBackend string OpenCodeClient *opencode.Client EgressStore *egress.Store EgressPending *egress.PendingRegistry @@ -82,9 +83,10 @@ func Run(cfg Config) error { }() handler := NewRouter(a, cfg.ProjectManager, cfg.WebFS, RouterConfig{ - HTTPMode: cfg.HTTPMode, - BaseDomain: cfg.BaseDomain, - HostAliases: cfg.HostAliases, + HTTPMode: cfg.HTTPMode, + BaseDomain: cfg.BaseDomain, + HostAliases: cfg.HostAliases, + AgentBackend: cfg.AgentBackend, }, cfg.OpenCodeClient, cfg.EgressStore, cfg.EgressPending, cfg.LocalManager) if cfg.HTTPMode { @@ -178,8 +180,6 @@ func runHTTP(cfg Config, handler http.Handler) error { IdleTimeout: 90 * time.Second, } - - return serveHTTP(srv, cfg.Port) } diff --git a/internal/server/settings_handlers.go b/internal/server/settings_handlers.go index a1d883c..47ed838 100644 --- a/internal/server/settings_handlers.go +++ b/internal/server/settings_handlers.go @@ -149,12 +149,16 @@ func handleGetTerminalBufferSize(store *auth.Store) http.HandlerFunc { } // handleGetConfig returns the handler for GET /api/config. It exposes server -// runtime configuration that the frontend needs at startup — currently the -// baseDomain so the SPA can construct correct subdomain URLs regardless of -// deployment mode. Auth required. -func handleGetConfig(baseDomain string) http.HandlerFunc { +// runtime configuration that the frontend needs at startup. Auth required. +func handleGetConfig(baseDomain string, agentBackend string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]string{"baseDomain": baseDomain}) + if agentBackend == "" { + agentBackend = "opencode" + } + writeJSON(w, map[string]string{ + "baseDomain": baseDomain, + "agentBackend": agentBackend, + }) } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 878c9e6..ebc39aa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -49,6 +49,7 @@ export interface Project { /** Server config returned by GET /api/config. */ export interface ServerConfig { baseDomain: string; + agentBackend: 'opencode' | 'pi'; } /** Fetches server runtime configuration including baseDomain. GET /api/config. */ diff --git a/web/src/components/OpenCodeStatus.tsx b/web/src/components/OpenCodeStatus.tsx index 50cd3cb..509fae0 100644 --- a/web/src/components/OpenCodeStatus.tsx +++ b/web/src/components/OpenCodeStatus.tsx @@ -3,14 +3,17 @@ import { getOpenCodeHealth } from '../api/client'; const POLL_INTERVAL = 10000; -/** OpenCodeStatus renders a small health indicator for the OpenCode server. - * Polls every 10 seconds. Shows a colored dot with label: green when healthy, - * red when down, gray on initial load. */ -export default function OpenCodeStatus() { +/** OpenCodeStatus renders a small health indicator for the active agent backend. */ +export default function OpenCodeStatus({ backend = 'opencode' }: { backend?: 'opencode' | 'pi' }) { const [healthy, setHealthy] = useState(null); const pollRef = useRef | null>(null); useEffect(() => { + if (backend === 'pi') { + setHealthy(true); + return; + } + const check = () => { getOpenCodeHealth() .then(res => setHealthy(res.healthy)) @@ -23,14 +26,15 @@ export default function OpenCodeStatus() { return () => { if (pollRef.current) clearInterval(pollRef.current); }; - }, []); + }, [backend]); const color = healthy === null ? 'var(--muted)' : healthy ? 'var(--green)' : 'var(--red)'; + const label = backend === 'pi' ? 'PI' : 'OPENCODE'; return ( - OPENCODE + {label} ); } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 7e24d62..27c22c7 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -16,6 +16,7 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); const [baseDomain, setBaseDomain] = useState('localhost'); + const [agentBackend, setAgentBackend] = useState<'opencode' | 'pi'>('opencode'); const pollRef = useRef | null>(null); const fetchProjects = useCallback(() => { @@ -27,7 +28,10 @@ export default function Dashboard() { useEffect(() => { getServerConfig() - .then((cfg) => setBaseDomain(cfg.baseDomain || 'localhost')) + .then((cfg) => { + setBaseDomain(cfg.baseDomain || 'localhost'); + setAgentBackend(cfg.agentBackend || 'opencode'); + }) .catch(() => {}); fetchProjects(); pollRef.current = setInterval(fetchProjects, POLL_INTERVAL); @@ -39,7 +43,7 @@ export default function Dashboard() {
APPX - +