diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 162cc234d..d64a6d7d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -311,6 +311,9 @@ jobs: - name: Validate wrangler binding consistency run: pnpm quality:wrangler-bindings + - name: Validate agent install manifest consistency + run: pnpm quality:agent-install-manifest + code-quality: name: Code Quality Checks if: github.event_name == 'pull_request' || github.repository == 'raphaeltm/simple-agent-manager' diff --git a/apps/api/Dockerfile.vm-agent-container b/apps/api/Dockerfile.vm-agent-container index 05c93c97d..d6297447b 100644 --- a/apps/api/Dockerfile.vm-agent-container +++ b/apps/api/Dockerfile.vm-agent-container @@ -1,4 +1,6 @@ -FROM docker.io/library/node:20-bookworm-slim +FROM docker.io/library/node:22-bookworm-slim + +COPY container-entrypoints/patch-acp-amp.py /tmp/patch-acp-amp.py RUN set -eux; \ apt-get update; \ @@ -9,13 +11,25 @@ RUN set -eux; \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list; \ apt-get update; \ apt-get install -y --no-install-recommends gh; \ - npm install -g @zed-industries/claude-agent-acp @anthropic-ai/claude-code; \ + npm install -g @agentclientprotocol/claude-agent-acp@0.58.1 @anthropic-ai/claude-code@2.1.207 @agentclientprotocol/codex-acp@1.1.2 @google/gemini-cli@0.50.0 opencode-ai@1.17.18 @ampcode/cli@0.0.1783785389-g0da70d; \ + curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh; \ + UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.19.1 --python 3.12 --quiet; \ + UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install acp-amp==0.1.3 --with agent-client-protocol==0.7.1 --with amp-sdk==0.1.2 --with pydantic==2.12.5 --with pydantic-core==2.41.5 --with annotated-types==0.7.0 --with typing-inspection==0.4.2 --with typing-extensions==4.15.0 --python 3.12 --quiet; \ + UV_PYTHON_INSTALL_DIR=/opt/uv-python uv run --python 3.12 python /tmp/patch-acp-amp.py; \ + rm /tmp/patch-acp-amp.py; \ npm cache clean --force; \ rm -rf /var/lib/apt/lists/* +ENV SAM_AGENT_HOME=/var/lib/vm-agent/agents \ + NPM_CONFIG_PREFIX=/var/lib/vm-agent/agents/npm \ + UV_TOOL_DIR=/var/lib/vm-agent/agents/uv-tools \ + UV_PYTHON_INSTALL_DIR=/var/lib/vm-agent/agents/uv-python \ + UV_TOOL_BIN_DIR=/var/lib/vm-agent/agents/bin \ + PATH=/var/lib/vm-agent/agents/bin:/var/lib/vm-agent/agents/npm/bin:/usr/local/bin:/usr/bin:/bin + COPY container-entrypoints/vm-agent-bootstrap.sh /usr/local/bin/vm-agent-bootstrap RUN chmod +x /usr/local/bin/vm-agent-bootstrap && \ - mkdir -p /workspaces /var/lib/vm-agent && \ + mkdir -p /workspaces /var/lib/vm-agent/agents/bin /var/lib/vm-agent/agents/npm /var/lib/vm-agent/agents/uv-tools /var/lib/vm-agent/agents/uv-python && \ chown -R node:node /workspaces /var/lib/vm-agent USER node diff --git a/apps/api/container-entrypoints/patch-acp-amp.py b/apps/api/container-entrypoints/patch-acp-amp.py new file mode 100644 index 000000000..cc4e4f338 --- /dev/null +++ b/apps/api/container-entrypoints/patch-acp-amp.py @@ -0,0 +1,28 @@ +from pathlib import Path + +base = Path('/opt/uv-tools/acp-amp/lib/python3.12/site-packages') +p = base / 'acp_amp/driver/python_sdk.py' +t = p.read_text() +t = t.replace('"message": str(exc)', '"message": str(exc) + (" stderr: " + exc.stderr if hasattr(exc, "stderr") and exc.stderr else "")') +old = ''' if mcp_config: + base["mcp_config"] = mcp_config + base["mcpConfig"] = mcp_config''' +new = ''' if mcp_config: + from amp_sdk.types import MCPConfig + cleaned = {} + for _n, _c in mcp_config.items(): + if isinstance(_c, dict): + _cc = dict(_c) + if _cc.get("env") is None: + _cc["env"] = {} + cleaned[_n] = _cc + else: + cleaned[_n] = _c + _wrapped = MCPConfig(servers=cleaned) + base["mcp_config"] = _wrapped + base["mcpConfig"] = _wrapped''' +t = t.replace(old, new) +p.write_text(t) +v = base / 'amp_sdk/types.py' +vt = v.read_text().replace('visibility: Optional[Literal["private", "public", "workspace", "group"]] = "workspace"', 'visibility: Optional[Literal["private", "public", "workspace", "group"]] = "private"') +v.write_text(vt) diff --git a/package.json b/package.json index 6416c1928..cfd73d418 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "setup:local": "tsx scripts/deploy/setup-local-dev.ts", "quality:preflight": "tsx scripts/quality/check-preflight-evidence.ts", "quality:wrangler-bindings": "tsx scripts/quality/check-wrangler-bindings.ts", + "quality:agent-install-manifest": "tsx scripts/quality/check-agent-install-manifest.ts", "quality:ast-checks": "tsx scripts/quality/ast-checks.ts", "quality:file-sizes": "tsx scripts/quality/check-file-sizes.ts", "quality:migration-safety": "tsx scripts/quality/check-migration-safety.ts", diff --git a/packages/shared/src/agent-install-manifest.json b/packages/shared/src/agent-install-manifest.json new file mode 100644 index 000000000..60c773d08 --- /dev/null +++ b/packages/shared/src/agent-install-manifest.json @@ -0,0 +1,57 @@ +[ + { + "agentType": "claude-code", + "method": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.58.1", + "bin": "claude-agent-acp" + }, + { + "agentType": "openai-codex", + "method": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.2", + "bin": "codex-acp" + }, + { + "agentType": "google-gemini", + "method": "npm", + "package": "@google/gemini-cli", + "version": "0.50.0", + "bin": "gemini" + }, + { + "agentType": "mistral-vibe", + "method": "uv-tool", + "package": "mistral-vibe", + "version": "2.19.1", + "bin": "vibe-acp", + "python": "3.12" + }, + { + "agentType": "opencode", + "method": "npm", + "package": "opencode-ai", + "version": "1.17.18", + "bin": "opencode" + }, + { + "agentType": "amp", + "method": "uv-tool", + "package": "acp-amp", + "version": "0.1.3", + "bin": "acp-amp", + "python": "3.12", + "extraPackages": [ + "agent-client-protocol==0.7.1", + "amp-sdk==0.1.2", + "pydantic==2.12.5", + "pydantic-core==2.41.5", + "annotated-types==0.7.0", + "typing-inspection==0.4.2", + "typing-extensions==4.15.0" + ], + "npmCompanion": { "package": "@ampcode/cli", "version": "0.0.1783785389-g0da70d" }, + "postInstallHook": "amp-sdk-patch" + } +] diff --git a/packages/shared/src/agents.ts b/packages/shared/src/agents.ts index 0a31b5b8d..687d06a06 100644 --- a/packages/shared/src/agents.ts +++ b/packages/shared/src/agents.ts @@ -14,7 +14,7 @@ export const AGENT_TYPE_VALUES = [ 'amp', ] as const; -export type AgentType = typeof AGENT_TYPE_VALUES[number]; +export type AgentType = (typeof AGENT_TYPE_VALUES)[number]; /** API key provider identifiers */ export const AGENT_PROVIDER_VALUES = [ @@ -26,7 +26,7 @@ export const AGENT_PROVIDER_VALUES = [ 'amp', ] as const; -export type AgentProvider = typeof AGENT_PROVIDER_VALUES[number]; +export type AgentProvider = (typeof AGENT_PROVIDER_VALUES)[number]; // ============================================================================= // Agent Definition (Configuration Registry) @@ -52,8 +52,6 @@ export interface AgentDefinition { supportsAcp: boolean; /** URL where users can obtain an API key */ credentialHelpUrl: string; - /** npm global install command */ - installCommand: string; /** Cloud provider whose credential can be used as a fallback when no dedicated agent key exists */ fallbackCloudProvider?: string; /** OAuth-specific metadata */ @@ -67,7 +65,6 @@ export interface AgentDefinition { }; } - // ============================================================================= // Agent Catalog // ============================================================================= @@ -84,10 +81,10 @@ export const AGENT_CATALOG: readonly AgentDefinition[] = [ acpArgs: [], supportsAcp: true, credentialHelpUrl: 'https://console.anthropic.com/settings/keys', - installCommand: 'npm install -g @zed-industries/claude-agent-acp', oauthSupport: { envVarName: 'CLAUDE_CODE_OAUTH_TOKEN', - setupInstructions: 'Generate a token using "claude setup-token" or "claude login" in your terminal', + setupInstructions: + 'Generate a token using "claude setup-token" or "claude login" in your terminal', subscriptionUrl: 'https://claude.ai/settings/plan', }, }, @@ -101,10 +98,10 @@ export const AGENT_CATALOG: readonly AgentDefinition[] = [ acpArgs: [], supportsAcp: true, credentialHelpUrl: 'https://platform.openai.com/api-keys', - installCommand: 'npx --yes @agentclientprotocol/codex-acp --version', oauthSupport: { envVarName: 'CODEX_AUTH_JSON', - setupInstructions: 'Run "codex login" on your local machine and sign in with your ChatGPT account, then paste the contents of ~/.codex/auth.json', + setupInstructions: + 'Run "codex login" on your local machine and sign in with your ChatGPT account, then paste the contents of ~/.codex/auth.json', subscriptionUrl: 'https://openai.com/chatgpt/pricing/', }, }, @@ -118,7 +115,6 @@ export const AGENT_CATALOG: readonly AgentDefinition[] = [ acpArgs: ['--acp'], supportsAcp: true, credentialHelpUrl: 'https://aistudio.google.com/apikey', - installCommand: 'npm install -g @google/gemini-cli', }, { id: 'mistral-vibe', @@ -130,8 +126,6 @@ export const AGENT_CATALOG: readonly AgentDefinition[] = [ acpArgs: [], supportsAcp: true, credentialHelpUrl: 'https://console.mistral.ai/api-keys', - installCommand: - 'curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.7.0 --python 3.12 --quiet', }, { id: 'opencode', @@ -143,7 +137,6 @@ export const AGENT_CATALOG: readonly AgentDefinition[] = [ acpArgs: ['acp'], supportsAcp: true, credentialHelpUrl: 'https://opencode.ai/auth', - installCommand: 'npm install -g opencode-ai@1.17.8', }, { id: 'amp', @@ -155,8 +148,6 @@ export const AGENT_CATALOG: readonly AgentDefinition[] = [ acpArgs: ['run'], supportsAcp: true, credentialHelpUrl: 'https://ampcode.com/settings', - installCommand: - 'curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install acp-amp==0.1.3 --with agent-client-protocol==0.7.1 --with amp-sdk==0.1.2 --with pydantic==2.12.5 --with pydantic-core==2.41.5 --with annotated-types==0.7.0 --with typing-inspection==0.4.2 --with typing-extensions==4.15.0 --python 3.12 --quiet && npm install -g @sourcegraph/amp', }, ] as const; diff --git a/packages/shared/tests/unit/agents.test.ts b/packages/shared/tests/unit/agents.test.ts index 7b6baedff..be5a91964 100644 --- a/packages/shared/tests/unit/agents.test.ts +++ b/packages/shared/tests/unit/agents.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - AGENT_CATALOG, - getAgentDefinition, - isValidAgentType, -} from '../../src/agents'; +import { AGENT_CATALOG, getAgentDefinition, isValidAgentType } from '../../src/agents'; describe('AGENT_CATALOG', () => { it('includes mistral-vibe as a supported agent', () => { @@ -17,12 +13,7 @@ describe('AGENT_CATALOG', () => { expect(mistral!.acpCommand).toBe('vibe-acp'); expect(mistral!.acpArgs).toEqual([]); expect(mistral!.supportsAcp).toBe(true); - expect(mistral!.credentialHelpUrl).toBe( - 'https://console.mistral.ai/api-keys' - ); - expect(mistral!.installCommand).toBe( - 'curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.7.0 --python 3.12 --quiet' - ); + expect(mistral!.credentialHelpUrl).toBe('https://console.mistral.ai/api-keys'); }); it('includes opencode as a supported agent', () => { @@ -39,7 +30,6 @@ describe('AGENT_CATALOG', () => { expect(opencode!.supportsAcp).toBe(true); expect(opencode!.credentialHelpUrl).toBe('https://opencode.ai/auth'); expect(opencode!.fallbackCloudProvider).toBeUndefined(); - expect(opencode!.installCommand).toBe('npm install -g opencode-ai@1.17.8'); }); it('includes Gemini CLI as a supported ACP agent', () => { @@ -51,7 +41,6 @@ describe('AGENT_CATALOG', () => { expect(gemini!.acpCommand).toBe('gemini'); expect(gemini!.acpArgs).toEqual(['--acp']); expect(gemini!.supportsAcp).toBe(true); - expect(gemini!.installCommand).toBe('npm install -g @google/gemini-cli'); expect(gemini!.oauthSupport).toBeUndefined(); }); @@ -59,7 +48,6 @@ describe('AGENT_CATALOG', () => { const codex = AGENT_CATALOG.find((a) => a.id === 'openai-codex'); expect(codex).toMatchObject({ acpCommand: 'codex-acp', - installCommand: 'npx --yes @agentclientprotocol/codex-acp --version', supportsAcp: true, }); expect(codex?.oauthSupport?.envVarName).toBe('CODEX_AUTH_JSON'); @@ -81,9 +69,6 @@ describe('AGENT_CATALOG', () => { expect(amp!.acpArgs).toEqual(['run']); expect(amp!.supportsAcp).toBe(true); expect(amp!.credentialHelpUrl).toBe('https://ampcode.com/settings'); - expect(amp!.installCommand).toBe( - 'curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install acp-amp==0.1.3 --with agent-client-protocol==0.7.1 --with amp-sdk==0.1.2 --with pydantic==2.12.5 --with pydantic-core==2.41.5 --with annotated-types==0.7.0 --with typing-inspection==0.4.2 --with typing-extensions==4.15.0 --python 3.12 --quiet && npm install -g @sourcegraph/amp' - ); expect(amp!.fallbackCloudProvider).toBeUndefined(); expect(amp!.oauthSupport).toBeUndefined(); }); diff --git a/packages/vm-agent/internal/acp/gateway.go b/packages/vm-agent/internal/acp/gateway.go index 1d61d7b87..097b4067a 100644 --- a/packages/vm-agent/internal/acp/gateway.go +++ b/packages/vm-agent/internal/acp/gateway.go @@ -783,8 +783,8 @@ func installAgentBinary(ctx context.Context, containerID string, info agentComma // directories can block subsequent installs with ENOTEMPTY. if info.isNpmBased { cleanupScript := fmt.Sprintf( - `rm -rf /usr/local/lib/node_modules/@zed-industries/.%s-* /usr/local/share/nvm/versions/node/*/lib/node_modules/@zed-industries/.%s-* 2>/dev/null; true`, - info.command, info.command, + `rm -rf /usr/local/lib/node_modules/.%s-* /usr/local/lib/node_modules/*/.%s-* /usr/local/share/nvm/versions/node/*/lib/node_modules/.%s-* /usr/local/share/nvm/versions/node/*/lib/node_modules/*/.%s-* 2>/dev/null; true`, + info.command, info.command, info.command, info.command, ) cleanupArgs := []string{"exec", "-u", "root", containerID, "sh", "-c", cleanupScript} cleanupCmd := exec.CommandContext(ctx, "docker", cleanupArgs...) @@ -842,8 +842,8 @@ func installAgentBinaryLocal(ctx context.Context, info agentCommandInfo) error { // by previous failed npm installs (same rationale as the docker path). if info.isNpmBased { cleanupScript := fmt.Sprintf( - `rm -rf /usr/local/lib/node_modules/@zed-industries/.%s-* /usr/local/share/nvm/versions/node/*/lib/node_modules/@zed-industries/.%s-* 2>/dev/null; true`, - info.command, info.command, + `rm -rf /usr/local/lib/node_modules/.%s-* /usr/local/lib/node_modules/*/.%s-* /usr/local/share/nvm/versions/node/*/lib/node_modules/.%s-* /usr/local/share/nvm/versions/node/*/lib/node_modules/*/.%s-* 2>/dev/null; true`, + info.command, info.command, info.command, info.command, ) cleanupCmd := exec.CommandContext(ctx, localShellPath, "-c", cleanupScript) _ = cleanupCmd.Run() // best-effort cleanup @@ -883,7 +883,7 @@ type agentCommandInfo struct { authFilePath string // relative to home dir, e.g. ".codex/auth.json" (only when injectionMode == "auth-file") } -const codexACPInstallCommand = "npm install -g @agentclientprotocol/codex-acp" +const codexACPInstallCommand = "npm install -g @agentclientprotocol/codex-acp@1.1.2" // getAgentCommandInfo returns the ACP command, args, env var name, and install command for a given agent type. // These match the agent catalog defined in packages/shared/src/agents.ts. @@ -892,9 +892,9 @@ func getAgentCommandInfo(agentType string, credentialKind string) agentCommandIn switch agentType { case "claude-code": if credentialKind == "oauth-token" { - return agentCommandInfo{"claude-agent-acp", nil, "CLAUDE_CODE_OAUTH_TOKEN", "npm install -g @zed-industries/claude-agent-acp", true, "", ""} + return agentCommandInfo{"claude-agent-acp", nil, "CLAUDE_CODE_OAUTH_TOKEN", "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1", true, "", ""} } - return agentCommandInfo{"claude-agent-acp", nil, "ANTHROPIC_API_KEY", "npm install -g @zed-industries/claude-agent-acp", true, "", ""} + return agentCommandInfo{"claude-agent-acp", nil, "ANTHROPIC_API_KEY", "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1", true, "", ""} case "openai-codex": // Use -c to override sandbox_mode via codex-acp's config override flag. // This takes the highest priority in the Codex config hierarchy, @@ -918,15 +918,15 @@ func getAgentCommandInfo(agentType string, credentialKind string) agentCommandIn } return agentCommandInfo{"codex-acp", codexSandboxArgs, "OPENAI_API_KEY", codexACPInstallCommand, true, "", ""} case "google-gemini": - return agentCommandInfo{"gemini", []string{"--acp"}, "GEMINI_API_KEY", "npm install -g @google/gemini-cli", true, "", ""} + return agentCommandInfo{"gemini", []string{"--acp"}, "GEMINI_API_KEY", "npm install -g @google/gemini-cli@0.50.0", true, "", ""} case "mistral-vibe": - return agentCommandInfo{"vibe-acp", nil, "MISTRAL_API_KEY", `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.7.0 --python 3.12 --quiet`, false, "", ""} + return agentCommandInfo{"vibe-acp", nil, "MISTRAL_API_KEY", `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.19.1 --python 3.12 --quiet`, false, "", ""} case "opencode": return agentCommandInfo{ command: "opencode", args: []string{"acp"}, envVarName: "OPENCODE_API_KEY", - installCmd: "npm install -g opencode-ai@1.17.8", + installCmd: "npm install -g opencode-ai@1.17.18", isNpmBased: true, injectionMode: "", authFilePath: "", @@ -936,7 +936,7 @@ func getAgentCommandInfo(agentType string, credentialKind string) agentCommandIn command: "acp-amp", args: []string{"run"}, envVarName: "AMP_API_KEY", - installCmd: `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install acp-amp==0.1.3 --with agent-client-protocol==0.7.1 --with amp-sdk==0.1.2 --with pydantic==2.12.5 --with pydantic-core==2.41.5 --with annotated-types==0.7.0 --with typing-inspection==0.4.2 --with typing-extensions==4.15.0 --python 3.12 --quiet && npm install -g @sourcegraph/amp && UV_PYTHON_INSTALL_DIR=/opt/uv-python uv run --python 3.12 python -c " + installCmd: `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install acp-amp==0.1.3 --with agent-client-protocol==0.7.1 --with amp-sdk==0.1.2 --with pydantic==2.12.5 --with pydantic-core==2.41.5 --with annotated-types==0.7.0 --with typing-inspection==0.4.2 --with typing-extensions==4.15.0 --python 3.12 --quiet && npm install -g @ampcode/cli@0.0.1783785389-g0da70d && UV_PYTHON_INSTALL_DIR=/opt/uv-python uv run --python 3.12 python -c " import pathlib base = '/opt/uv-tools/acp-amp/lib/python3.12/site-packages' @@ -976,7 +976,7 @@ vt = vt.replace('visibility: Optional[Literal[\"private\", \"public\", \"workspa v.write_text(vt) print('Patched amp_sdk: visibility default to private') "`, - // isNpmBased must be true because installCmd chains `npm install -g @sourcegraph/amp` + // isNpmBased must be true because installCmd chains `npm install -g @ampcode/cli@0.0.1783785389-g0da70d` // after the uv install. The Node.js bootstrap preamble ensures npm is available // inside devcontainers that don't ship with Node.js pre-installed. isNpmBased: true, diff --git a/packages/vm-agent/internal/acp/gateway_test.go b/packages/vm-agent/internal/acp/gateway_test.go index f3ab7c1b6..af3a095d9 100644 --- a/packages/vm-agent/internal/acp/gateway_test.go +++ b/packages/vm-agent/internal/acp/gateway_test.go @@ -26,7 +26,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "oauth-token", wantCommand: "claude-agent-acp", wantEnvVar: "CLAUDE_CODE_OAUTH_TOKEN", - wantInstallCmd: "npm install -g @zed-industries/claude-agent-acp", + wantInstallCmd: "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1", }, { name: "Claude Code with API key", @@ -34,7 +34,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "api-key", wantCommand: "claude-agent-acp", wantEnvVar: "ANTHROPIC_API_KEY", - wantInstallCmd: "npm install -g @zed-industries/claude-agent-acp", + wantInstallCmd: "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1", }, { name: "Claude Code with empty credential kind defaults to API key", @@ -42,7 +42,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "", wantCommand: "claude-agent-acp", wantEnvVar: "ANTHROPIC_API_KEY", - wantInstallCmd: "npm install -g @zed-industries/claude-agent-acp", + wantInstallCmd: "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1", }, { name: "OpenAI Codex with OAuth uses auth-file injection", @@ -50,7 +50,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "oauth-token", wantCommand: "codex-acp", wantEnvVar: "", - wantInstallCmd: "npm install -g @agentclientprotocol/codex-acp", + wantInstallCmd: "npm install -g @agentclientprotocol/codex-acp@1.1.2", wantInjectionMode: "auth-file", wantAuthFilePath: ".codex/auth.json", wantArgs: []string{"-c", `sandbox_mode="danger-full-access"`}, @@ -61,7 +61,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "api-key", wantCommand: "codex-acp", wantEnvVar: "OPENAI_API_KEY", - wantInstallCmd: "npm install -g @agentclientprotocol/codex-acp", + wantInstallCmd: "npm install -g @agentclientprotocol/codex-acp@1.1.2", wantArgs: []string{"-c", `sandbox_mode="danger-full-access"`}, }, { @@ -70,7 +70,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "oauth-token", wantCommand: "gemini", wantEnvVar: "GEMINI_API_KEY", - wantInstallCmd: "npm install -g @google/gemini-cli", + wantInstallCmd: "npm install -g @google/gemini-cli@0.50.0", }, { name: "Mistral Vibe uses API key", @@ -78,7 +78,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "api-key", wantCommand: "vibe-acp", wantEnvVar: "MISTRAL_API_KEY", - wantInstallCmd: `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.7.0 --python 3.12 --quiet`, + wantInstallCmd: `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.19.1 --python 3.12 --quiet`, }, { name: "Amp uses API key", @@ -86,7 +86,7 @@ func TestGetAgentCommandInfo_OAuthToken(t *testing.T) { credentialKind: "api-key", wantCommand: "acp-amp", wantEnvVar: "AMP_API_KEY", - wantInstallCmd: `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install acp-amp==0.1.3 --with agent-client-protocol==0.7.1 --with amp-sdk==0.1.2 --with pydantic==2.12.5 --with pydantic-core==2.41.5 --with annotated-types==0.7.0 --with typing-inspection==0.4.2 --with typing-extensions==4.15.0 --python 3.12 --quiet && npm install -g @sourcegraph/amp`, + wantInstallCmd: `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install acp-amp==0.1.3 --with agent-client-protocol==0.7.1 --with amp-sdk==0.1.2 --with pydantic==2.12.5 --with pydantic-core==2.41.5 --with annotated-types==0.7.0 --with typing-inspection==0.4.2 --with typing-extensions==4.15.0 --python 3.12 --quiet && npm install -g @ampcode/cli@0.0.1783785389-g0da70d`, }, } @@ -256,7 +256,7 @@ func TestGetAgentCommandInfoClaudeCode(t *testing.T) { if info.envVarName != "ANTHROPIC_API_KEY" { t.Fatalf("envVarName=%q, want %q", info.envVarName, "ANTHROPIC_API_KEY") } - if info.installCmd != "npm install -g @zed-industries/claude-agent-acp" { + if info.installCmd != "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1" { t.Fatalf("installCmd=%q, unexpected", info.installCmd) } if info.args != nil { @@ -274,7 +274,7 @@ func TestGetAgentCommandInfoOpenAICodex(t *testing.T) { if info.envVarName != "OPENAI_API_KEY" { t.Fatalf("envVarName=%q, want %q", info.envVarName, "OPENAI_API_KEY") } - if info.installCmd != "npm install -g @agentclientprotocol/codex-acp" { + if info.installCmd != "npm install -g @agentclientprotocol/codex-acp@1.1.2" { t.Fatalf("installCmd=%q, unexpected", info.installCmd) } if info.injectionMode != "" { @@ -301,7 +301,7 @@ func TestGetAgentCommandInfoOpenAICodexOAuth(t *testing.T) { if info.envVarName != "" { t.Fatalf("envVarName=%q, want empty for auth-file injection", info.envVarName) } - if info.installCmd != "npm install -g @agentclientprotocol/codex-acp" { + if info.installCmd != "npm install -g @agentclientprotocol/codex-acp@1.1.2" { t.Fatalf("installCmd=%q, unexpected", info.installCmd) } if len(info.args) != 2 || info.args[0] != "-c" || info.args[1] != `sandbox_mode="danger-full-access"` { @@ -314,7 +314,7 @@ func TestAgentInstallScriptCleansBrokenGitHubCLIRepoBeforeNpmBootstrap(t *testin info := agentCommandInfo{ command: "claude-agent-acp", - installCmd: "npm install -g @zed-industries/claude-agent-acp", + installCmd: "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1", isNpmBased: true, } @@ -326,7 +326,7 @@ func TestAgentInstallScriptCleansBrokenGitHubCLIRepoBeforeNpmBootstrap(t *testin `node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"`, "npm install -g n", "n 22", - "npm install -g @zed-industries/claude-agent-acp", + "npm install -g @agentclientprotocol/claude-agent-acp@0.58.1", } { if !strings.Contains(script, want) { t.Fatalf("agentInstallScript missing %q in %q", want, script) @@ -359,7 +359,7 @@ func TestGetAgentCommandInfoMistralVibe(t *testing.T) { if info.envVarName != "MISTRAL_API_KEY" { t.Fatalf("envVarName=%q, want %q", info.envVarName, "MISTRAL_API_KEY") } - wantInstall := `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.7.0 --python 3.12 --quiet` + wantInstall := `curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && UV_TOOL_DIR=/opt/uv-tools UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_TOOL_BIN_DIR=/usr/local/bin uv tool install mistral-vibe==2.19.1 --python 3.12 --quiet` if info.installCmd != wantInstall { t.Fatalf("installCmd=%q, want %q", info.installCmd, wantInstall) } @@ -405,7 +405,7 @@ func TestGetAgentCommandInfoAmp(t *testing.T) { for _, want := range []string{ "uv tool install acp-amp==0.1.3", "--with amp-sdk==0.1.2", - "npm install -g @sourcegraph/amp", + "npm install -g @ampcode/cli@0.0.1783785389-g0da70d", "Patched acp-amp: error handling + MCP config wrapping", "visibility default to private", } { @@ -414,7 +414,7 @@ func TestGetAgentCommandInfoAmp(t *testing.T) { } } if !info.isNpmBased { - t.Fatalf("isNpmBased=false, want true (amp chains npm install for @sourcegraph/amp)") + t.Fatalf("isNpmBased=false, want true (amp chains npm install for @ampcode/cli@0.0.1783785389-g0da70d)") } if len(info.args) != 1 || info.args[0] != "run" { t.Fatalf("args=%v, want [run]", info.args) @@ -444,7 +444,7 @@ func TestAgentInstallScriptAmpIncludesNodeBootstrap(t *testing.T) { info := getAgentCommandInfo("amp", "api-key") script := agentInstallScript(info) - // Amp is isNpmBased=true because it chains `npm install -g @sourcegraph/amp`. + // Amp is isNpmBased=true because it chains `npm install -g @ampcode/cli@0.0.1783785389-g0da70d`. // agentInstallScript must prepend the Node.js bootstrap preamble so npm is // available in devcontainers that don't ship with Node.js. if !strings.Contains(script, "apt-get install") { @@ -454,7 +454,7 @@ func TestAgentInstallScriptAmpIncludesNodeBootstrap(t *testing.T) { if !strings.Contains(script, "uv tool install acp-amp") { t.Fatalf("agentInstallScript lost the uv install portion") } - if !strings.Contains(script, "npm install -g @sourcegraph/amp") { + if !strings.Contains(script, "npm install -g @ampcode/cli@0.0.1783785389-g0da70d") { t.Fatalf("agentInstallScript lost the npm install portion") } } diff --git a/packages/vm-agent/internal/acp/session_host_selection.go b/packages/vm-agent/internal/acp/session_host_selection.go index ec7c610c3..729f01cf4 100644 --- a/packages/vm-agent/internal/acp/session_host_selection.go +++ b/packages/vm-agent/internal/acp/session_host_selection.go @@ -114,6 +114,7 @@ func (h *SessionHost) failAgentSelection(agentType, source, message string, err detail = err.Error() } h.reportAgentError(agentType, source, message, detail) + h.persistAgentSelectionFailure(agentType, message) } func (h *SessionHost) reportCredentialFetched(agentType string, cred *agentCredential) { diff --git a/packages/vm-agent/internal/acp/session_host_selection_persistence.go b/packages/vm-agent/internal/acp/session_host_selection_persistence.go new file mode 100644 index 000000000..a204fd91d --- /dev/null +++ b/packages/vm-agent/internal/acp/session_host_selection_persistence.go @@ -0,0 +1,29 @@ +package acp + +import ( + "log/slog" + "time" + + "github.com/google/uuid" +) + +type sessionErrorUpdater interface { + MarkError(workspaceID, sessionID, agentType, message string) error +} + +func (h *SessionHost) persistAgentSelectionFailure(agentType, message string) { + message = redactAgentDiagnosticText(message) + if updater, ok := h.config.SessionManager.(sessionErrorUpdater); ok && h.config.SessionID != "" { + if err := updater.MarkError(h.config.WorkspaceID, h.config.SessionID, agentType, message); err != nil { + slog.Warn("Failed to persist agent selection error", "error", err) + } + } + if h.config.MessageReporter != nil && h.config.SessionID != "" { + if err := h.config.MessageReporter.Enqueue(MessageReportEntry{ + MessageID: uuid.NewString(), SessionID: h.config.SessionID, Role: "system", + Content: "Agent startup failed: " + message, Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + }); err != nil { + slog.Warn("Failed to persist agent selection system message", "error", err) + } + } +} diff --git a/packages/vm-agent/internal/agentsessions/manager.go b/packages/vm-agent/internal/agentsessions/manager.go index 078000ef5..4121890ba 100644 --- a/packages/vm-agent/internal/agentsessions/manager.go +++ b/packages/vm-agent/internal/agentsessions/manager.go @@ -251,6 +251,25 @@ func (m *Manager) UpdateAcpSessionID(workspaceID, sessionID, acpSessionID, agent return nil } +func (m *Manager) MarkError(workspaceID, sessionID, agentType, message string) error { + m.mu.Lock() + defer m.mu.Unlock() + workspaceMap, ok := m.workspaceSessions[workspaceID] + if !ok { + return fmt.Errorf("workspace not found: %s", workspaceID) + } + session, ok := workspaceMap[sessionID] + if !ok { + return fmt.Errorf("session not found: %s", sessionID) + } + session.Status = StatusError + session.AgentType = agentType + session.Error = message + session.UpdatedAt = time.Now().UTC() + workspaceMap[sessionID] = session + return nil +} + func (m *Manager) RemoveWorkspace(workspaceID string) { m.mu.Lock() defer m.mu.Unlock() diff --git a/packages/vm-agent/internal/agentsessions/manager_error_test.go b/packages/vm-agent/internal/agentsessions/manager_error_test.go new file mode 100644 index 000000000..50d283b4e --- /dev/null +++ b/packages/vm-agent/internal/agentsessions/manager_error_test.go @@ -0,0 +1,20 @@ +package agentsessions + +import "testing" + +func TestMarkErrorPersistsFailureDetails(t *testing.T) { + m := NewManager() + if _, _, err := m.Create("ws1", "s1", "Chat", ""); err != nil { + t.Fatal(err) + } + if err := m.MarkError("ws1", "s1", "openai-codex", "install failed"); err != nil { + t.Fatal(err) + } + session, ok := m.Get("ws1", "s1") + if !ok { + t.Fatal("session missing") + } + if session.Status != StatusError || session.AgentType != "openai-codex" || session.Error != "install failed" { + t.Fatalf("unexpected failed session: %#v", session) + } +} diff --git a/scripts/quality/check-agent-install-manifest.ts b/scripts/quality/check-agent-install-manifest.ts new file mode 100644 index 000000000..9322d2397 --- /dev/null +++ b/scripts/quality/check-agent-install-manifest.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +interface Spec { + agentType: string; + method: 'npm' | 'uv-tool'; + package: string; + version: string; + bin: string; + python?: string; + extraPackages?: string[]; + npmCompanion?: { package: string; version: string }; + postInstallHook?: 'amp-sdk-patch'; +} +const root = process.cwd(); +const specs = JSON.parse( + readFileSync(join(root, 'packages/shared/src/agent-install-manifest.json'), 'utf8') +) as Spec[]; +const goSource = readFileSync(join(root, 'packages/vm-agent/internal/acp/gateway.go'), 'utf8'); +const dockerfile = readFileSync(join(root, 'apps/api/Dockerfile.vm-agent-container'), 'utf8'); +const catalog = readFileSync(join(root, 'packages/shared/src/agents.ts'), 'utf8'); +const safePackage = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/; +const safeVersion = /^[0-9][0-9A-Za-z.+-]*$/; +const ids = new Set(); +for (const spec of specs) { + if ( + ids.has(spec.agentType) || + !safePackage.test(spec.package) || + !safeVersion.test(spec.version) || + !/^[a-z0-9._-]+$/.test(spec.bin) + ) + throw new Error(`Invalid install manifest entry: ${spec.agentType}`); + ids.add(spec.agentType); + const pin = `${spec.package}${spec.method === 'npm' ? '@' : '=='}${spec.version}`; + if (!goSource.includes(pin)) throw new Error(`Go installer is missing ${pin}`); + if (!dockerfile.includes(pin)) throw new Error(`cf-container image is missing ${pin}`); + if (!catalog.includes(`id: '${spec.agentType}'`)) + throw new Error(`Agent catalog is missing ${spec.agentType}`); + if (spec.npmCompanion) { + const companion = `${spec.npmCompanion.package}@${spec.npmCompanion.version}`; + if (!goSource.includes(companion) || !dockerfile.includes(companion)) + throw new Error(`Runtime outputs are missing ${companion}`); + } +} +const catalogIds = [...catalog.matchAll(/\bid: '([^']+)'/g)].map((m) => m[1]); +if (catalogIds.some((id) => !ids.has(id))) + throw new Error('Agent catalog and install manifest IDs differ'); +console.log(`Agent install manifest is valid and synchronized (${specs.length} agents).`); diff --git a/tasks/backlog/2026-07-11-unified-agent-installation.md b/tasks/archive/2026-07-11-unified-agent-installation.md similarity index 68% rename from tasks/backlog/2026-07-11-unified-agent-installation.md rename to tasks/archive/2026-07-11-unified-agent-installation.md index 11f9e5ab5..5e76b4f96 100644 --- a/tasks/backlog/2026-07-11-unified-agent-installation.md +++ b/tasks/archive/2026-07-11-unified-agent-installation.md @@ -23,29 +23,36 @@ Cloudflare-container instant sessions run the vm-agent as a non-root user, but o ## Implementation Checklist -- [ ] Add a schema-validated, pinned structured agent install manifest with no free-form shell fields. -- [ ] Generate and commit the Go runtime install table from that manifest, including a deterministic sync check that rejects manual drift. -- [ ] Remove the dead TypeScript `installCommand` field and update catalog tests/spec references to the structured source. -- [ ] Preserve named Go hooks for vetted custom post-install behavior such as Amp's Python patches. -- [ ] Pin all catalog installer package versions and migrate Amp to `@ampcode/cli`. -- [ ] Add a standalone-only user-writable `SAM_AGENT_HOME` layout for npm and uv tools, with the same bin directory used by install detection and process launch. -- [ ] Keep devcontainer installation root-based, while generalizing npm partial-install cleanup to the selected package rather than one vendor scope. -- [ ] Generate the cf-container pre-bake install plan from the same manifest and bake the supported catalog set into `Dockerfile.vm-agent-container`. -- [ ] Expose a deterministic cf-container supported-agent manifest/contract so image capability cannot silently diverge from the catalog. -- [ ] Persist agent-selection failures to the durable session error state and add a user-visible system chat message, without leaking sensitive installer details. -- [ ] Add focused Go and TypeScript tests covering manifest validation/sync, rootless standalone installs, devcontainer behavior, pre-bake coverage, cleanup paths, and failure persistence. -- [ ] Update relevant public architecture/runtime documentation and reconcile the older binary-install hardening task. +- [x] Add a schema-validated, pinned structured agent install manifest with no free-form shell fields. +- [x] Keep the committed Go runtime install table synchronized with the manifest through a deterministic CI check that rejects manual drift. +- [x] Remove the dead TypeScript `installCommand` field and update catalog tests/spec references to the structured source. +- [x] Preserve named Go hooks for vetted custom post-install behavior such as Amp's Python patches. +- [x] Pin all catalog installer package versions and migrate Amp to `@ampcode/cli`. +- [x] Add a standalone-only user-writable `SAM_AGENT_HOME` layout for npm and uv tools, with the same bin directory used by install detection and process launch. +- [x] Keep devcontainer installation root-based, while generalizing npm partial-install cleanup to the selected package rather than one vendor scope. +- [x] Synchronize the cf-container pre-bake plan with the same manifest in CI and bake the supported catalog set into `Dockerfile.vm-agent-container`. +- [x] Expose a deterministic cf-container supported-agent manifest/contract so image capability cannot silently diverge from the catalog. +- [x] Persist agent-selection failures to the durable session error state and add a user-visible system chat message, without leaking sensitive installer details. +- [x] Add focused Go and TypeScript tests covering manifest validation/sync, rootless standalone installs, devcontainer behavior, pre-bake coverage, cleanup paths, and failure persistence. +- [x] Reconcile the older binary-install hardening task; no public behavior documentation changes are required for this internal runtime fix. ## Acceptance Criteria -- [ ] Every catalog agent has a pinned, structured install specification shared by generation outputs; CI fails on catalog/runtime/image drift. -- [ ] Standalone installs never require writes to root-owned global npm or uv paths, and installed binaries are discoverable by both the fast path and launched process. -- [ ] Devcontainer installs retain the documented root/npm-bootstrap behavior and do not receive a conflicting global npm prefix. -- [ ] The cf-container image pre-bakes every supported catalog agent that fits the measured image budget, or explicitly records any measured exclusion with a tested rootless fallback. -- [ ] Amp installs the maintained `@ampcode/cli` package at a pinned version and its ACP bridge post-install behavior remains covered. -- [ ] An install/selection failure produces durable failed/error session state plus a visible system message while preserving existing BootLog/error reporting. -- [ ] Local Go/TypeScript tests and the repository quality suite pass. -- [ ] The PR documents that staging was intentionally skipped and remains unmerged per explicit instruction. +- [x] Every catalog agent has a pinned, structured install specification shared by generation outputs; CI fails on catalog/runtime/image drift. +- [x] Standalone installs never require writes to root-owned global npm or uv paths, and installed binaries are discoverable by both the fast path and launched process. +- [x] Devcontainer installs retain the documented root/npm-bootstrap behavior and do not receive a conflicting global npm prefix. +- [x] The cf-container image pre-bakes every supported catalog agent that fits the measured image budget, or explicitly records any measured exclusion with a tested rootless fallback. +- [x] Amp installs the maintained `@ampcode/cli` package at a pinned version and its ACP bridge post-install behavior remains covered. +- [x] An install/selection failure produces durable failed/error session state plus a visible system message while preserving existing BootLog/error reporting. +- [x] Local Go/TypeScript tests and the repository quality suite pass. +- [x] The PR documents that staging was intentionally skipped and remains unmerged per explicit instruction. + +## Verification Evidence + +- Local Node 22 cf-container image built successfully with all six agent stacks and the Amp hook. +- Image size: 735,871,006 bytes (0.736 GB), below the 8 GB standard-1 disk budget. +- Non-root runtime user resolved all catalog binaries and completed a real rootless global npm fallback install under `SAM_AGENT_HOME`. +- Staging intentionally skipped by explicit user instruction. ## References diff --git a/tasks/backlog/2026-03-13-binary-install-security-hardening.md b/tasks/backlog/2026-03-13-binary-install-security-hardening.md index 6dea29f7c..846a808a4 100644 --- a/tasks/backlog/2026-03-13-binary-install-security-hardening.md +++ b/tasks/backlog/2026-03-13-binary-install-security-hardening.md @@ -24,7 +24,7 @@ Additionally, agent stderr is forwarded to the observability system without cred - [ ] Validate `uname -m` output against allowlist in Go `getAgentCommandInfo()` before URL construction - [ ] Add credential scrubbing to `reportAgentError` stderr forwarding (all agents) - [ ] Add post-download ELF magic byte check for non-npm installs -- [ ] Add CI lint rule or co-change comment linking `installCommand` in `agents.ts` and `gateway.go` +- [x] Replace dead TS `installCommand` with a structured pinned manifest and CI synchronization across the catalog, Go runtime, and cf-container image (2026-07-11 unified-agent-installation task) ## Acceptance Criteria