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
1 change: 0 additions & 1 deletion apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
},
"dependencies": {
"@chat-adapter/state-memory": "^4.35.0",
"@corbits/access-tools": "workspace:*",
"@corbits/agent-directory": "workspace:*",
"@corbits/agent-lifecycle": "workspace:*",
"@corbits/approvals": "workspace:*",
Expand Down
46 changes: 9 additions & 37 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
createWorkflowRunDispatchStore,
listAssetsForTenant,
listVisibleOfferings,
resolveCredentialRequirement,
} from "@intx/db";
import {
asset as assetTable,
Expand Down Expand Up @@ -125,7 +124,6 @@ import {
createPostgresBenchModelPolicyStore,
createWorkflowCatalogRoutes,
} from "@corbits/inference-catalog";
import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes";
import { generateId } from "@intx/hub-common";

import {
Expand Down Expand Up @@ -275,6 +273,7 @@ import {
createInFlightRequestTracker,
withInFlightRequestTracking,
} from "./in-flight-requests";
import { withWorkflowRunTenantAuth } from "./workflow-run-tenant-auth";

// Host policy constants, not configuration.
const MAX_TARBALL_BYTES = 10 * 1024 * 1024;
Expand Down Expand Up @@ -677,18 +676,6 @@ export async function createHub(config: HubConfig) {
},
};
const hubPublicKey = hexEncode(signingKey.publicKey);
// Same owning check GET /connections uses — see
// `@corbits/connections`' `workflow-connection-routes.ts` and the
// `createWorkflowConnectionRoutes` wiring below. Not
// `listConnectedProviders` (catalog-only).
const isConnectorConnected = async (tenantId: string, connectorId: string) =>
(await resolveCredentialRequirement(
db,
tenantId,
{ providerName: connectorId, source: "tenant" },
null,
null,
)) !== null;
// One resolver serves both seams, exactly as @intx/hub-sessions's own
// reference host wires them: `resolve` turns a presented bearer token
// into a verified identity at the handshake, and `isCurrent`
Expand Down Expand Up @@ -2269,10 +2256,6 @@ export async function createHub(config: HubConfig) {
"/api/workflow-connections",
createWorkflowConnectionRoutes({
authenticator: createWorkflowRunAuthenticator({ db }),
registry: CONNECTOR_REGISTRY,
// Same `isConnectorConnected` the pinned-package factory is wired
// with above (CL-6492).
isConnectorConnected,
listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId),
}),
);
Expand All @@ -2296,24 +2279,6 @@ export async function createHub(config: HubConfig) {
getPolicy: (tenantId) => benchModelPolicy.store.getPolicy(tenantId),
}),
);
// Myra's own principal/grant surface (`@corbits/access-tools`'
// `list_principals`/`list_grants`/`grant_access`/`revoke_access`): the
// workflow-run-authenticated counterpart to the tenant-session
// `/api/tenants/:tenantId/principals` and `/grants` routes
// `@intx/hub-api` mounts above. Reuses the SAME `chatGrantStore`/
// `chatConditionRegistry` every other extension's own requireGrant
// check runs against, so a seeded principal's real grants (see
// the deleted seeding package's `SEED_GRANTS`) gate this surface exactly like
// every other write route.
app.route(
"/api/workflow-access",
createWorkflowAccessRoutes({
db,
authenticator: createWorkflowRunAuthenticator({ db }),
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
}),
);
// Notify-to-reconnect for an OAuth-connected credential whose token
// expired (Hugging Face today — see docs/onboarding-huggingface-connect.md):
// a light periodic sweep over `@corbits/notify`'s pure
Expand Down Expand Up @@ -2350,8 +2315,15 @@ export async function createHub(config: HubConfig) {
app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir)));

// Stock Interchange currently leaves tenant creation and dispatch ungated.
// Let a workflow-run agent reach every stock tenant route with the run
// bearer, not just the deploy endpoint — see ./workflow-run-tenant-auth.ts
// for why this is an outer wrap.
const runBearerApp = withWorkflowRunTenantAuth(app, {
db,
authenticator: createWorkflowRunAuthenticator({ db }),
});
const inFlight = createInFlightRequestTracker();
const servingApp = withInFlightRequestTracking(app, inFlight);
const servingApp = withInFlightRequestTracking(runBearerApp, inFlight);

