From 3a119bfd702219afb0e3306b5a13b465877013c2 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 19:56:38 -0700 Subject: [PATCH 1/5] test(web): Myra deploy input pins a source-tree commit; tarball packer test moves beside it (CL-8417) --- apps/web/src/myra-deploy.test.ts | 27 ++++++--------------------- apps/web/src/tools/tarball.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/tools/tarball.test.ts diff --git a/apps/web/src/myra-deploy.test.ts b/apps/web/src/myra-deploy.test.ts index 8a3aca6d4c..fee1908ac2 100644 --- a/apps/web/src/myra-deploy.test.ts +++ b/apps/web/src/myra-deploy.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { buildMyraDeployInput, MyraDeployError, packTarball } from "./myra-deploy"; +import { buildMyraDeployInput, MyraDeployError } from "./myra-deploy"; import { MYRA_SOURCE_CONFIG } from "./myra-source"; describe("buildMyraDeployInput", () => { - test("maps a published asset and the operator's offering pick to a WorkflowDeployInput", () => { + test("maps the pushed commit and the operator's offering pick to a source-tree deploy", () => { const input = buildMyraDeployInput({ assetId: "ast_123", + commitSha: "abc123", sourceOfferingIds: ["off_1", "off_2"], defaultSourceOfferingId: "off_2", }); @@ -15,12 +16,11 @@ describe("buildMyraDeployInput", () => { source: { kind: "asset", assetId: "ast_123", - package: { format: "tarball" }, + package: { format: "source", commitSha: "abc123" }, }, entry: MYRA_SOURCE_CONFIG.entryPath, sourceOfferingIds: ["off_1", "off_2"], defaultSourceOfferingId: "off_2", - pin: `${MYRA_SOURCE_CONFIG.packageName}@${MYRA_SOURCE_CONFIG.packageVersion}`, }); }); @@ -28,6 +28,7 @@ describe("buildMyraDeployInput", () => { expect(() => buildMyraDeployInput({ assetId: "ast_123", + commitSha: "abc123", sourceOfferingIds: [], defaultSourceOfferingId: "off_1", }), @@ -38,26 +39,10 @@ describe("buildMyraDeployInput", () => { expect(() => buildMyraDeployInput({ assetId: "ast_123", + commitSha: "abc123", sourceOfferingIds: ["off_1"], defaultSourceOfferingId: "off_2", }), ).toThrow(MyraDeployError); }); }); - -describe("packTarball", () => { - test("writes USTAR headers whose checksum verifies and whose typeflag survives", async () => { - const tarball = await packTarball({ "package.json": "{}" }); - const tar = new Uint8Array( - await new Response( - new Blob([tarball as Uint8Array]).stream().pipeThrough(new DecompressionStream("gzip")), - ).arrayBuffer(), - ); - const header = tar.slice(0, 512); - let expected = 0; - for (let i = 0; i < 512; i++) expected += i >= 148 && i < 156 ? 0x20 : header[i]!; - const stored = parseInt(new TextDecoder().decode(header.slice(148, 154)), 8); - expect(stored).toBe(expected); - expect(String.fromCharCode(header[156]!)).toBe("0"); - }); -}); diff --git a/apps/web/src/tools/tarball.test.ts b/apps/web/src/tools/tarball.test.ts new file mode 100644 index 0000000000..b192c1ad3a --- /dev/null +++ b/apps/web/src/tools/tarball.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { packTarball } from "./tarball"; + +describe("packTarball", () => { + test("writes USTAR headers whose checksum verifies and whose typeflag survives", async () => { + const tarball = await packTarball({ "package.json": "{}" }); + const tar = new Uint8Array( + await new Response( + new Blob([tarball as Uint8Array]) + .stream() + .pipeThrough(new DecompressionStream("gzip")), + ).arrayBuffer(), + ); + const header = tar.slice(0, 512); + let expected = 0; + for (let i = 0; i < 512; i++) expected += i >= 148 && i < 156 ? 0x20 : header[i]!; + const stored = parseInt(new TextDecoder().decode(header.slice(148, 154)), 8); + expect(stored).toBe(expected); + expect(String.fromCharCode(header[156]!)).toBe("0"); + }); +}); From 427a0893b946602bd967992c9dd8eb8cae1c75e8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 19:56:38 -0700 Subject: [PATCH 2/5] feat(web): Myra deploys as a workflow-kind source asset pushed over git smart-HTTP (CL-8417) --- apps/web/package.json | 3 + apps/web/src/git-push.ts | 49 +++++++ apps/web/src/myra-deploy.ts | 194 ++++++++----------------- apps/web/src/myra-source.ts | 40 +---- apps/web/src/pages/onboarding-page.tsx | 2 +- apps/web/src/tools/registry-publish.ts | 13 +- apps/web/src/tools/tarball.ts | 68 +++++++++ bun.lock | 5 + 8 files changed, 201 insertions(+), 173 deletions(-) create mode 100644 apps/web/src/git-push.ts create mode 100644 apps/web/src/tools/tarball.ts diff --git a/apps/web/package.json b/apps/web/package.json index a8ca2c97e1..8c9dc0ff8d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,11 +25,14 @@ "@corbits/url-path": "workspace:*", "@corbits/workflows": "workspace:*", "@intx/types": "workspace:*", + "@isomorphic-git/lightning-fs": "4.7.0", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "catalog:", "arktype": "catalog:", + "buffer": "^6.0.3", + "isomorphic-git": "catalog:", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/web/src/git-push.ts b/apps/web/src/git-push.ts new file mode 100644 index 0000000000..3a4080c34e --- /dev/null +++ b/apps/web/src/git-push.ts @@ -0,0 +1,49 @@ +// Pushes a small file tree into a hub asset repo from the browser over +// the stock git smart-HTTP route, using an in-memory filesystem. +import LightningFS from "@isomorphic-git/lightning-fs"; +import { Buffer } from "buffer"; +import git from "isomorphic-git"; +import http from "isomorphic-git/http/web"; + +// isomorphic-git reads the Node `Buffer` global; browsers do not ship one. +globalThis.Buffer ??= Buffer; + +export class GitPushError extends Error {} + +const COMMIT_AUTHOR = { name: "Workbench", email: "workbench@corbits.dev" }; + +/** Commits `tree` as the sole commit on `main` and force-pushes it to + * `url` with the bearer token. Returns the commit sha. */ +export async function pushSourceTree(args: { + url: string; + token: string; + tree: Readonly>; + message: string; +}): Promise { + const fs = new LightningFS(`workbench-push-${crypto.randomUUID()}`, { wipe: true }); + const dir = "/repo"; + await fs.promises.mkdir(dir); + await git.init({ fs, dir, defaultBranch: "main" }); + for (const [filepath, contents] of Object.entries(args.tree)) { + if (filepath.includes("/")) { + throw new GitPushError(`pushSourceTree writes a flat tree; got ${JSON.stringify(filepath)}`); + } + await fs.promises.writeFile(`${dir}/${filepath}`, contents, "utf8"); + await git.add({ fs, dir, filepath }); + } + const sha = await git.commit({ fs, dir, message: args.message, author: COMMIT_AUTHOR }); + const result = await git.push({ + fs, + http, + dir, + url: args.url, + ref: "main", + remoteRef: "main", + force: true, + headers: { Authorization: `Bearer ${args.token}` }, + }); + if (!result.ok) { + throw new GitPushError(`git push was refused: ${result.error ?? "unknown reason"}`); + } + return sha; +} diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index 8c11d31dbb..0dc01254b5 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -1,16 +1,7 @@ // Builds and publishes Myra's deployable definition entirely over stock -// routes. See `myra-source.ts` for why this is the one -// deploy-source variant that installs today. -// -// Three steps, each a stock route: ensure the `package-registry` asset -// exists (`POST /api/tenants/:id/assets`, tolerating the 409 an -// already-provisioned tenant reports); pack the built assistant -// definition as an npm-shaped tarball and PUT it in -// (`PUT /api/tenants/:id/assets/:assetId/tarballs/:filename`); hand back -// the `WorkflowDeployInput` the caller passes to `convergeNeedsList`. -// The pure offering-ids → deploy-input mapping (`buildMyraDeployInput`) -// is split out so it can be unit-tested without a fetch or a real -// gzip/tar round-trip. +// routes: ensure the `workflow`-kind asset exists, mint a short-lived push +// token, push the rendered source tree into the asset's git repo, revoke +// the token, and hand back the `WorkflowDeployInput` pinned to that commit. import { ASSISTANT_SYSTEM_PROMPT } from "@corbits/myra/prompt"; import { ASSISTANT_TOOL_PACKAGE_PINS } from "@corbits/myra/tool-packages"; import { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "@corbits/myra/workflow-ids"; @@ -24,7 +15,9 @@ export class MyraDeployError extends Error {} const AssetCreatedShape = type({ id: "string" }); const AssetListShape = type({ id: "string", name: "string" }).array(); -const TarballPutShape = type({ commit: "string", integrity: "string" }); +const GitTokenMintShape = type({ id: "string", secret: "string" }); + +const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000; async function readErrorBody(response: Response): Promise { const body: unknown = await response.json().catch(() => undefined); @@ -34,9 +27,8 @@ async function readErrorBody(response: Response): Promise { return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; } -/** Idempotently ensures the `package-registry` asset Myra's tarball is - * published into, mirroring `the deleted onboarding package's `ensureWorkflowAsset` - * 409-then-list pattern. */ +/** Idempotently ensures the `workflow` asset Myra's source is pushed + * into: create it, or on 409 find the existing one by name. */ export async function ensureMyraSourceAsset( tenantId: string, fetchImpl: typeof fetch = fetch, @@ -83,90 +75,6 @@ export async function ensureMyraSourceAsset( return existing.id; } -// -- tar/gzip: a minimal, dependency-free USTAR + gzip writer for the -// exact two small text files `renderWorkflowSourceTree` emits. No new -// package: the browser's own `CompressionStream("gzip")` does the -// compression; only the tar container needs hand-rolling. -- - -const TAR_BLOCK_SIZE = 512; - -function tarChecksumPlaceholder(): string { - return " "; // 8 spaces, per the USTAR header spec -} - -function padOctal(value: number, width: number): string { - return value.toString(8).padStart(width - 1, "0") + "\0"; -} - -/** One USTAR header block plus its content, padded to a 512-byte - * boundary — everything `tar.Parser`'s auto-detected gzip+ustar read - * needs for a single regular-file entry. */ -function tarEntry(path: string, content: Uint8Array): Uint8Array { - if (path.length >= 100) { - throw new MyraDeployError( - `tar entry path ${JSON.stringify(path)} is too long for a USTAR header`, - ); - } - const header = new Uint8Array(TAR_BLOCK_SIZE); - const encoder = new TextEncoder(); - const writeField = (offset: number, value: string): void => { - header.set(encoder.encode(value), offset); - }; - writeField(0, path); - writeField(100, padOctal(0o644, 8)); - writeField(108, padOctal(0, 8)); - writeField(116, padOctal(0, 8)); - writeField(124, padOctal(content.length, 12)); - writeField(136, padOctal(0, 12)); - writeField(148, tarChecksumPlaceholder()); - writeField(156, "0"); // typeflag: regular file - writeField(257, "ustar\0"); - writeField(263, "00"); - - let checksum = 0; - for (const byte of header) checksum += byte; - // The 8-byte checksum field is 6 octal digits, NUL, space; anything - // longer spills into the typeflag byte and fails every reader's check. - writeField(148, checksum.toString(8).padStart(6, "0") + "\0 "); - - const paddedLength = Math.ceil(content.length / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; - const body = new Uint8Array(paddedLength); - body.set(content); - - const entry = new Uint8Array(header.length + body.length); - entry.set(header, 0); - entry.set(body, header.length); - return entry; -} - -/** Packs a `{ path: contents }` tree (as `renderWorkflowSourceTree` - * returns) into an npm-shaped tar archive rooted at `package/`, then - * gzips it. */ -export async function packTarball(tree: Readonly>): Promise { - const encoder = new TextEncoder(); - const entries: Uint8Array[] = []; - for (const [path, contents] of Object.entries(tree)) { - entries.push(tarEntry(`package/${path}`, encoder.encode(contents))); - } - const endOfArchive = new Uint8Array(TAR_BLOCK_SIZE * 2); - const totalLength = entries.reduce((sum, entry) => sum + entry.length, 0) + endOfArchive.length; - const tar = new Uint8Array(totalLength); - let offset = 0; - for (const entry of entries) { - tar.set(entry, offset); - offset += entry.length; - } - tar.set(endOfArchive, offset); - - const gzipStream = new Blob([tar]).stream().pipeThrough(new CompressionStream("gzip")); - return new Uint8Array(await new Response(gzipStream).arrayBuffer()); -} - -function tarballFilename(): string { - const safeName = MYRA_SOURCE_CONFIG.packageName.replace(/[^A-Za-z0-9_.@+-]/g, "-"); - return `${safeName}-${MYRA_SOURCE_CONFIG.packageVersion}.tgz`; -} - const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; /** @@ -219,48 +127,70 @@ export function buildMyraDefinitionJson(triggerAddress: string): unknown { }; } -/** Renders Myra's built assistant definition, packs it, and PUTs it into - * the given `package-registry` asset. Returns the published filename - * (`pin`'s `name@version` names the package inside it; the filename - * itself never leaves this module). */ -export async function publishMyraTarball( +/** Mints a push-only token scoped to `main`, runs `push` with it, and + * revokes the token afterwards whatever the push's outcome. */ +async function withPushToken( + tenantId: string, + assetId: string, + fetchImpl: typeof fetch, + push: (token: string) => Promise, +): Promise { + const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`; + const minted = await fetchImpl(tokensPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "myra-deploy", + resource: `asset:${assetId}`, + refPattern: "refs/heads/main", + actions: ["can_push"], + expiresAt: new Date(Date.now() + PUSH_TOKEN_LIFETIME_MS).toISOString(), + }), + }); + if (!minted.ok) { + throw new MyraDeployError(`minting a push token failed: ${await readErrorBody(minted)}`); + } + const token = GitTokenMintShape(await minted.json()); + if (token instanceof type.errors) { + throw new MyraDeployError(`the push token came back an unexpected shape: ${token.summary}`); + } + try { + return await push(token.secret); + } finally { + await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); + } +} + +/** Renders Myra's built definition as a source tree and pushes it to the + * asset's `main`. Returns the commit sha the deploy pins to. */ +export async function pushMyraSource( tenantId: string, assetId: string, tenantDomain: string, fetchImpl: typeof fetch = fetch, -): Promise { +): Promise { const tree = renderWorkflowSourceTree({ packageName: MYRA_SOURCE_CONFIG.packageName, workflowJson: JSON.stringify(buildMyraDefinitionJson(`assistant@${tenantDomain}`)), }); - const tarball = await packTarball(tree); - const filename = tarballFilename(); - const response = await fetchImpl( - `/api/tenants/${encodeURIComponent(tenantId)}/assets/${encodeURIComponent(assetId)}/tarballs/${encodeURIComponent(filename)}`, - { - method: "PUT", - headers: { "content-type": "application/octet-stream" }, - body: new Blob([tarball as Uint8Array]), - }, + const url = new URL( + `/api/tenants/${encodeURIComponent(tenantId)}/assets/${MYRA_SOURCE_CONFIG.assetKind}/${MYRA_SOURCE_CONFIG.assetName}.git`, + globalThis.location.origin, + ).toString(); + // Loaded on demand: the git client only runs during setup, so it stays + // out of the main bundle. + const { pushSourceTree } = await import("./git-push"); + return withPushToken(tenantId, assetId, fetchImpl, (token) => + pushSourceTree({ url, token, tree, message: "Publish Myra's definition" }), ); - if (!response.ok) { - throw new MyraDeployError(`publishing Myra's tarball failed: ${await readErrorBody(response)}`); - } - const parsed = TarballPutShape(await response.json()); - if (parsed instanceof type.errors) { - throw new MyraDeployError( - `Myra's tarball upload came back an unexpected shape: ${parsed.summary}`, - ); - } } /** The pure mapping this module exists to get right: the operator's - * offering pick plus the asset this tenant just published into, turned - * into the exact `WorkflowDeployInput` `convergeNeedsList` needs. Split - * out from the fetch/tar plumbing above so it is unit-testable on its - * own. */ + * offering pick plus the commit just pushed, turned into the exact + * `WorkflowDeployInput` `convergeNeedsList` needs. */ export function buildMyraDeployInput(args: { assetId: string; + commitSha: string; sourceOfferingIds: readonly string[]; defaultSourceOfferingId: string; }): WorkflowDeployInput { @@ -276,16 +206,15 @@ export function buildMyraDeployInput(args: { source: { kind: "asset", assetId: args.assetId, - package: { format: "tarball" }, + package: { format: "source", commitSha: args.commitSha }, }, entry: MYRA_SOURCE_CONFIG.entryPath, sourceOfferingIds: [...args.sourceOfferingIds], defaultSourceOfferingId: args.defaultSourceOfferingId, - pin: `${MYRA_SOURCE_CONFIG.packageName}@${MYRA_SOURCE_CONFIG.packageVersion}`, }; } -/** Orchestrates the three steps above and returns the `myraDeploy` input +/** Orchestrates the steps above and returns the `myraDeploy` input * ready to hand to `bootstrapClientSession`/`convergeNeedsList`. */ export async function deployMyraSource( args: { @@ -297,9 +226,10 @@ export async function deployMyraSource( fetchImpl: typeof fetch = fetch, ): Promise { const assetId = await ensureMyraSourceAsset(args.tenantId, fetchImpl); - await publishMyraTarball(args.tenantId, assetId, args.tenantDomain, fetchImpl); + const commitSha = await pushMyraSource(args.tenantId, assetId, args.tenantDomain, fetchImpl); return buildMyraDeployInput({ assetId, + commitSha, sourceOfferingIds: args.sourceOfferingIds, defaultSourceOfferingId: args.defaultSourceOfferingId, }); diff --git a/apps/web/src/myra-source.ts b/apps/web/src/myra-source.ts index 1d97458e81..d7fb7cbb10 100644 --- a/apps/web/src/myra-source.ts +++ b/apps/web/src/myra-source.ts @@ -1,49 +1,25 @@ -// Myra's deploy source, pinned to what this hub can actually install -// today. -// -// `@corbits/myra` (agents/myra) is `private: true` -// and unpublished, so `WorkflowDefinitionRegistrySource` (the `registry` -// variant) has no external npm registry to resolve it from yet — that is -// its job. The package also exports builder functions -// (`buildAssistantWorkflow`), not a default-exported `WorkflowDefinition`, -// so pointing a git `asset` "source" install at the real workspace -// package would fail the source-tree installer's shape checks too. -// -// The one variant that installs cleanly today, entirely over stock -// routes and with no hub or seed change, is the same shape -// `the deleted onboarding package's `seedTenant` and the e2e harness already push: -// the built definition rendered as the two-file source package -// `@corbits/workflows`' `renderWorkflowSourceTree` emits, published as a -// `tarball` inside a stock `package-registry` asset via `PUT -// /api/tenants/:id/assets/:assetId/tarballs/:filename` — a route that -// takes raw tarball bytes over plain `fetch`, no git protocol, no -// system git binary, so it is the only variant reachable from a browser -// SPA like `apps/web`. `myra-deploy.ts` drives it. +// Myra's deploy source: a `workflow`-kind asset holding the two-file +// codebase `renderWorkflowSourceTree` emits, pushed over the stock git +// smart-HTTP route and deployed as a source tree at that commit. That is +// the one variant the stock deploy route can anchor from a browser: it +// requires a `workflow`-kind asset, and a `workflow` asset only takes its +// code by git push. import { type } from "arktype"; export const MyraSourceConfig = type({ - assetKind: "'package-registry'", + assetKind: "'workflow'", assetName: "string > 0", displayName: "string > 0", packageName: "string > 0", - packageVersion: "string > 0", entryPath: "string > 0", }); export type MyraSourceConfig = typeof MyraSourceConfig.infer; const parsed = MyraSourceConfig({ - assetKind: "package-registry", - // Distinct from `SETUP_AGENT_ASSET_NAME` ("assistant", a `workflow`-kind - // asset `seedTenant` owns): this is a `package-registry`-kind asset, - // a different asset namespace, so it never collides with a seeded - // tenant's own asset. + assetKind: "workflow", assetName: "myra-deploy-source", displayName: "Myra", - // The rendered package's own name — never `@corbits/myra` - // itself, which names the real, un-deployable workspace package this - // module explicitly does not source from (see the module doc above). packageName: "@workbench-onboarding/myra", - packageVersion: "0.0.0", entryPath: "./workflow.js", }); if (parsed instanceof type.errors) { diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 00d407d786..19d2852b82 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -11,7 +11,7 @@ // tenant already resolves one (inherited, or a previous run of // this step), skip straight past; otherwise ask the operator to // connect exactly one provider credential (`ProviderConnectStep`); -// 3. publish Myra's tarball-sourced deploy input for that offering +// 3. push Myra's source tree and build its deploy input for that offering // (`deployMyraSource`) and hand it to `bootstrapClientSession` as // `myraDeploy`. // diff --git a/apps/web/src/tools/registry-publish.ts b/apps/web/src/tools/registry-publish.ts index 1c399d750e..4e5c09a285 100644 --- a/apps/web/src/tools/registry-publish.ts +++ b/apps/web/src/tools/registry-publish.ts @@ -1,15 +1,12 @@ // Publishes every `tools/*` package's raw TypeScript source as an // npm-shaped tarball into the tenant's `corbits-tools` package-registry -// asset — the same ensure-asset-then-PUT-tarball plumbing -// `myra-deploy.ts` already uses for Myra's own workflow source, and the -// same `packTarball` (Interchange's stock tool-package resolver installs -// and runs a tool package straight from its packed source, so no bundling -// step is needed here either). `tools/*` directories are read directly -// off the workspace at build time via `import.meta.glob`, so a new -// `tools/*` package is picked up without editing a directory list here. +// asset. Interchange's stock tool-package resolver installs and runs a +// tool package straight from its packed source, so no bundling step is +// needed. `tools/*` directories are read off the workspace at build time +// via `import.meta.glob`, so a new package is picked up automatically. import { type } from "arktype"; -import { packTarball } from "../myra-deploy"; +import { packTarball } from "./tarball"; export const CORBITS_TOOLS_REGISTRY = "corbits-tools"; diff --git a/apps/web/src/tools/tarball.ts b/apps/web/src/tools/tarball.ts new file mode 100644 index 0000000000..949982cb17 --- /dev/null +++ b/apps/web/src/tools/tarball.ts @@ -0,0 +1,68 @@ +// A minimal USTAR + gzip writer: the browser's own `CompressionStream` +// compresses, so only the tar container needs hand-rolling. + +const TAR_BLOCK_SIZE = 512; + +export class TarballError extends Error {} + +function padOctal(value: number, width: number): string { + return value.toString(8).padStart(width - 1, "0") + "\0"; +} + +function tarEntry(path: string, content: Uint8Array): Uint8Array { + if (path.length >= 100) { + throw new TarballError(`tar entry path ${JSON.stringify(path)} is too long for a USTAR header`); + } + const header = new Uint8Array(TAR_BLOCK_SIZE); + const encoder = new TextEncoder(); + const writeField = (offset: number, value: string): void => { + header.set(encoder.encode(value), offset); + }; + writeField(0, path); + writeField(100, padOctal(0o644, 8)); + writeField(108, padOctal(0, 8)); + writeField(116, padOctal(0, 8)); + writeField(124, padOctal(content.length, 12)); + writeField(136, padOctal(0, 12)); + writeField(148, " "); + writeField(156, "0"); + writeField(257, "ustar\0"); + writeField(263, "00"); + + let checksum = 0; + for (const byte of header) checksum += byte; + // The 8-byte checksum field is 6 octal digits, NUL, space; anything + // longer spills into the typeflag byte and fails every reader's check. + writeField(148, checksum.toString(8).padStart(6, "0") + "\0 "); + + const paddedLength = Math.ceil(content.length / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + const body = new Uint8Array(paddedLength); + body.set(content); + + const entry = new Uint8Array(header.length + body.length); + entry.set(header, 0); + entry.set(body, header.length); + return entry; +} + +/** Packs a `{ path: contents }` tree into an npm-shaped gzipped tar + * archive rooted at `package/`. */ +export async function packTarball(tree: Readonly>): Promise { + const encoder = new TextEncoder(); + const entries: Uint8Array[] = []; + for (const [path, contents] of Object.entries(tree)) { + entries.push(tarEntry(`package/${path}`, encoder.encode(contents))); + } + const endOfArchive = new Uint8Array(TAR_BLOCK_SIZE * 2); + const totalLength = entries.reduce((sum, entry) => sum + entry.length, 0) + endOfArchive.length; + const tar = new Uint8Array(totalLength); + let offset = 0; + for (const entry of entries) { + tar.set(entry, offset); + offset += entry.length; + } + tar.set(endOfArchive, offset); + + const gzipStream = new Blob([tar]).stream().pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(gzipStream).arrayBuffer()); +} diff --git a/bun.lock b/bun.lock index eb458684bc..da6bb23579 100644 --- a/bun.lock +++ b/bun.lock @@ -141,11 +141,14 @@ "@corbits/url-path": "workspace:*", "@corbits/workflows": "workspace:*", "@intx/types": "workspace:*", + "@isomorphic-git/lightning-fs": "4.7.0", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "catalog:", "arktype": "catalog:", + "buffer": "^6.0.3", + "isomorphic-git": "catalog:", "react": "^19.2.0", "react-dom": "^19.2.0", }, @@ -1985,6 +1988,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/inference-catalog/@corbits/ollama-adapter": ["@corbits/ollama-adapter@github:corbitsdev/corbits-ollama-adapter#0387eb1", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-ollama-adapter-0387eb1", "sha512-O1dl5lSwWzNGJibQLlpYDFKJZWpstgHX519bl8oe1ALL/KHszT/tBDlpsD9jN3hmQD3jV2L1jNwvOwLOBLtpyw=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], From 440916b6a5daa2446578ef9edbce3b2fc8a72ecc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 20:03:32 -0700 Subject: [PATCH 3/5] feat(web): speak receive-pack directly and chain onto the advertised head (CL-8417) --- apps/web/src/git-push.ts | 99 +++++++++++++++++++++++++++++++------ apps/web/src/myra-deploy.ts | 4 +- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/apps/web/src/git-push.ts b/apps/web/src/git-push.ts index 3a4080c34e..203b0ab43c 100644 --- a/apps/web/src/git-push.ts +++ b/apps/web/src/git-push.ts @@ -1,9 +1,10 @@ -// Pushes a small file tree into a hub asset repo from the browser over -// the stock git smart-HTTP route, using an in-memory filesystem. +// Pushes a small flat file tree into a hub asset repo from the browser. +// isomorphic-git builds the objects and the pack; the receive-pack wire +// exchange is spoken directly because the hub answers `report-status` +// as raw pkt-lines, which isomorphic-git's own push cannot read. import LightningFS from "@isomorphic-git/lightning-fs"; import { Buffer } from "buffer"; import git from "isomorphic-git"; -import http from "isomorphic-git/http/web"; // isomorphic-git reads the Node `Buffer` global; browsers do not ship one. globalThis.Buffer ??= Buffer; @@ -11,9 +12,48 @@ globalThis.Buffer ??= Buffer; export class GitPushError extends Error {} const COMMIT_AUTHOR = { name: "Workbench", email: "workbench@corbits.dev" }; +const MAIN_REF = "refs/heads/main"; +const ZERO_OID = "0".repeat(40); -/** Commits `tree` as the sole commit on `main` and force-pushes it to - * `url` with the bearer token. Returns the commit sha. */ +function pktLine(text: string): Uint8Array { + const payload = new TextEncoder().encode(text); + const header = (payload.length + 4).toString(16).padStart(4, "0"); + return new Uint8Array([...new TextEncoder().encode(header), ...payload]); +} + +function readPktLines(body: Uint8Array): string[] { + const decoder = new TextDecoder(); + const lines: string[] = []; + let offset = 0; + while (offset + 4 <= body.length) { + const length = parseInt(decoder.decode(body.subarray(offset, offset + 4)), 16); + offset += 4; + if (Number.isNaN(length)) throw new GitPushError("malformed pkt-line header from the hub"); + if (length === 0) continue; + lines.push(decoder.decode(body.subarray(offset, offset + length - 4))); + offset += length - 4; + } + return lines; +} + +async function advertisedMainSha(url: string, token: string): Promise { + const response = await fetch(`${url}/info/refs?service=git-receive-pack`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new GitPushError(`ref advertisement failed: HTTP ${response.status}`); + } + const lines = readPktLines(new Uint8Array(await response.arrayBuffer())); + for (const line of lines) { + const [sha, rest] = line.split(" ", 2); + const ref = rest?.split("\0")[0]?.trim(); + if (sha !== undefined && ref === MAIN_REF) return sha; + } + return ZERO_OID; +} + +/** Commits `tree` on top of the asset's current `main` and pushes it. + * Returns the new commit sha. */ export async function pushSourceTree(args: { url: string; token: string; @@ -31,19 +71,48 @@ export async function pushSourceTree(args: { await fs.promises.writeFile(`${dir}/${filepath}`, contents, "utf8"); await git.add({ fs, dir, filepath }); } - const sha = await git.commit({ fs, dir, message: args.message, author: COMMIT_AUTHOR }); - const result = await git.push({ + const oldSha = await advertisedMainSha(args.url, args.token); + const sha = await git.commit({ + fs, + dir, + message: args.message, + author: COMMIT_AUTHOR, + parent: oldSha === ZERO_OID ? [] : [oldSha], + }); + const { commit } = await git.readCommit({ fs, dir, oid: sha }); + const { tree } = await git.readTree({ fs, dir, oid: commit.tree }); + const { packfile } = await git.packObjects({ fs, - http, dir, - url: args.url, - ref: "main", - remoteRef: "main", - force: true, - headers: { Authorization: `Bearer ${args.token}` }, + oids: [sha, commit.tree, ...tree.map((entry) => entry.oid)], }); - if (!result.ok) { - throw new GitPushError(`git push was refused: ${result.error ?? "unknown reason"}`); + if (packfile === undefined) throw new GitPushError("packObjects returned no packfile"); + + const command = pktLine(`${oldSha} ${sha} ${MAIN_REF}\0report-status\n`); + const body = new Uint8Array(command.length + 4 + packfile.length); + body.set(command, 0); + body.set(new TextEncoder().encode("0000"), command.length); + body.set(packfile, command.length + 4); + const response = await fetch(`${args.url}/git-receive-pack`, { + method: "POST", + headers: { + Authorization: `Bearer ${args.token}`, + "content-type": "application/x-git-receive-pack-request", + }, + body, + }); + if (!response.ok) { + throw new GitPushError(`git push failed: HTTP ${response.status}`); + } + const report = readPktLines(new Uint8Array(await response.arrayBuffer())); + const refusal = report.find( + (line) => line.startsWith("ng ") || (line.startsWith("unpack ") && line.trim() !== "unpack ok"), + ); + if (refusal !== undefined) { + throw new GitPushError(`git push was refused: ${refusal.trim()}`); + } + if (!report.some((line) => line.trim() === `ok ${MAIN_REF}`)) { + throw new GitPushError(`git push reported no result for ${MAIN_REF}`); } return sha; } diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index 0dc01254b5..58cb064f6b 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -143,7 +143,9 @@ async function withPushToken( name: "myra-deploy", resource: `asset:${assetId}`, refPattern: "refs/heads/main", - actions: ["can_push"], + // The ref advertisement before a push is a read, so a push-only + // token is refused at info/refs. + actions: ["can_read", "can_push"], expiresAt: new Date(Date.now() + PUSH_TOKEN_LIFETIME_MS).toISOString(), }), }); From 0676865ab29c7596f4bb630123b1eb3a1afc9936 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 20:22:24 -0700 Subject: [PATCH 4/5] test(web): Myra's definition declares the offering chain's inference sources (CL-8423) --- apps/web/src/myra-deploy.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/myra-deploy.test.ts b/apps/web/src/myra-deploy.test.ts index fee1908ac2..35e7ff8d97 100644 --- a/apps/web/src/myra-deploy.test.ts +++ b/apps/web/src/myra-deploy.test.ts @@ -1,8 +1,22 @@ import { describe, expect, test } from "bun:test"; -import { buildMyraDeployInput, MyraDeployError } from "./myra-deploy"; +import { buildMyraDefinitionJson, buildMyraDeployInput, MyraDeployError } from "./myra-deploy"; import { MYRA_SOURCE_CONFIG } from "./myra-source"; +describe("buildMyraDefinitionJson", () => { + test("declares the offering chain's sources so the probe approves them", () => { + const definition = buildMyraDefinitionJson("assistant@ada.example", [ + { provider: "openai-compatible", model: "qwen2.5:7b" }, + { provider: "anthropic", model: "claude-sonnet-5" }, + ]) as { steps: Record }; + + expect(definition.steps["assistant"]?.agent.inference.sources).toEqual([ + { provider: "openai-compatible", model: "qwen2.5:7b" }, + { provider: "anthropic", model: "claude-sonnet-5" }, + ]); + }); +}); + describe("buildMyraDeployInput", () => { test("maps the pushed commit and the operator's offering pick to a source-tree deploy", () => { const input = buildMyraDeployInput({ From 59ae7fac6928cbb9c80f576b03fafb951b0b6b62 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 20:22:24 -0700 Subject: [PATCH 5/5] fix(web): Myra declares the offering chain's inference sources so the probe approves them (CL-8423) --- apps/web/src/myra-deploy.ts | 28 +++++++++++++------ .../src/onboarding/provider-connect-step.tsx | 22 +++++++++++++-- apps/web/src/pages/onboarding-page.tsx | 1 + 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index 58cb064f6b..8ff1ac94db 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -10,6 +10,7 @@ import { type } from "arktype"; import { MYRA_SOURCE_CONFIG } from "./myra-source"; import type { WorkflowDeployInput } from "./needs-list"; +import type { DeclaredSource } from "./onboarding/provider-connect-step"; export class MyraDeployError extends Error {} @@ -94,7 +95,10 @@ const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; * "unbounded"` (not the numeric default) gets `drainBehavior: "wait"`; a * bare `trigger` becomes a one-element `triggers` array. */ -export function buildMyraDefinitionJson(triggerAddress: string): unknown { +export function buildMyraDefinitionJson( + triggerAddress: string, + declaredSources: readonly DeclaredSource[], +): unknown { return { id: ASSISTANT_WORKFLOW_ID, triggers: [{ type: "mail", to: triggerAddress }], @@ -110,11 +114,9 @@ export function buildMyraDefinitionJson(triggerAddress: string): unknown { systemPrompt: ASSISTANT_SYSTEM_PROMPT, toolFactories: [], capabilities: [], - // Cosmetic label only (see `InferencePreference`'s own doc - // comment in `@intx/agent`): deploy-time inference actually - // resolves from the deploy's `sourceOfferingIds`, never from - // this field. - inference: { sources: [{ provider: "stock", model: "stock" }] }, + // The probe approves exactly these `(provider, model)` pairs, so + // they must name what the deploy's offering chain resolves to. + inference: { sources: declaredSources.map((source) => ({ ...source })) }, toolPackagePins: ASSISTANT_TOOL_PACKAGE_PINS, }, drainBehavior: "wait", @@ -169,11 +171,14 @@ export async function pushMyraSource( tenantId: string, assetId: string, tenantDomain: string, + declaredSources: readonly DeclaredSource[], fetchImpl: typeof fetch = fetch, ): Promise { const tree = renderWorkflowSourceTree({ packageName: MYRA_SOURCE_CONFIG.packageName, - workflowJson: JSON.stringify(buildMyraDefinitionJson(`assistant@${tenantDomain}`)), + workflowJson: JSON.stringify( + buildMyraDefinitionJson(`assistant@${tenantDomain}`, declaredSources), + ), }); const url = new URL( `/api/tenants/${encodeURIComponent(tenantId)}/assets/${MYRA_SOURCE_CONFIG.assetKind}/${MYRA_SOURCE_CONFIG.assetName}.git`, @@ -224,11 +229,18 @@ export async function deployMyraSource( tenantDomain: string; sourceOfferingIds: readonly string[]; defaultSourceOfferingId: string; + declaredSources: readonly DeclaredSource[]; }, fetchImpl: typeof fetch = fetch, ): Promise { const assetId = await ensureMyraSourceAsset(args.tenantId, fetchImpl); - const commitSha = await pushMyraSource(args.tenantId, assetId, args.tenantDomain, fetchImpl); + const commitSha = await pushMyraSource( + args.tenantId, + assetId, + args.tenantDomain, + args.declaredSources, + fetchImpl, + ); return buildMyraDeployInput({ assetId, commitSha, diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx index d7337684b6..f72a5bc6c2 100644 --- a/apps/web/src/onboarding/provider-connect-step.tsx +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -69,22 +69,38 @@ export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ // The credential row requires a secret; Ollama ignores whatever is sent. const LOCAL_PLACEHOLDER_KEY = "ollama"; +/** A `(plugin, canonicalName)` pair, the identity the deploy gate approves + * an inference source under. */ +export type DeclaredSource = { + readonly provider: ModelProviderPlugin; + readonly model: string; +}; + export type ExistingOffering = { readonly sourceOfferingIds: readonly string[]; readonly defaultSourceOfferingId: string; + /** Same order as `sourceOfferingIds`; Myra declares these so the probe + * approves exactly what the offering chain resolves to. */ + readonly declaredSources: readonly DeclaredSource[]; }; /** Every visible offering becomes a source; the lowest priority is the default. */ export async function resolveExistingOffering(tenantId: string): Promise { const models = await getResolvedCatalog(tenantId); const offerings = models - .flatMap((model) => model.offerings) + .flatMap((model) => + model.offerings.map((offering) => ({ ...offering, model: model.canonicalName })), + ) .sort((a, b) => a.priority - b.priority); const defaultSourceOfferingId = offerings[0]?.offeringId; if (defaultSourceOfferingId === undefined) return null; return { sourceOfferingIds: offerings.map((offering) => offering.offeringId), defaultSourceOfferingId, + declaredSources: offerings.map((offering) => ({ + provider: offering.plugin, + model: offering.model, + })), }; } @@ -125,8 +141,9 @@ export function ProviderConnectStep({ } setSubmitting(true); try { + const canonicalName = isLocal ? modelName.trim() : option.canonicalName; const created = await shadowOffering(tenantId, { - canonicalName: isLocal ? modelName.trim() : option.canonicalName, + canonicalName, modelDisplayName: isLocal ? modelName.trim() : option.modelDisplayName, providerName: option.label, plugin: option.plugin, @@ -137,6 +154,7 @@ export function ProviderConnectStep({ onConnected({ sourceOfferingIds: [created.id], defaultSourceOfferingId: created.id, + declaredSources: [{ provider: option.plugin, model: canonicalName }], }); } catch (cause) { const refId = reportError(cause, { diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 19d2852b82..e765871e54 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -162,6 +162,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { tenantDomain, sourceOfferingIds: offering.sourceOfferingIds, defaultSourceOfferingId: offering.defaultSourceOfferingId, + declaredSources: offering.declaredSources, }), ) .then(