Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions agent/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion agent/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencomputer/agent",
"version": "0.5.1",
"version": "0.5.2",
"description": "Reactive agent authoring API for OpenComputer.",
"type": "module",
"main": "./dist/index.js",
Expand Down
57 changes: 46 additions & 11 deletions agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,28 @@ export interface ScheduleRunContext {
readonly manual: boolean;
}

export interface WebhookRequestContext {
readonly id: string;
readonly requestId: string;
readonly receivedAt: string;
}

interface BasicAgentInput {
readonly text?: string;
readonly payload?: DataValue;
}

export type AgentInput =
| (BasicAgentInput & { readonly source: Exclude<InputSource, "schedule"> })
| (BasicAgentInput & {
readonly source: Exclude<InputSource, "schedule" | "webhook">;
})
| (BasicAgentInput & {
readonly source: "schedule";
readonly schedule: Readonly<ScheduleRunContext>;
})
| (BasicAgentInput & {
readonly source: "webhook";
readonly webhook: Readonly<WebhookRequestContext>;
});

export interface ResourceReference {
Expand Down Expand Up @@ -306,7 +318,10 @@ export function defineConnection(input: {
}
const redirectOrigins = input.redirectOrigins?.map((input) => {
const redirectOrigin = new URL(input.origin);
if (redirectOrigin.protocol !== "https:" || redirectOrigin.pathname !== "/") {
if (
redirectOrigin.protocol !== "https:" ||
redirectOrigin.pathname !== "/"
) {
throw new Error(
"Connection redirect origins must be HTTPS origins without a path",
);
Expand Down Expand Up @@ -442,7 +457,9 @@ function schedulePayload(value: DataValue | undefined): DataValue | undefined {
throw new Error("Schedule payloads must be JSON-compatible");
}
if (serialized === undefined || serialized.length > 32 * 1024) {
throw new Error("Schedule payloads must be JSON-compatible and at most 32 KiB");
throw new Error(
"Schedule payloads must be JSON-compatible and at most 32 KiB",
);
}
return JSON.parse(serialized) as DataValue;
}
Expand All @@ -461,7 +478,9 @@ export function defineSchedule(input: {
const id = resourceIdentifier(input.id, "defineSchedule");
const cron = input.cron.trim().replace(/\s+/g, " ");
if (cron.split(" ").length !== 5) {
throw new Error("Schedule cron expressions must contain exactly five fields");
throw new Error(
"Schedule cron expressions must contain exactly five fields",
);
}
const timezone = input.timezone?.trim() || "UTC";
try {
Expand All @@ -474,7 +493,9 @@ export function defineSchedule(input: {
} catch {
throw new Error(`Schedule ${id} has an invalid cron expression`);
}
const enabled = [...new Set(input.enabled ?? ["production"])] as ScheduleEnvironment[];
const enabled = [
...new Set(input.enabled ?? ["production"]),
] as ScheduleEnvironment[];
if (
!enabled.length ||
enabled.some(
Expand Down Expand Up @@ -517,7 +538,10 @@ export function defineChannel(input: {
}): SlackChannelDefinition {
const id = resourceIdentifier(input.id, "defineChannel");
const scopes = [...new Set(input.scopes.bot.map((scope) => scope.trim()))];
if (!scopes.length || scopes.some((scope) => !SLACK_SCOPE_PATTERN.test(scope))) {
if (
!scopes.length ||
scopes.some((scope) => !SLACK_SCOPE_PATTERN.test(scope))
) {
throw new Error("Slack bot scopes must be non-empty Slack scope names");
}
const events = [...new Set(input.events ?? [])];
Expand All @@ -527,16 +551,23 @@ export function defineChannel(input: {
throw new Error(`Slack event ${event} requires bot scope ${required}`);
}
}
const destinations: Record<string, Readonly<ChannelDestinationDefinition>> = {};
const destinations: Record<
string,
Readonly<ChannelDestinationDefinition>
> = {};
for (const [name, destination] of Object.entries(input.destinations ?? {})) {
const destinationId = resourceIdentifier(name, "Channel destination");
const required =
destination.visibility === "private" ? "groups:read" : "channels:read";
if (!scopes.includes(required)) {
throw new Error(`Slack destination ${destinationId} requires bot scope ${required}`);
throw new Error(
`Slack destination ${destinationId} requires bot scope ${required}`,
);
}
if (!scopes.includes("chat:write")) {
throw new Error(`Slack destination ${destinationId} requires bot scope chat:write`);
throw new Error(
`Slack destination ${destinationId} requires bot scope chat:write`,
);
}
destinations[destinationId] = Object.freeze({ ...destination });
}
Expand All @@ -545,11 +576,15 @@ export function defineChannel(input: {
version: 1 as const,
id,
type: input.type,
...(input.displayName?.trim() ? { displayName: input.displayName.trim() } : {}),
...(input.displayName?.trim()
? { displayName: input.displayName.trim() }
: {}),
scopes: Object.freeze({ bot: Object.freeze(scopes) }),
events: Object.freeze(events),
destinations: Object.freeze(destinations),
routing: Object.freeze({ whenAmbiguous: input.routing?.whenAmbiguous ?? "ask" }),
routing: Object.freeze({
whenAmbiguous: input.routing?.whenAmbiguous ?? "ask",
}),
});
}

Expand Down
16 changes: 16 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ opencomputer run hello-world "Say hello"
The CLI calls the public OpenComputer API and uses OpenComputer authentication.
It does not require a separate backend account, key, or CLI.

## Agent webhooks

Create an environment-scoped webhook that starts a fresh session for the
selected agent. The bearer token is displayed only when created or rotated:

```bash
opencomputer webhooks create daily-hygiene --agent current --environment production
opencomputer webhooks list --agent current --environment production
opencomputer webhooks disable <webhook-id>
opencomputer webhooks rotate-token <webhook-id>
opencomputer webhooks remove <webhook-id>
```

Invoke the URL with a JSON object containing `text`, `payload`, or both. The
structured payload is available to agent code as `input.payload`.

## Secrets and managed egress

Secret values are read from a hidden prompt, or from standard input in CI.
Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencomputer/cli",
"version": "0.5.5",
"version": "0.5.6",
"description": "Build, test, deploy, and share OpenComputer agents as code.",
"type": "module",
"bin": {
Expand Down
86 changes: 85 additions & 1 deletion cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ export interface AgentRuntimeVariableMetadata {
updatedAt: string;
}

export interface ManagedAgentWebhook {
id: string;
projectId: string;
environment: "development" | "production";
agentId: string;
name: string;
enabled: boolean;
invocationUrl: string;
token?: string;
createdAt: string;
updatedAt: string;
lastInvokedAt?: string;
}

export interface ManagedAgentLog {
id: string;
cursor: string;
Expand Down Expand Up @@ -322,6 +336,72 @@ export class OpenComputerClient {
);
}

async webhooks(input: {
projectId: string;
environment?: "development" | "production";
agentId?: string;
}): Promise<ManagedAgentWebhook[]> {
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<{ webhooks: ManagedAgentWebhook[] }>(
`/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks${suffix}`,
);
return result.webhooks;
}

createWebhook(input: {
projectId: string;
name: string;
environment: "development" | "production";
agentId: string;
}) {
return this.request<{ webhook: ManagedAgentWebhook }>(
`/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks`,
{
method: "POST",
body: JSON.stringify({
name: input.name,
environment: input.environment,
agentId: input.agentId,
}),
},
).then((result) => result.webhook);
}

updateWebhook(input: {
projectId: string;
webhookId: string;
name?: string;
enabled?: boolean;
}) {
return this.request<{ webhook: ManagedAgentWebhook }>(
`/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks/${encodeURIComponent(input.webhookId)}`,
{
method: "PATCH",
body: JSON.stringify({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
}),
},
).then((result) => result.webhook);
}

rotateWebhookToken(input: { projectId: string; webhookId: string }) {
return this.request<{ webhook: ManagedAgentWebhook }>(
`/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks/${encodeURIComponent(input.webhookId)}/rotate-token`,
{ method: "POST" },
).then((result) => result.webhook);
}

deleteWebhook(input: { projectId: string; webhookId: string }) {
return this.request<void>(
`/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks/${encodeURIComponent(input.webhookId)}`,
{ method: "DELETE" },
);
}

logs(input: {
agentId?: string;
sessionId?: string;
Expand Down Expand Up @@ -367,7 +447,11 @@ export class OpenComputerClient {
id: string;
digest: string;
localAgentId: string;
agents: Array<{ localId: string; agentId: string; artifactDigest: string }>;
agents: Array<{
localId: string;
agentId: string;
artifactDigest: string;
}>;
resources: ProjectResourceManifest;
};
source: {
Expand Down
Loading
Loading