diff --git a/cli/README.md b/cli/README.md index 8cbdc05c..a9e661d8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -68,6 +68,17 @@ opencomputer secrets list --environment development opencomputer secrets remove GITHUB_TOKEN --environment development ``` +For values that agent code and commands must read directly from the process +environment, use encrypted agent runtime variables. They require no source +declaration and apply to newly started runtimes: + +```bash +opencomputer env set DATABASE_URL +opencomputer env set DATABASE_URL --agent current --environment production +opencomputer env list --environment development +opencomputer env remove DATABASE_URL --environment development +``` + Agent code declares secret-backed destinations with `defineConnection()` and `useSecret()`. Requests use the managed gateway, which injects a secret only for the declared origin, path, method, agent, and diff --git a/cli/package-lock.json b/cli/package-lock.json index 4e890cc3..535648a7 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencomputer/cli", - "version": "0.5.4", + "version": "0.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencomputer/cli", - "version": "0.5.4", + "version": "0.5.5", "dependencies": { "@opencode-ai/sdk": "1.18.4", "ai": "^7.0.45", diff --git a/cli/package.json b/cli/package.json index 517f134c..5eb04c3d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opencomputer/cli", - "version": "0.5.4", + "version": "0.5.5", "description": "Build, test, deploy, and share OpenComputer agents as code.", "type": "module", "bin": { diff --git a/cli/src/api.ts b/cli/src/api.ts index c99a56f2..0e00d9ab 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -57,6 +57,15 @@ export interface ManagedSecretMetadata { updatedAt: string; } +export interface AgentRuntimeVariableMetadata { + name: string; + projectId: string; + environment: "development" | "production"; + agentId?: string; + createdAt: string; + updatedAt: string; +} + export interface ManagedAgentLog { id: string; cursor: string; @@ -262,6 +271,57 @@ export class OpenComputerClient { ); } + async runtimeVariables(input: { + projectId: string; + environment?: "development" | "production"; + agentId?: string; + }): Promise { + const query = new URLSearchParams(); + if (input.environment) query.set("environment", input.environment); + if (input.agentId) query.set("agentId", input.agentId); + const suffix = query.size ? `?${query.toString()}` : ""; + const result = await this.request<{ + variables: AgentRuntimeVariableMetadata[]; + }>( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/runtime-variables${suffix}`, + ); + return result.variables; + } + + putRuntimeVariable(input: { + projectId: string; + name: string; + value: string; + environment: "development" | "production"; + agentId?: string; + }) { + return this.request( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/runtime-variables/${encodeURIComponent(input.name)}`, + { + method: "PUT", + body: JSON.stringify({ + value: input.value, + environment: input.environment, + ...(input.agentId ? { agentId: input.agentId } : {}), + }), + }, + ); + } + + deleteRuntimeVariable(input: { + projectId: string; + name: string; + environment: "development" | "production"; + agentId?: string; + }) { + const query = new URLSearchParams({ environment: input.environment }); + if (input.agentId) query.set("agentId", input.agentId); + return this.request( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/runtime-variables/${encodeURIComponent(input.name)}?${query.toString()}`, + { method: "DELETE" }, + ); + } + logs(input: { agentId?: string; sessionId?: string; diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 7f6a34ae..c65f55d4 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -76,7 +76,9 @@ async function readSecretValue(): Promise { for await (const chunk of process.stdin) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } - const value = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, ""); + const value = Buffer.concat(chunks) + .toString("utf8") + .replace(/\r?\n$/, ""); if (!value) throw new Error("Secret value was empty"); return value; } @@ -663,9 +665,7 @@ export async function runCommand( } const name = args.shift(); if (!name) { - throw new Error( - "Use `opencomputer secrets set|list|remove `.", - ); + throw new Error("Use `opencomputer secrets set|list|remove `."); } if (action === "set") { const explicitOrigins = options(args, "--allow-origin"); @@ -724,6 +724,80 @@ export async function runCommand( throw new Error("Use `opencomputer secrets set`, `list`, or `remove`."); } + if (command === "env") { + const action = args.shift(); + const projectReference = option(args, "--project"); + const agentOption = option(args, "--agent"); + const environment = environmentOption(option(args, "--environment")); + const project = await selectedProject( + client, + config, + projectReference, + !globals.json, + ); + const agentId = agentOption + ? agentOption === "current" + ? project.agentId + : agentOption + : undefined; + if (action === "list") { + if (args.length) throw new Error(`Unexpected argument: ${args[0]}`); + const variables = await client.runtimeVariables({ + projectId: project.projectId, + environment, + ...(agentId ? { agentId } : {}), + }); + if (globals.json) printJSON(variables); + else if (!variables.length) + process.stdout.write("No runtime variables.\n"); + else { + for (const variable of variables) { + process.stdout.write( + `${variable.name.padEnd(28)} ${variable.environment.padEnd(12)} ` + + `${variable.agentId ?? "project"}\n`, + ); + } + } + return; + } + const name = args.shift()?.trim().toUpperCase(); + if (!name) { + throw new Error("Use `opencomputer env set|list|remove `."); + } + if (action === "set") { + if (args.length) throw new Error(`Unexpected argument: ${args[0]}`); + const variable = await client.putRuntimeVariable({ + projectId: project.projectId, + name, + value: await readSecretValue(), + environment, + ...(agentId ? { agentId } : {}), + }); + if (globals.json) printJSON(variable); + else { + process.stdout.write( + `Set ${variable.name} for ${variable.agentId ?? "project"} ` + + `(${variable.environment}). Restart the agent runtime to apply it.\n`, + ); + } + return; + } + if (action === "remove" || action === "delete") { + if (args.length) throw new Error(`Unexpected argument: ${args[0]}`); + await client.deleteRuntimeVariable({ + projectId: project.projectId, + name, + environment, + ...(agentId ? { agentId } : {}), + }); + if (globals.json) + printJSON({ removed: true, name, environment, agentId }); + else process.stdout.write(`Removed runtime variable ${name}.\n`); + return; + } + throw new Error("Use `opencomputer env set|list|remove `."); + } + if (command === "logs") { const follow = flag(args, "--follow"); let agentId = option(args, "--agent"); diff --git a/cli/src/index.ts b/cli/src/index.ts index 80b15d55..0f64b5b9 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -60,6 +60,9 @@ Usage: opencomputer secrets set [--environment development|production] [--agent |current] opencomputer secrets list [--environment development|production] [--agent |current] opencomputer secrets remove [--environment development|production] [--agent |current] + opencomputer env set [--environment development|production] [--agent |current] + opencomputer env list [--environment development|production] [--agent |current] + opencomputer env remove [--environment development|production] [--agent |current] opencomputer logs [--agent ] [--session ] [--environment development|production] [--follow] opencomputer deploy [--alias ] opencomputer run [--keep] diff --git a/cloudflare-workers/api-edge/src/managed_agents.test.ts b/cloudflare-workers/api-edge/src/managed_agents.test.ts index b1138277..da382f0a 100644 --- a/cloudflare-workers/api-edge/src/managed_agents.test.ts +++ b/cloudflare-workers/api-edge/src/managed_agents.test.ts @@ -443,8 +443,7 @@ describe("managed agents proxy", () => { expect(fetchSpy).toHaveBeenCalledWith( expect.objectContaining({ - href: - "https://managedagents.test/v1/outboxes?agentId=reviewer-agent&environment=development", + href: "https://managedagents.test/v1/outboxes?agentId=reviewer-agent&environment=development", }), expect.anything(), ); @@ -918,6 +917,48 @@ describe("managed agents proxy", () => { }); }); + it("forwards runtime variable metadata without returning its value", async () => { + const fetchSpy = vi.fn(async () => + Response.json({ + name: "DATABASE_URL", + value: "postgres://must-not-leak", + projectId: "prj_1", + environment: "production", + createdAt: "2026-08-18T00:00:00.000Z", + updatedAt: "2026-08-18T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchSpy); + + const response = await proxyManagedAgents( + new Request( + "https://app.opencomputer.dev/api/managed-agents/projects/prj_1/runtime-variables/DATABASE_URL", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + value: "postgres://must-not-leak", + environment: "production", + }), + }, + ), + { + OC_MANAGED_AGENTS_SECRET: "test-secret", + MANAGED_AGENTS_API_URL: "https://managedagents.test", + }, + { orgID: "org_test", userID: "user_test" }, + "/api/managed-agents", + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ + name: "DATABASE_URL", + environment: "production", + }); + expect(JSON.stringify(body)).not.toContain("postgres://must-not-leak"); + }); + it("forwards redacted reactive render snapshots for the debug playground", async () => { vi.stubGlobal( "fetch", @@ -967,7 +1008,9 @@ describe("managed agents proxy", () => { ], }); expect(JSON.stringify(body)).not.toContain("never-return-this"); - expect(JSON.stringify(body)).not.toContain("You are an OpenComputer agent."); + expect(JSON.stringify(body)).not.toContain( + "You are an OpenComputer agent.", + ); expect(JSON.stringify(body)).not.toContain("platformInstructions"); }); diff --git a/cloudflare-workers/api-edge/src/managed_agents.ts b/cloudflare-workers/api-edge/src/managed_agents.ts index 2f8b5577..57af1b3b 100644 --- a/cloudflare-workers/api-edge/src/managed_agents.ts +++ b/cloudflare-workers/api-edge/src/managed_agents.ts @@ -320,6 +320,20 @@ function stripPrivateValues(value: unknown): unknown { ); } +function publicRuntimeVariable(value: unknown): Record { + const variable = record(value) ?? {}; + return { + name: variable.name, + projectId: variable.projectId, + environment: variable.environment, + ...(typeof variable.agentId === "string" + ? { agentId: variable.agentId } + : {}), + createdAt: variable.createdAt, + updatedAt: variable.updatedAt, + }; +} + const PRIVATE_EVENT_KEYS = new Set([ "accountId", "account_id", @@ -400,6 +414,14 @@ function publicSuccessBody( ) { return stripPrivateValues(body); } + if ( + (method === "GET" || method === "PUT") && + /^\/projects\/[^/]+\/runtime-variables(?:\/[^/]+)?$/.test(suffix) + ) { + return Array.isArray(body.variables) + ? { variables: body.variables.map(publicRuntimeVariable) } + : publicRuntimeVariable(body); + } if (method === "GET" && suffix === "/logs") { return stripPrivateValues(body); } @@ -714,6 +736,12 @@ function isAllowedManagedAgentsRoute(method: string, suffix: string): boolean { ) { return true; } + if ( + (method === "GET" || method === "PUT" || method === "DELETE") && + /^\/projects\/[^/]+\/runtime-variables(?:\/[^/]+)?$/.test(suffix) + ) { + return true; + } if (method === "GET" && suffix === "/logs") return true; if (method === "POST" && suffix === "/deployments") return true; if (method === "POST" && suffix === "/benchmarks/warm-pool") return true; diff --git a/docs/agents/secrets.mdx b/docs/agents/secrets.mdx index fecbb2fa..daa7785a 100644 --- a/docs/agents/secrets.mdx +++ b/docs/agents/secrets.mdx @@ -1,6 +1,6 @@ --- -title: "Secrets and outbound requests" -description: "Use project and agent secrets without exposing values to agent runtimes" +title: "Secrets and runtime variables" +description: "Configure managed outbound credentials and agent runtime environment variables" --- OpenComputer secrets are write-only values used by declared outbound @@ -10,11 +10,7 @@ deployment manifests, runtime environment variables, logs, or API responses. ## Declare a connection ```tsx -import { - bearer, - defineConnection, - useSecret, -} from "@opencomputer/agent"; +import { bearer, defineConnection, useSecret } from "@opencomputer/agent"; const github = defineConnection({ id: "github-api", @@ -99,9 +95,33 @@ npx --package @opencomputer/cli opencomputer secrets remove GITHUB_TOKEN --envir List output contains metadata such as the name, scope, environment, and allowed origins. Secret values are never returned. +## Agent runtime variables + +Use an agent runtime variable when code or a command must receive a value as a +normal environment variable. Configure it in the **Agent runtime variables** +section of the project's Secrets page, or with the CLI: + +```bash +npx --package @opencomputer/cli opencomputer env set DATABASE_URL +npx --package @opencomputer/cli opencomputer env list --environment development +npx --package @opencomputer/cli opencomputer env remove DATABASE_URL +``` + +The CLI reads new values from a hidden prompt. Runtime variables can apply to +the whole project or override one agent with `--agent current`, and development +and production values are separate. No declaration in agent source is needed. + +OpenComputer stores these values encrypted and never returns them through the +dashboard or management API. A newly started agent runtime receives the +resolved values in its process environment, so agent code, tools, commands, and +child processes can read them. Because the agent can access the plaintext, +runtime variables are appropriate for personal-agent credentials such as +`DATABASE_URL`, but they do not provide the destination isolation of managed +secrets. Restart a running agent runtime after changing a value. + ## Security guarantees -OpenComputer resolves the declared connection and scoped secret for each +For managed secrets, OpenComputer resolves the declared connection and scoped secret for each request. The credential is attached only after the destination, method, path, agent, project, and environment have been validated. The value is never added to the agent's source bundle, prompt, browser application, or logs. diff --git a/web/src/managed-agents/Secrets.tsx b/web/src/managed-agents/Secrets.tsx index 019ad208..95e7e9a0 100644 --- a/web/src/managed-agents/Secrets.tsx +++ b/web/src/managed-agents/Secrets.tsx @@ -13,8 +13,11 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { notifyError, notifySuccess } from '@/lib/errors' import { + deleteAgentRuntimeVariable, deleteManagedProjectSecret, + getAgentRuntimeVariables, getManagedProjectSecrets, + putAgentRuntimeVariable, putManagedProjectSecret, } from './api' @@ -91,7 +94,7 @@ export function ManagedProjectSecrets({ } return ( -
+
@@ -150,7 +153,8 @@ export function ManagedProjectSecrets({
{!origins.trim() ? ( -

+

All hosts — not recommended. Prefer limiting this secret to the external APIs that need it.

@@ -266,6 +270,223 @@ export function ManagedProjectSecrets({ )} +
) } + +function AgentRuntimeVariables({ + projectId, + agents, + environment, +}: { + projectId: string + agents: Array<{ id: string; name: string }> + environment: Environment +}) { + const queryClient = useQueryClient() + const [name, setName] = useState('') + const [value, setValue] = useState('') + const [agentId, setAgentId] = useState('') + const queryKey = ['agent-runtime-variables', projectId, environment] + const variables = useQuery({ + queryKey, + queryFn: () => getAgentRuntimeVariables(projectId, environment), + }) + const save = useMutation({ + mutationFn: putAgentRuntimeVariable, + onSuccess: async () => { + setName('') + setValue('') + await queryClient.invalidateQueries({ queryKey }) + notifySuccess( + 'Runtime variable saved.', + 'Newly started agent runtimes will receive it.', + ) + }, + onError: (error) => + notifyError("Couldn't save the runtime variable.", error), + }) + const remove = useMutation({ + mutationFn: deleteAgentRuntimeVariable, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey }) + notifySuccess('Runtime variable removed.') + }, + onError: (error) => + notifyError("Couldn't remove the runtime variable.", error), + }) + const agentNames = new Map(agents.map((agent) => [agent.id, agent.name])) + + function submit(event: FormEvent) { + event.preventDefault() + const normalizedName = name.trim().toUpperCase() + if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(normalizedName)) { + notifyError( + 'Use an uppercase variable name containing only letters, numbers, and underscores.', + ) + return + } + save.mutate({ + projectId, + environment, + ...(agentId ? { agentId } : {}), + name: normalizedName, + value, + }) + } + + return ( +
+ + +
+ Agent runtime variables + + Environment variables injected directly into agent code, tools, + commands, and subprocesses. Values are encrypted and masked here, + but the running agent can read them through process.env. + +
+ + {environment} + +
+ +
+
+ + setName(event.target.value.toUpperCase())} + placeholder="DATABASE_URL" + autoComplete="off" + /> +
+
+ + +
+
+ + setValue(event.target.value)} + placeholder="Enter a new value" + autoComplete="new-password" + /> +

+ Existing values cannot be viewed. Saving the same name and scope + replaces its value. Restart the agent runtime to apply changes. +

+
+
+ +
+
+
+
+ + + +
+ Configured runtime variables + + Values stay masked in OpenComputer but are available inside the + agent runtime. + +
+
+ {variables.isLoading ? ( + + Loading runtime + variables… + + ) : variables.isError ? ( + +

+ Runtime variables are temporarily unavailable. +

+ +
+ ) : variables.data?.length ? ( +
+ {variables.data.map((variable) => ( +
+
+

+ {variable.name} +

+

+ Updated {new Date(variable.updatedAt).toLocaleString()} +

+
+
+

Scope

+

+ {variable.agentId + ? (agentNames.get(variable.agentId) ?? variable.agentId) + : 'Entire project'} +

+
+ +
+ ))} +
+ ) : ( + + No runtime variables configured for {environment}. + + )} +
+
+ ) +} diff --git a/web/src/managed-agents/api.ts b/web/src/managed-agents/api.ts index f0eddb00..184258a2 100644 --- a/web/src/managed-agents/api.ts +++ b/web/src/managed-agents/api.ts @@ -99,6 +99,19 @@ const secretSchema = z.object({ const secretsResponseSchema = z.object({ secrets: z.array(secretSchema) }) +const runtimeVariableSchema = z.object({ + name: z.string(), + projectId: z.string(), + environment: z.enum(['development', 'production']), + agentId: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +const runtimeVariablesResponseSchema = z.object({ + variables: z.array(runtimeVariableSchema), +}) + const connectionSchema = z.object({ id: z.string(), kind: z.enum(['tool', 'channel']), @@ -439,6 +452,54 @@ export async function deleteManagedProjectSecret(input: { ) } +export async function getAgentRuntimeVariables( + projectId: string, + environment: 'development' | 'production', +) { + return ( + await apiFetch( + `/managed-agents/projects/${encodeURIComponent(projectId)}/runtime-variables?environment=${encodeURIComponent(environment)}`, + undefined, + runtimeVariablesResponseSchema, + ) + ).variables +} + +export async function putAgentRuntimeVariable(input: { + projectId: string + environment: 'development' | 'production' + agentId?: string + name: string + value: string +}) { + return apiFetch( + `/managed-agents/projects/${encodeURIComponent(input.projectId)}/runtime-variables/${encodeURIComponent(input.name)}`, + { + method: 'PUT', + body: JSON.stringify({ + environment: input.environment, + ...(input.agentId ? { agentId: input.agentId } : {}), + value: input.value, + }), + }, + runtimeVariableSchema, + ) +} + +export async function deleteAgentRuntimeVariable(input: { + projectId: string + environment: 'development' | 'production' + agentId?: string + name: string +}) { + const query = new URLSearchParams({ environment: input.environment }) + if (input.agentId) query.set('agentId', input.agentId) + return apiFetch( + `/managed-agents/projects/${encodeURIComponent(input.projectId)}/runtime-variables/${encodeURIComponent(input.name)}?${query.toString()}`, + { method: 'DELETE' }, + ) +} + export async function getManagedAgentConnections() { return ( await apiFetch(