diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 070ca56f..694a13ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,7 +142,8 @@ change follows, whether a person or a coding agent writes it. ## Releasing Maintainers bump `version` in the root, `apps/hub`, `apps/web`, -`packages/client` and `packages/schemas` package.json files, run +`packages/client`, `packages/schemas` and `packages/tools-hub` package.json +files, run `bun install`, and merge. Once the publish workflow for that commit on main has finished, `git tag -s vX.Y.Z && git push origin vX.Y.Z` retags its hub and sidecar images as `X.Y.Z` and publishes the GitHub release; a 0.x version or diff --git a/apps/hub/package.json b/apps/hub/package.json index ef66c1f7..6fa6c828 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -20,6 +20,7 @@ "@corbits-builder/designer": "workspace:*", "@corbits-builder/reviewer": "workspace:*", "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", "@corbits-builder/writer": "workspace:*", "@corbits/agent-token": "0.2.2", "@corbits/artifacts": "0.3.0", diff --git a/apps/hub/src/artifacts/design-tools.ts b/apps/hub/src/artifacts/design-tools.ts index 91af0408..95edce29 100644 --- a/apps/hub/src/artifacts/design-tools.ts +++ b/apps/hub/src/artifacts/design-tools.ts @@ -8,8 +8,10 @@ import { DesignBundle } from "@corbits-builder/schemas/artifact"; import { designCheckFor } from "../builds/design-check.ts"; import { withDesignTokens } from "./design-tokens.ts"; import { DESIGN_STYLES } from "./design-tweaks.ts"; -import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; -import { workflowHttpJson } from "../workflows/http-json.ts"; +import { + resolveWorkflowProjectScope, + workflowHttpJson, +} from "@corbits-builder/tools-hub"; import { failure, HubArtifactReceipt } from "./tools.ts"; interface DesignToolEnv extends BaseEnv { diff --git a/apps/hub/src/artifacts/tools.ts b/apps/hub/src/artifacts/tools.ts index 11579584..6bf8d704 100644 --- a/apps/hub/src/artifacts/tools.ts +++ b/apps/hub/src/artifacts/tools.ts @@ -15,11 +15,12 @@ import { } from "@corbits-builder/schemas/artifact"; import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; import { unpackDesign } from "./design-tools.ts"; +import { projectScopeFailure } from "../workflows/project-scope.ts"; import { - projectScopeFailure, resolveWorkflowProjectScope, -} from "../workflows/project-scope.ts"; -import { workflowHttpJson, WorkflowHttpError } from "../workflows/http-json.ts"; + workflowHttpJson, + WorkflowHttpError, +} from "@corbits-builder/tools-hub"; interface ArtifactToolEnv extends BaseEnv { capabilities: RuntimeCapabilities; diff --git a/apps/hub/src/builds/app-repository.ts b/apps/hub/src/builds/app-repository.ts index d6145afc..096c1705 100644 --- a/apps/hub/src/builds/app-repository.ts +++ b/apps/hub/src/builds/app-repository.ts @@ -32,7 +32,7 @@ import { mediatedHttpJson, WorkflowHttpError, workflowHttpJson, -} from "../workflows/http-json.ts"; +} from "@corbits-builder/tools-hub"; const AppPushAccess = type({ path: "string", diff --git a/apps/hub/src/builds/app-snapshot.ts b/apps/hub/src/builds/app-snapshot.ts index 6398fb0e..3c34924c 100644 --- a/apps/hub/src/builds/app-snapshot.ts +++ b/apps/hub/src/builds/app-snapshot.ts @@ -12,7 +12,10 @@ import { DesignDraft, MAX_APP_BYTES, } from "@corbits-builder/schemas/artifact"; -import { WorkflowHttpError, workflowHttpJson } from "../workflows/http-json.ts"; +import { + WorkflowHttpError, + workflowHttpJson, +} from "@corbits-builder/tools-hub"; import { INPUTS_DIRECTORY } from "../config/inputs.ts"; // Installs and builds are rebuilt by setup and check-app, and .builder and the inputs come from diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index fef7123c..86907265 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -11,8 +11,10 @@ import { type ToolRunner, } from "@intx/types/runtime"; import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; -import { WorkflowHttpError } from "../workflows/http-json.ts"; +import { + resolveWorkflowProjectScope, + WorkflowHttpError, +} from "@corbits-builder/tools-hub"; import { APP_STATUS_TOOL, createCodingTools } from "./coding-tools.ts"; import { createPosixBuildExecutor } from "./posix-sandbox.ts"; import { browserTools } from "./browser.ts"; diff --git a/apps/hub/src/projects/deploy-tools.ts b/apps/hub/src/projects/deploy-tools.ts index acd66ef8..5bfb12bf 100644 --- a/apps/hub/src/projects/deploy-tools.ts +++ b/apps/hub/src/projects/deploy-tools.ts @@ -1,7 +1,9 @@ +import { projectScopeFailure } from "../workflows/project-scope.ts"; import { - projectScopeFailure, resolveWorkflowProjectScope, -} from "../workflows/project-scope.ts"; + workflowHttpJson, + WorkflowHttpError, +} from "@corbits-builder/tools-hub"; import { defineTool, type BaseEnv, @@ -50,7 +52,6 @@ import { designLookIdeas, } from "../config/design.ts"; import { QUESTIONS_ASKED } from "../config/questions.ts"; -import { workflowHttpJson, WorkflowHttpError } from "../workflows/http-json.ts"; import { assignmentToWriterMessage } from "../workflows/writer-task.ts"; import { coderTaskMessage } from "@corbits-builder/coder/contract"; diff --git a/apps/hub/src/workflows/project-scope.ts b/apps/hub/src/workflows/project-scope.ts index 7ed53d54..121c38b1 100644 --- a/apps/hub/src/workflows/project-scope.ts +++ b/apps/hub/src/workflows/project-scope.ts @@ -1,47 +1,13 @@ -import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; -import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { - WorkflowProjectScope, - type WorkflowRole, -} from "@corbits-builder/schemas/installed-workflows"; import { getLogger } from "@intx/log"; +import { projectScopeCode } from "@corbits-builder/tools-hub"; import { requestErrorMetadata } from "../request-error.ts"; -import { WorkflowHttpError, workflowHttpJson } from "./http-json.ts"; const log = getLogger(["corbits-builder", "hub", "workflow-project-scope"]); -const SAFE_CODE = /^[a-z0-9_:]+$/; -// A deployed agent keeps the bundle it started with; a field a newer hub adds must not refuse its scope. -const HubProjectScope = WorkflowProjectScope.onDeepUndeclaredKey("ignore"); -// The model sees only the original code (never a refusal body or arbitrary message); the cause stays in the hub log. export function projectScopeFailure(callId: string, error: unknown) { log.error( "Workflow project scope or tool call failed", requestErrorMetadata(error), ); - const code = - error instanceof WorkflowHttpError && error.refusalCode - ? error.refusalCode - : error instanceof Error && SAFE_CODE.test(error.message) - ? error.message - : "workflow_project_scope_unavailable"; - return { callId, content: code, isError: true }; -} - -export async function resolveWorkflowProjectScope( - capabilities: RuntimeCapabilities, - role: WorkflowRole, - signal: AbortSignal, -) { - const scope = HubProjectScope.assert( - await workflowHttpJson({ - capabilities, - handle: HUB_CREDENTIAL_HANDLE, - path: "/api/workflow-project-scope", - maxResponseBytes: 16384, - signal, - }), - ); - if (scope.role !== role) throw new Error("workflow_project_role_refused"); - return scope; + return { callId, content: projectScopeCode(error), isError: true }; } diff --git a/bun.lock b/bun.lock index 0d77bca2..025b96c9 100644 --- a/bun.lock +++ b/bun.lock @@ -67,6 +67,7 @@ "@corbits-builder/designer": "workspace:*", "@corbits-builder/reviewer": "workspace:*", "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", "@corbits-builder/writer": "workspace:*", "@corbits/agent-token": "0.2.2", "@corbits/artifacts": "0.3.0", @@ -158,6 +159,14 @@ "arktype": "catalog:", }, }, + "packages/tools-hub": { + "name": "@corbits-builder/tools-hub", + "version": "0.1.0-rc.2", + "dependencies": { + "@corbits-builder/schemas": "workspace:*", + "@intx/types": "workspace:*", + }, + }, "vendor/interchange/apps/sidecar": { "name": "@intx/sidecar-app", "version": "0.5.0", @@ -732,6 +741,8 @@ "@corbits-builder/schemas": ["@corbits-builder/schemas@workspace:packages/schemas"], + "@corbits-builder/tools-hub": ["@corbits-builder/tools-hub@workspace:packages/tools-hub"], + "@corbits-builder/web": ["@corbits-builder/web@workspace:apps/web"], "@corbits-builder/writer": ["@corbits-builder/writer@workspace:workflows/writer"], diff --git a/docs/DEV.md b/docs/DEV.md index 44c7bc5f..48c43170 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -15,6 +15,7 @@ running with Docker Compose is in | `apps/web` | The UI: Vite and React over that client, with its own small router in `src/routes.ts`. | | `workflows/*` | One package per role: its agent, prompt and workflow definition; the hub supplies its tools. | | `packages/schemas` | Types and schemas shared by the hub, the client and the workflows. | +| `packages/tools-hub` | The helpers the hub's agent tools use to call the hub and resolve a run's project (`@corbits-builder/tools-hub`). | | `scripts/` | Dev and verification scripts: pin and vendor checks, the commit check, the Google sign-in emulator and React UI API generation. | | `deploy/` | The Dockerfile and Compose files ([SELF_HOST.md](https://github.com/corbitsdev/corbits-builder/blob/main/docs/SELF_HOST.md)). | diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fe5c84d7..6e677d18 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -126,6 +126,7 @@ Environment files, installs and build output never leave the workspace. | `workflows/designer` | `@corbits-builder/designer` | The designer, its starter, themes and skills. | | `workflows/coder` | `@corbits-builder/coder` | The coder, its app starter and `check-app`. | | `packages/schemas` | `@corbits-builder/schemas` | Types and schemas shared by the hub, client and workflows. | +| `packages/tools-hub` | `@corbits-builder/tools-hub` | The hub HTTP/JSON and project-scope helpers agent tools use. | | `vendor/interchange` | | Interchange at the pinned revision. | | `deploy/` | | The Dockerfile and Compose files. | | `scripts/` | | Dev and verification scripts. | diff --git a/packages/tools-hub/README.md b/packages/tools-hub/README.md new file mode 100644 index 00000000..698332e3 --- /dev/null +++ b/packages/tools-hub/README.md @@ -0,0 +1,30 @@ +# Tools hub + +`@corbits-builder/tools-hub`: the helpers agent tools use to reach the +hub from inside a run. `workflowHttpJson` calls a hub route through the run's +mediated hub credential and throws `WorkflowHttpError` on a refusal, +`resolveWorkflowProjectScope` reads the project a run belongs to, and +`projectScopeCode` turns a failure into the only code the model sees. + +## Where it fits + +Pure functions with no tool definitions and no runtime of their own. It +imports only [`packages/schemas`][schemas] and Interchange's types. The +[hub][hub]'s agent tools use it, and the workflow bundles carry it with +those tools. + +## Layout + +- `src/index.ts`: the package's exports. +- `src/http-json.ts`: `workflowHttpJson`, `mediatedHttpJson` and + `WorkflowHttpError`. +- `src/project-scope.ts`: `resolveWorkflowProjectScope` and + `projectScopeCode`. + +## Working on it + +Refusal bodies never reach the model; keep any new field to a public code. +`bun run typecheck` checks every importer and `bun run test` runs the tests. + +[hub]: https://github.com/corbitsdev/corbits-builder/tree/main/apps/hub +[schemas]: https://github.com/corbitsdev/corbits-builder/tree/main/packages/schemas diff --git a/packages/tools-hub/package.json b/packages/tools-hub/package.json new file mode 100644 index 00000000..25b12461 --- /dev/null +++ b/packages/tools-hub/package.json @@ -0,0 +1,15 @@ +{ + "name": "@corbits-builder/tools-hub", + "version": "0.1.0-rc.2", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@corbits-builder/schemas": "workspace:*", + "@intx/types": "workspace:*" + } +} diff --git a/apps/hub/src/workflows/http-json.ts b/packages/tools-hub/src/http-json.ts similarity index 100% rename from apps/hub/src/workflows/http-json.ts rename to packages/tools-hub/src/http-json.ts diff --git a/packages/tools-hub/src/index.ts b/packages/tools-hub/src/index.ts new file mode 100644 index 00000000..ce9147e6 --- /dev/null +++ b/packages/tools-hub/src/index.ts @@ -0,0 +1,9 @@ +export { + mediatedHttpJson, + WorkflowHttpError, + workflowHttpJson, +} from "./http-json.ts"; +export { + projectScopeCode, + resolveWorkflowProjectScope, +} from "./project-scope.ts"; diff --git a/packages/tools-hub/src/project-scope.test.ts b/packages/tools-hub/src/project-scope.test.ts new file mode 100644 index 00000000..47ed721b --- /dev/null +++ b/packages/tools-hub/src/project-scope.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { WorkflowHttpError } from "./http-json.ts"; +import { projectScopeCode } from "./project-scope.ts"; + +test("project scope failures expose only a refusal code, a safe code or the fallback", () => { + expect( + projectScopeCode( + new WorkflowHttpError(403, "unspecified", "project_access_refused"), + ), + ).toBe("project_access_refused"); + expect(projectScopeCode(new Error("workflow_project_role_refused"))).toBe( + "workflow_project_role_refused", + ); + expect(projectScopeCode(new Error("ENOENT: /secret/path"))).toBe( + "workflow_project_scope_unavailable", + ); +}); diff --git a/packages/tools-hub/src/project-scope.ts b/packages/tools-hub/src/project-scope.ts new file mode 100644 index 00000000..c3a4c198 --- /dev/null +++ b/packages/tools-hub/src/project-scope.ts @@ -0,0 +1,39 @@ +import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { + WorkflowProjectScope, + type WorkflowRole, +} from "@corbits-builder/schemas/installed-workflows"; +import { WorkflowHttpError, workflowHttpJson } from "./http-json.ts"; + +// A deployed agent keeps the bundle it started with; a field a newer hub adds must not refuse its scope. +const HubProjectScope = WorkflowProjectScope.onDeepUndeclaredKey("ignore"); + +export async function resolveWorkflowProjectScope( + capabilities: RuntimeCapabilities, + role: WorkflowRole, + signal: AbortSignal, +) { + const scope = HubProjectScope.assert( + await workflowHttpJson({ + capabilities, + handle: HUB_CREDENTIAL_HANDLE, + path: "/api/workflow-project-scope", + maxResponseBytes: 16384, + signal, + }), + ); + if (scope.role !== role) throw new Error("workflow_project_role_refused"); + return scope; +} + +const SAFE_CODE = /^[a-z0-9_:]+$/; + +// The model sees only the original code (never a refusal body or arbitrary message); the cause stays in the hub log. +export function projectScopeCode(error: unknown): string { + if (error instanceof WorkflowHttpError && error.refusalCode) + return error.refusalCode; + if (error instanceof Error && SAFE_CODE.test(error.message)) + return error.message; + return "workflow_project_scope_unavailable"; +}