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
80 changes: 27 additions & 53 deletions apps/web/src/agent-source-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ import {
import { type } from "arktype";

import { fetchSourceFile } from "./git-fetch";
import { withGitToken } from "./git-token";

export class AgentSourceReadError extends Error {}

const GitTokenMintShape = type({ id: "string", secret: "string" });

const ToolPackagePinShape = type({ name: "string", version: "string" });

const AgentWorkflowJsonShape = type({
Expand All @@ -34,14 +33,6 @@ const AgentWorkflowJsonShape = type({

const READ_TOKEN_LIFETIME_MS = 10 * 60 * 1000;

async function readErrorBody(response: Response): Promise<string> {
const body: unknown = await response.json().catch(() => undefined);
const envelope = type({
error: { code: "string", userMessage: "string", refId: "string" },
})(body);
return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage;
}

/** An existing agent's deploy source, in the shape `deployAgentSource`'s
* `NewAgentInput` needs plus the sources it declares for inference. */
export type AgentSource = {
Expand All @@ -63,53 +54,36 @@ async function readAgentWorkflowStep(
assetName: string,
fetchImpl: typeof fetch,
): Promise<AgentWorkflowStep> {
const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`;
const minted = await fetchImpl(tokensPath, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: `agent-read-${crypto.randomUUID()}`,
resource: `asset:${assetId}`,
refPattern: "refs/heads/main",
actions: ["can_read"],
expiresAt: new Date(Date.now() + READ_TOKEN_LIFETIME_MS).toISOString(),
}),
});
if (!minted.ok) {
throw new AgentSourceReadError(`minting a read token failed: ${await readErrorBody(minted)}`);
}
const token = GitTokenMintShape(await minted.json());
if (token instanceof type.errors) {
throw new AgentSourceReadError(
`the read token came back an unexpected shape: ${token.summary}`,
);
}

const url = new URL(
`/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`,
globalThis.location.origin,
).toString();
try {
const definitionFile = await fetchSourceFile({
url,
token: token.secret,
filepath: WORKFLOW_SOURCE_DEFINITION_PATH,
});
const workflowJson = parseWorkflowSourceDefinition(definitionFile, assetId);
const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson));
if (parsed instanceof type.errors) {
throw new AgentSourceReadError(
`this agent's source came back an unexpected shape: ${parsed.summary}`,
);
}
const step = Object.values(parsed.steps)[0];
if (step === undefined) {
throw new AgentSourceReadError("this agent's source has no steps to read a prompt from");
}
return step;
} finally {
await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" });
}
return withGitToken({
tenantId,
assetId,
actions: ["can_read"],
lifetimeMs: READ_TOKEN_LIFETIME_MS,
fetchImpl,
use: async (token) => {
const definitionFile = await fetchSourceFile({
url,
token,
filepath: WORKFLOW_SOURCE_DEFINITION_PATH,
});
const workflowJson = parseWorkflowSourceDefinition(definitionFile, assetId);
const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson));
if (parsed instanceof type.errors) {
throw new AgentSourceReadError(
`this agent's source came back an unexpected shape: ${parsed.summary}`,
);
}
const step = Object.values(parsed.steps)[0];
if (step === undefined) {
throw new AgentSourceReadError("this agent's source has no steps to read a prompt from");
}
return step;
},
});
}

/** Mints a read-only token, fetches the asset's `main`, and parses out the
Expand Down
68 changes: 51 additions & 17 deletions apps/web/src/git-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// pkt-lines fine, so no hand-rolled wire code is needed here.
import LightningFS from "@isomorphic-git/lightning-fs";
import { Buffer } from "buffer";
import git from "isomorphic-git";
import git, { Errors } from "isomorphic-git";
import http from "isomorphic-git/http/web";

globalThis.Buffer ??= Buffer;
Expand All @@ -13,33 +13,67 @@ export class GitFetchError extends Error {}

const MAIN_REF = "refs/heads/main";

/** Fetches `main` and returns `filepath`'s contents as text. */
export async function fetchSourceFile(args: {
async function cloneAndFetchMain(args: {
url: string;
token: string;
filepath: string;
}): Promise<string> {
}): Promise<{ fs: InstanceType<typeof LightningFS>; dir: string }> {
const fs = new LightningFS(`workbench-fetch-${crypto.randomUUID()}`, { wipe: true });
const dir = "/repo";
await fs.promises.mkdir(dir);
await git.init({ fs, dir, defaultBranch: "main" });
await git.fetch({
fs,
http,
dir,
url: args.url,
ref: MAIN_REF,
// The hub's git server advertises no `shallow` capability, so a
// depth-limited fetch is rejected outright; fetch the full branch.
singleBranch: true,
tags: false,
headers: { Authorization: `Bearer ${args.token}` },
});
return { fs, dir };
}

/** Fetches `main` and returns `filepath`'s contents as text. */
export async function fetchSourceFile(args: {
url: string;
token: string;
filepath: string;
}): Promise<string> {
try {
await git.fetch({
fs,
http,
dir,
url: args.url,
ref: MAIN_REF,
// The hub's git server advertises no `shallow` capability, so a
// depth-limited fetch is rejected outright; fetch the full branch.
singleBranch: true,
tags: false,
headers: { Authorization: `Bearer ${args.token}` },
});
const { fs, dir } = await cloneAndFetchMain(args);
const oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" });
const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath });
return new TextDecoder().decode(blob);
} catch (cause) {
throw new GitFetchError(cause instanceof Error ? cause.message : String(cause));
}
}