return {
app: servingApp,
Expand Down
51 changes: 51 additions & 0 deletions apps/hub/src/workflow-run-tenant-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// A workflow child has no browser session — only its sidecar bearer token
// and its own run address. `@intx/hub-api` already knows how to turn that
// pair into a `principal` + `tenant` context (`createWorkflowRunDeployAuth`),
// but mounts it on the single `/api/tenants/:tenantId/workflows/deployments`
// path, so every other stock tenant route 401s a run-authenticated caller.
//
// This composition root widens that mount to the whole tenant subtree, so an
// agent tool package can read and write stock tenant routes (principals,
// grants, credentials, providers, offerings, workflow deployments) with the
// run bearer instead of a Workbench-specific `/api/workflow-*` mirror.
//
// It has to be an outer wrap rather than an `app.use()` on the built app:
// `createApp()` registers `createResolveTenant` on `/api/tenants/:tenantId/*`
// before it returns, and Hono composes middleware in registration order — the
// same reasoning `./tenant-create-guard.ts` and `./dispatch-tenant-guard.ts`
// give for their own wraps.
//
// The middleware is a no-op for a request that presents no bearer credential
// (it calls `next()` with nothing set), so session traffic, the git-token
// smart-HTTP mounts, and the existing deploy mirror all behave exactly as
// before. The `:tenantId` path segment is never trusted: the middleware sets
// the tenant from the authenticated run's own scope, so a run cannot widen
// its reach by naming another tenant in the URL.
import { Hono } from "hono";
import type { DB } from "@intx/db";
import {
createWorkflowRunDeployAuth,
type AppEnv,
type WorkflowRunAuthenticator,
} from "@intx/hub-api";

export type WorkflowRunTenantAuthDeps = {
db: DB["db"];
authenticator: WorkflowRunAuthenticator;
};

export function withWorkflowRunTenantAuth(
nativeApp: Hono<AppEnv>,
deps: WorkflowRunTenantAuthDeps,
): Hono<AppEnv> {
const wrapped = new Hono<AppEnv>();
wrapped.use(
"/api/tenants/:tenantId/*",
createWorkflowRunDeployAuth({
db: deps.db,
authenticator: deps.authenticator,
}),
);
wrapped.route("/", nativeApp);
return wrapped;
}
8 changes: 6 additions & 2 deletions apps/sidecar/src/workflow-deployment-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ async function makeDataDir(): Promise<string> {
}

const baseRecord: WorkflowDeploymentRecord = {
version: 1,
version: 2,
tenantId: "ten_test",
principalId: "prin_test",
agentAddress: "run_parked-test@example.com",
definitionId: "def_1",
sources: {},
Expand Down Expand Up @@ -98,7 +100,9 @@ describe("scanWorkflowDeploymentRecords reaps pre-cutover records", () => {
test("an unreadable old-format record (missing approvedWireHash/sourceRef) is reaped once, then boots quietly", async () => {
const dataDir = await makeDataDir();
const preCutover = {
version: 1,
version: 2,
tenantId: "ten_test",
principalId: "prin_test",
agentAddress: "run_old-format@example.com",
definitionId: "def_1",
sources: {},
Expand Down
10 changes: 9 additions & 1 deletion apps/sidecar/src/workflow-deployment-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ const RECORD_FILENAME = "deployment.json";
* blindly. Validated at read time (the boot scan) at the trust boundary.
*/
export const WorkflowDeploymentRecord = type({
version: "1",
version: "2",
agentAddress: "string > 0",
definitionId: "string > 0",
// The run's own tenant and principal, from the hub's signed deploy frame.
// Required since version 2: the child threads them into its substrate
// config so a step tool can address stock `/api/tenants/:tenantId/*` with
// the run bearer, and a restore that could not name them would stand up a
// child whose tools cannot reach the hub. A version-1 record fails this
// parse and is redeployed rather than restored.
tenantId: "string > 0",
principalId: "string > 0",
sources: {
"[string]": InferenceSource.array().atLeastLength(1),
},
Expand Down
19 changes: 18 additions & 1 deletion apps/sidecar/src/workflow-host-wiring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,15 @@ export function createSidecarDeployRouter(deps: {
sources: NonNullable<AgentDeployFrame["workflow"]>["sources"];
/** Correlates the child's inference events to the deploy's session. */
sessionId: string | undefined;
/**
* The run's own tenant and principal, from the hub's signed deploy frame
* (`HarnessConfig`). Threaded into the child's substrate config so a step
* tool can address stock `/api/tenants/:tenantId/*` with the run bearer.
* Persisted on the deployment record so a boot-time restore reseeds the
* same pair rather than losing it.
*/
tenantId: string;
principalId: string;
/**
* Hub public key recorded at the head for deploy-pack verification and
* inbound hub-frame verification. Required for a single-step
Expand Down Expand Up @@ -835,9 +844,11 @@ export function createSidecarDeployRouter(deps: {
// rather than assign `undefined` to it -- hence one conditional-spread
// per optional field, folded into this single literal.
return {
version: 1 as const,
version: 2 as const,
agentAddress: spec.agentAddress,
definitionId: spec.definition.id,
tenantId: spec.tenantId,
principalId: spec.principalId,
sources,
...(spec.sessionId !== undefined ? { sessionId: spec.sessionId } : {}),
...(spec.hubPublicKey !== undefined
Expand Down Expand Up @@ -928,6 +939,8 @@ export function createSidecarDeployRouter(deps: {
const substrateEnv: Record<string, string> = {
...multistepSubstrateEnv,
WORKFLOW_DEFINITION_ID: spec.definition.id,
WORKFLOW_TENANT_ID: spec.tenantId,
WORKFLOW_PRINCIPAL_ID: spec.principalId,
WORKFLOW_RUN_REPO_ID: deploymentId,
WORKFLOW_RUN_REF: "refs/heads/main",
CLOSURE_PACKAGE_DIR: spec.closurePackageDir,
Expand Down Expand Up @@ -1511,6 +1524,8 @@ export function createSidecarDeployRouter(deps: {
definition,
sources: projection.sources,
sessionId: frame.config.sessionId,
tenantId: frame.config.tenantId,
principalId: frame.config.principalId,
hubPublicKey:
definition.stepOrder.length === 1 ? frame.hubPublicKey : undefined,
approvedWireHash: projection.approvedWireHash,
Expand Down Expand Up @@ -1809,6 +1824,8 @@ export function createSidecarDeployRouter(deps: {
definition,
sources: record.sources,
sessionId: record.sessionId,
tenantId: record.tenantId,
principalId: record.principalId,
hubPublicKey: record.hubPublicKey,
approvedWireHash: record.approvedWireHash,
closurePackageDir: applied.packageDir,
Expand Down
8 changes: 8 additions & 0 deletions apps/sidecar/src/workflow-substrate-factory/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { InferenceSource } from "@intx/types/runtime";
export const SIDECAR_SUBSTRATE_CONFIG_KEYS = [
"SIDECAR_DATA_DIR",
"WORKFLOW_DEFINITION_ID",
"WORKFLOW_TENANT_ID",
"WORKFLOW_PRINCIPAL_ID",
"WORKFLOW_RUN_REPO_ID",
"WORKFLOW_RUN_REF",
"SIDECAR_SIGNING_PUBLIC_KEY",
Expand All @@ -46,6 +48,12 @@ export const SubstrateConfig = type({
// capabilities route a step tool calls. Identity only: the definition
// itself is evaluated from the closure, never read from a repo.
WORKFLOW_DEFINITION_ID: "string > 0",
// The run's own tenant and principal, taken from the hub's signed deploy
// frame (`HarnessConfig.tenantId` / `.principalId`). A step tool addresses
// stock `/api/tenants/:tenantId/*` with them and the run bearer; without
// them a tool package has no way to name the tenant it is running for.
WORKFLOW_TENANT_ID: "string > 0",
WORKFLOW_PRINCIPAL_ID: "string > 0",
WORKFLOW_RUN_REPO_ID: "string > 0",
WORKFLOW_RUN_REF: "string > 0",
SIDECAR_SIGNING_PUBLIC_KEY: "string > 0",
Expand Down
2 changes: 2 additions & 0 deletions apps/sidecar/src/workflow-substrate-factory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ export function createSidecarSubstrateFactory(
hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL),
sidecarToken: validated.SIDECAR_TOKEN,
definitionId: validated.WORKFLOW_DEFINITION_ID,
tenantId: validated.WORKFLOW_TENANT_ID,
principalId: validated.WORKFLOW_PRINCIPAL_ID,
};
const buildStepEnv = createSidecarStepBuildEnv(
durableConversation !== undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ function buildEnvDeps(dataDir: string) {
sidecarToken: "sc-token",
adapters: { resolve: () => undefined } as never,
definitionId: "wfd_capability_owner",
tenantId: "ten_test",
principalId: "prin_test",
};
}

Expand Down Expand Up @@ -103,6 +105,8 @@ test("a body-step env with no staged deploy tree still carries definitionId, so
const buildEnv = createSidecarStepBuildEnv({
...buildEnvDeps(dataDir),
definitionId: "wfd_parent_definition",
tenantId: "ten_test",
principalId: "prin_test",
});
const sourcesRef: SourcesSnapshotRef = {
current: {
Expand Down
19 changes: 19 additions & 0 deletions apps/sidecar/src/workflow-substrate-factory/step-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ export interface SidecarStepBuildEnvDeps {
* side by this field).
*/
definitionId: string;
/**
* The run's own tenant and principal, threaded from the hub's signed
* deploy frame (`HarnessConfig.tenantId` / `.principalId`) through the
* `WORKFLOW_TENANT_ID` / `WORKFLOW_PRINCIPAL_ID` substrate-config
* entries. Carried on the step env beyond `BaseEnv` exactly like
* `definitionId` above so a tool package can address stock
* `/api/tenants/:tenantId/*` with the run bearer — the tenant is a path
* segment on every stock tenant route, and a step tool has no other
* sanctioned way to learn it.
*/
tenantId: string;
principalId: string;
/**
* Adapter registry the step agent resolves inference adapters through.
* The child builds this eagerly at boot from the validated
Expand Down Expand Up @@ -372,6 +384,8 @@ export function createSidecarStepBuildEnv(
hubAccessUrl: string;
sidecarToken: string;
definitionId: string;
tenantId: string;
principalId: string;
} = {
// An `InferenceSource` names a `credentialId`, not an inline secret:
// the reactor fills the request's credential at send time through
Expand Down Expand Up @@ -447,6 +461,11 @@ export function createSidecarStepBuildEnv(
hubAccessUrl: deps.hubArtifactsUrl,
sidecarToken: deps.sidecarToken,
definitionId: deps.definitionId,
// The run's own tenant/principal, for the tool bundles that address
// stock `/api/tenants/:tenantId/*` routes with the run bearer
// (`@corbits/access-tools`, `@corbits/connections-tools`).
tenantId: deps.tenantId,
principalId: deps.principalId,
};
// Carry the materialized tool runtime to the tool-bearing
// `agentFactory` via the env's symbol-keyed slot. The step-invoker
Expand Down
8 changes: 6 additions & 2 deletions apps/sidecar/test/workflow-restore-defer-to-wake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ test("a single-step deployment's frozen dead source is never replayed at restore
});

const record: WorkflowDeploymentRecord = {
version: 1,
version: 2,
tenantId: "ten_test",
principalId: "prin_test",
agentAddress,
definitionId: "def_1",
// A single-step deployment's `sources` table has exactly one entry,
Expand Down Expand Up @@ -243,7 +245,9 @@ test("a multi-step deployment still restores eagerly from its frozen sources, un
});

const record: WorkflowDeploymentRecord = {
version: 1,
version: 2,
tenantId: "ten_test",
principalId: "prin_test",
agentAddress,
definitionId: "def_multi",
sources: {
Expand Down
Loading
Loading