Skip to content
Open
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
70 changes: 68 additions & 2 deletions agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,21 @@ export interface SecretHeaderReference {
readonly suffix?: string;
}

export type GithubAppPermission = "read" | "write";

export interface GithubAppHeaderReference {
readonly kind: "github-app-header";
readonly permissions: Readonly<Record<string, GithubAppPermission>>;
}

export type ConnectionHeaderValue =
| string
| SecretHeaderReference
| GithubAppHeaderReference;

export interface HttpConnectionDefinition extends ConnectionReference {
readonly origin: string;
readonly headers: Readonly<Record<string, string | SecretHeaderReference>>;
readonly headers: Readonly<Record<string, ConnectionHeaderValue>>;
readonly methods?: readonly string[];
readonly pathPrefix?: string;
readonly redirectOrigins?: readonly HttpConnectionRedirectOrigin[];
Expand Down Expand Up @@ -286,10 +298,53 @@ export function bearer(secret: SecretReference): SecretHeaderReference {
return secretHeader(secret, { prefix: "Bearer " });
}

const GITHUB_APP_PERMISSION_KEYS = new Set([
"contents",
"pull_requests",
"issues",
"metadata",
"checks",
]);

const GITHUB_APP_ORIGINS = new Set([
"https://api.github.com",
"https://api.githubcopilot.com",
]);

export function githubApp(options: {
permissions: Readonly<Record<string, GithubAppPermission>>;
}): GithubAppHeaderReference {
const entries = Object.entries(options?.permissions ?? {});
if (entries.length === 0) {
throw new Error(
"githubApp() requires at least one permission, e.g. githubApp({ permissions: { contents: \"read\" } })",
);
}
const permissions: Record<string, GithubAppPermission> = {};
for (const [key, value] of entries) {
if (!GITHUB_APP_PERMISSION_KEYS.has(key)) {
throw new Error(
`githubApp() does not support the ${key} permission; supported keys are ${[...GITHUB_APP_PERMISSION_KEYS].join(", ")}`,
);
}
if (value !== "read" && value !== "write") {
throw new Error(`githubApp() permissions must be read or write, got ${String(value)}`);
}
if ((key === "metadata" || key === "checks") && value === "write") {
throw new Error(`The ${key} permission is read-only`);
}
permissions[key] = value;
}
return Object.freeze({
kind: "github-app-header",
permissions: Object.freeze(permissions),
});
}

export function defineConnection(input: {
id: string;
origin: string;
headers?: Readonly<Record<string, string | SecretHeaderReference>>;
headers?: Readonly<Record<string, ConnectionHeaderValue>>;
methods?: readonly string[];
pathPrefix?: string;
redirectOrigins?: readonly HttpConnectionRedirectOrigin[];
Expand All @@ -299,6 +354,17 @@ export function defineConnection(input: {
if (origin.protocol !== "https:" || origin.pathname !== "/") {
throw new Error("Connection origins must be HTTPS origins without a path");
}
for (const [, value] of Object.entries(input.headers ?? {})) {
if (
typeof value === "object" &&
value.kind === "github-app-header" &&
!GITHUB_APP_ORIGINS.has(origin.origin)
) {
throw new Error(
"githubApp() headers are only valid on GitHub API origins (https://api.github.com, https://api.githubcopilot.com)",
);
}
}
for (const [name, value] of Object.entries(input.headers ?? {})) {
if (
[
Expand Down
33 changes: 33 additions & 0 deletions cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,35 @@ export class OpenComputerClient {
);
}

githubStatus(input: { projectId: string }) {
return this.request<{
environments: Array<{
environment: "development" | "production";
state: string;
app?: { mode: string; slug: string };
installation?: { accountLogin: string };
scopeMode?: "all" | "selected";
selectedRepositoryCount?: number;
}>;
ocAppAvailable: boolean;
}>(
`/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/github`,
);
}

githubConnect(input: {
projectId: string;
environments: Array<"development" | "production">;
}) {
return this.request<{ installUrl?: string }>(
`/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/github/connect`,
{
method: "POST",
body: JSON.stringify({ environments: input.environments }),
},
);
}

deleteSecret(input: {
projectId: string;
name: string;
Expand Down Expand Up @@ -438,6 +467,10 @@ export class OpenComputerClient {
prefix?: string;
suffix?: string;
}
| {
kind: "github_app";
permissions: Record<string, "read" | "write">;
}
>;
methods?: string[];
pathPrefix?: string;
Expand Down
74 changes: 73 additions & 1 deletion cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,10 @@ export async function runCommand(
allowedOrigins = built.httpConnections
.filter((connection) =>
Object.values(connection.headers).some(
(value) => typeof value !== "string" && value.name === name,
(value) =>
typeof value !== "string" &&
value.kind === "secret" &&
value.name === name,
),
)
.flatMap((connection) => [
Expand Down Expand Up @@ -798,6 +801,75 @@ export async function runCommand(
throw new Error("Use `opencomputer env set|list|remove <name>`.");
}

if (command === "github") {
const action = args.shift();
const projectReference = option(args, "--project");
const project = await selectedProject(
client,
config,
projectReference,
!globals.json,
);
if (action === "status" || action === undefined) {
if (args.length) throw new Error(`Unexpected argument: ${args[0]}`);
const status = await client.githubStatus({
projectId: project.projectId,
});
if (globals.json) printJSON(status);
else {
for (const entry of status.environments) {
const app = entry.app
? `${entry.app.slug} (${entry.app.mode === "oc_app" ? "shared" : "dedicated"})`
: "—";
const scope =
entry.scopeMode === "selected"
? `${entry.selectedRepositoryCount ?? 0} selected repositories`
: entry.scopeMode === "all"
? "all granted repositories"
: "";
process.stdout.write(
`${entry.environment.padEnd(12)} ${entry.state.padEnd(14)} ${app}` +
(entry.installation ? ` @${entry.installation.accountLogin}` : "") +
(scope ? ` ${scope}` : "") +
"\n",
);
}
if (
status.environments.every((entry) => entry.state === "not_connected")
) {
process.stdout.write(
"Connect with `opencomputer github connect` or from the dashboard Repositories tab.\n",
);
}
}
return;
}
if (action === "connect") {
const environmentValue = option(args, "--environment");
if (args.length) throw new Error(`Unexpected argument: ${args[0]}`);
const result = await client.githubConnect({
projectId: project.projectId,
environments: environmentValue
? [environmentOption(environmentValue)]
: ["development", "production"],
});
if (globals.json) printJSON(result);
else if (result.installUrl) {
process.stdout.write(
"Open this URL to install the OpenComputer GitHub app:\n" +
` ${result.installUrl}\n` +
"Pick repositories on GitHub, then manage scope from the dashboard Repositories tab.\n",
);
} else {
process.stdout.write("GitHub is connected for this project.\n");
}
return;
}
throw new Error(
"Use `opencomputer github status` or `opencomputer github connect`.",
);
}

if (command === "webhooks") {
const action = args.shift();
const projectReference = option(args, "--project");
Expand Down
29 changes: 28 additions & 1 deletion cli/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function secretOrigins(results: DevelopmentResults): Map<string, string[]> {
for (const { built } of results) {
for (const connection of built.httpConnections) {
for (const header of Object.values(connection.headers)) {
if (typeof header === "string") continue;
if (typeof header === "string" || header.kind !== "secret") continue;
const current = origins.get(header.name) ?? new Set<string>();
current.add(connection.origin);
for (const redirect of connection.redirectOrigins ?? []) {
Expand Down Expand Up @@ -516,6 +516,33 @@ export async function runCloudDevelopment(
? `React: starting local Vite app\n`
: `React: not included\n`),
);
const usesGithubApp = initial.some(({ built }) =>
built.httpConnections.some((connection) =>
Object.values(connection.headers).some(
(header) => typeof header !== "string" && header.kind === "github_app",
),
),
);
if (usesGithubApp) {
try {
const github = await client.githubStatus({
projectId: binding.projectId,
});
const development = github.environments.find(
(entry) => entry.environment === "development",
);
if (development && development.state !== "connected") {
process.stdout.write(
`GitHub: not connected for development — githubApp() calls will fail
` +
` Run \`opencomputer github connect\` or open the dashboard Repositories tab
`,
);
}
} catch {
// Connection status is advisory; never block the dev loop on it.
}
}
if (spa) web = await startReactDevServer(projectRoot);
watcher = watch(
resolve(projectRoot, "opencomputer"),
Expand Down
2 changes: 2 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ Usage:
opencomputer env set <name> [--environment development|production] [--agent <agent>|current]
opencomputer env list [--environment development|production] [--agent <agent>|current]
opencomputer env remove <name> [--environment development|production] [--agent <agent>|current]
opencomputer github status [--project <id|slug>]
opencomputer github connect [--environment development|production] [--project <id|slug>]
opencomputer webhooks list [--environment development|production] [--agent <agent>|current]
opencomputer webhooks create <name> [--environment development|production] [--agent <agent>|current]
opencomputer webhooks enable <webhook-id> [--project <id|slug>]
Expand Down
75 changes: 75 additions & 0 deletions cli/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,81 @@ export default function Agent() {
}
});

test("the compiler records app-minted GitHub connections with literal permissions", async () => {
const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-github-app-"));
const root = resolve(parent, "app");
try {
const initialized = await initializeAgentProject(root);
await writeFile(
resolve(initialized.agentRoot, "agent.ts"),
`import { defineConnection, githubApp } from "@opencomputer/agent";

export const github = defineConnection({
id: "github",
origin: "https://api.github.com",
headers: {
Authorization: githubApp({
permissions: { contents: "read", pull_requests: "write" },
}),
},
});
export default function Agent() {
return "Use GitHub.";
}
`,
);
const built = await buildAgentArtifact(initialized.agentRoot);
await assert.doesNotReject(
import(
`${pathToFileURL(resolve(initialized.agentRoot, ".opencomputer", "runtime", "opencomputer-agent.js")).href}?test=${crypto.randomUUID()}`
),
);
assert.deepEqual(built.httpConnections, [
{
id: "github",
origin: "https://api.github.com",
headers: {
Authorization: {
kind: "github_app",
permissions: { contents: "read", pull_requests: "write" },
},
},
},
]);
} finally {
await rm(parent, { recursive: true, force: true });
}
});

test("the compiler rejects githubApp permissions that are not literal", async () => {
const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-github-bad-"));
const root = resolve(parent, "app");
try {
const initialized = await initializeAgentProject(root);
await writeFile(
resolve(initialized.agentRoot, "agent.ts"),
`import { defineConnection, githubApp } from "@opencomputer/agent";

const shared = { contents: "read" } as const;
export const github = defineConnection({
id: "github",
origin: "https://api.github.com",
headers: { Authorization: githubApp({ permissions: shared }) },
});
export default function Agent() {
return "Use GitHub.";
}
`,
);
await assert.rejects(
buildAgentArtifact(initialized.agentRoot),
/githubApp\(\) permissions must be an inline object literal/,
);
} finally {
await rm(parent, { recursive: true, force: true });
}
});

test("the compiler records managed MCP server definitions", async () => {
const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-mcp-"));
const root = resolve(parent, "app");
Expand Down
Loading
Loading