/** Fetches `main` and returns `filepath`'s contents as text, or `""` when
* the branch has no commits yet or the file isn't in it — the two shapes
* a freshly created, still-empty asset takes. Any other failure (auth,
* network, a malformed repo) still throws. */
export async function fetchSourceFileOrEmpty(args: {
url: string;
token: string;
filepath: string;
}): Promise<string> {
try {
const { fs, dir } = await cloneAndFetchMain(args);
let oid: string;
try {
oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" });
} catch (cause) {
if (cause instanceof Errors.NotFoundError) return "";
throw cause;
}
const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath });
return new TextDecoder().decode(blob);
} catch (cause) {
if (cause instanceof Errors.NotFoundError) return "";
throw new GitFetchError(cause instanceof Error ? cause.message : String(cause));
}
}
55 changes: 55 additions & 0 deletions apps/web/src/git-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Short-lived git tokens scoped to one asset — the mint/use/revoke shape
// every reader and pusher of an asset's smart-HTTP git remote needs.
// Pulled out of `agent-source-read.ts` and `agent-deploy.ts`'s copies so a
// new caller (skill content) mints and revokes through the same helper
// rather than a third copy of this dance.
import { type } from "arktype";

export class GitTokenError extends Error {}

const GitTokenMintShape = type({ id: "string", secret: "string" });

async function readErrorBody(response: Response): Promise<string> {
const body: unknown = await response.json().catch(() => undefined);
const envelope = type({
error: { code: "string", userMessage: "string", refId: "string" },
})(body);
return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage;
}

/** Mints a token scoped to `assetId` on `refs/heads/main`, runs `use` with
* its secret, and revokes it afterward whether `use` succeeds or throws. */
export async function withGitToken<T>(args: {
tenantId: string;
assetId: string;
actions: readonly ("can_read" | "can_push")[];
lifetimeMs: number;
fetchImpl?: typeof fetch;
use: (token: string) => Promise<T>;
}): Promise<T> {
const fetchImpl = args.fetchImpl ?? fetch;
const tokensPath = `/api/tenants/${encodeURIComponent(args.tenantId)}/git-tokens`;
const minted = await fetchImpl(tokensPath, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: `git-token-${crypto.randomUUID()}`,
resource: `asset:${args.assetId}`,
refPattern: "refs/heads/main",
actions: args.actions,
expiresAt: new Date(Date.now() + args.lifetimeMs).toISOString(),
}),
});
if (!minted.ok) {
throw new GitTokenError(`minting a git token failed: ${await readErrorBody(minted)}`);
}
const token = GitTokenMintShape(await minted.json());
if (token instanceof type.errors) {
throw new GitTokenError(`the git token came back an unexpected shape: ${token.summary}`);
}
try {
return await args.use(token.secret);
} finally {
await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" });
}
}
Loading
Loading