From 30182c704ce51687a6fdf3e09197cdbf0611cf87 Mon Sep 17 00:00:00 2001 From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:32:40 -0700 Subject: [PATCH 01/16] feat(telemetry): propose connector telemetry events behind proposal markers (#13582) Adds connection.created / connection.updated / connection.invoked as proposed-telemetry wrappers (unregistered; the TelemetryClient cannot queue or send them) plus the server-side emitter wiring and tests. No telemetry is transmitted by this change. Proposal: #13578. Coordinated release plan and review package: PAP-24 (Step A.1). --- packages/shared/src/telemetry/client.ts | 12 +- packages/shared/src/telemetry/events.ts | 69 +++ packages/shared/src/telemetry/index.ts | 3 + .../proposed-connector-events.test.ts | 124 ++++ .../tool-access-connector-telemetry.test.ts | 493 +++++++++++++++ .../tool-gateway-connector-telemetry.test.ts | 571 ++++++++++++++++++ .../src/services/connector-telemetry.test.ts | 269 +++++++++ server/src/services/connector-telemetry.ts | 226 +++++++ server/src/services/tool-access.ts | 74 ++- server/src/services/tool-action-review.ts | 6 + server/src/services/tool-gateway.ts | 36 ++ 11 files changed, 1881 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/telemetry/proposed-connector-events.test.ts create mode 100644 server/src/__tests__/tool-access-connector-telemetry.test.ts create mode 100644 server/src/__tests__/tool-gateway-connector-telemetry.test.ts create mode 100644 server/src/services/connector-telemetry.test.ts create mode 100644 server/src/services/connector-telemetry.ts diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index 4a131d20294..03a10a5550b 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -116,11 +116,21 @@ export class TelemetryClient { * backend event schema. */ track(eventName: K, ...args: TrackArgs): void { - if (!Object.hasOwn(PAPERCLIP_EVENTS, eventName)) return; + if (!this.isRegisteredEventName(eventName)) return; const [dimensions] = args; this.enqueue(eventName, dimensions); } + /** + * Whether `track` would enqueue this first-party event name rather than drop + * it as unregistered. Lets a caller with an expensive payload (e.g. one that + * needs database reads) skip building dimensions for a proposed event the + * client would discard anyway. + */ + isRegisteredEventName(eventName: string): boolean { + return Object.hasOwn(PAPERCLIP_EVENTS, eventName); + } + /** * Tracks plugin telemetry bridge events whose names are built dynamically * from third-party plugin input. The backend accepts only explicitly diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 5b38b75d784..71f78bb2f3f 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -153,6 +153,75 @@ export function trackAgentTaskRun( }); } +export function trackConnectionCreated( + client: TelemetryClient, + dims: { + connector_key: RawDimension<"custom">; + transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">; + auth_kind: RawDimension<"oauth" | "api_key" | "none">; + setup_flow: RawDimension<"gallery" | "api" | "example">; + status: RawDimension<"draft" | "active" | "disabled" | "archived">; + enabled: boolean; + }, +): void { + client.track( + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure which catalog connectors installations create connections for + "connection.created", + dims, + ); +} + +export function trackConnectionUpdated( + client: TelemetryClient, + dims: { + connector_key: RawDimension<"custom">; + transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">; + auth_kind: RawDimension<"oauth" | "api_key" | "none">; + change_source: RawDimension< + | "api" + | "gallery" + | "oauth_callback" + | "credential_refresh" + | "archive" + | "example" + >; + previous_status: RawDimension<"draft" | "active" | "disabled" | "archived">; + status: RawDimension<"draft" | "active" | "disabled" | "archived">; + previous_enabled: boolean; + enabled: boolean; + }, +): void { + client.track( + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure connector lifecycle transitions (configured, paused, archived) after creation + "connection.updated", + dims, + ); +} + +export function trackConnectionInvoked( + client: TelemetryClient, + dims: { + connector_key: RawDimension<"custom">; + transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">; + status: RawDimension< + | "succeeded" + | "failed" + | "denied" + | "cancelled" + | "timed_out" + | "rate_limited" + >; + origin: RawDimension<"setup_test" | "agent" | "user" | "system" | "plugin">; + duration_seconds?: number; + }, +): void { + client.track( + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure whether connected connectors are successfully used and where invocations fail; records completed invocation attempts and their terminal status, never invocation starts + "connection.invoked", + dims, + ); +} + export function trackErrorHandlerCrash( client: TelemetryClient, dims: { errorCode: string }, diff --git a/packages/shared/src/telemetry/index.ts b/packages/shared/src/telemetry/index.ts index 7628cac5927..61f945ad834 100644 --- a/packages/shared/src/telemetry/index.ts +++ b/packages/shared/src/telemetry/index.ts @@ -18,6 +18,9 @@ export { trackErrorHandlerCrash, trackInteractionCreated, trackInteractionResolved, + trackConnectionCreated, + trackConnectionUpdated, + trackConnectionInvoked, } from "./events.js"; export type { TelemetryConfig, diff --git a/packages/shared/src/telemetry/proposed-connector-events.test.ts b/packages/shared/src/telemetry/proposed-connector-events.test.ts new file mode 100644 index 00000000000..a8a6826e9de --- /dev/null +++ b/packages/shared/src/telemetry/proposed-connector-events.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TelemetryClient } from "./client.js"; +import { resolveTelemetryConfig } from "./config.js"; +import { + trackConnectionCreated, + trackConnectionUpdated, + trackConnectionInvoked, +} from "./events.js"; +import type { TelemetryState } from "./types.js"; + +const TEST_STATE: TelemetryState = { + installId: "test-install", + salt: "test-salt", + createdAt: "2026-01-01T00:00:00Z", + firstSeenVersion: "0.0.0", +}; + +function makeClient(config?: { enabled?: boolean }) { + const stateFactory = vi.fn(() => TEST_STATE); + return { + client: new TelemetryClient( + { enabled: config?.enabled ?? true, endpoint: "http://localhost:9999/ingest" }, + stateFactory, + "0.0.0-test", + () => 0.5, + ), + stateFactory, + }; +} + +function trackAllProposedConnectorEvents(client: TelemetryClient) { + trackConnectionCreated(client, { + connector_key: "github", + transport: "mcp_remote", + auth_kind: "oauth", + setup_flow: "gallery", + status: "active", + enabled: true, + }); + trackConnectionUpdated(client, { + connector_key: "github", + transport: "mcp_remote", + auth_kind: "oauth", + change_source: "api", + previous_status: "draft", + status: "active", + previous_enabled: false, + enabled: true, + }); + trackConnectionInvoked(client, { + connector_key: "github", + transport: "mcp_remote", + status: "succeeded", + origin: "agent", + duration_seconds: 3, + }); +} + +describe("proposed connector events against the real TelemetryClient", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("cannot queue or send: state stays untouched and nothing hits the wire", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); + const { client, stateFactory } = makeClient(); + + trackAllProposedConnectorEvents(client); + await client.flush(); + + expect(stateFactory).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("differential control: a registered event through the same client does send", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); + const { client } = makeClient(); + + trackAllProposedConnectorEvents(client); + client.track("project.created", {}); + await client.flush(); + + expect(fetch).toHaveBeenCalledTimes(1); + const body = JSON.parse( + String((vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit).body), + ); + expect(body.events).toEqual([ + expect.objectContaining({ name: "project.created" }), + ]); + }); + + it("reports the three connector event names as unregistered", () => { + const { client } = makeClient(); + expect(client.isRegisteredEventName("connection.created")).toBe(false); + expect(client.isRegisteredEventName("connection.updated")).toBe(false); + expect(client.isRegisteredEventName("connection.invoked")).toBe(false); + expect(client.isRegisteredEventName("project.created")).toBe(true); + }); + + it("a disabled client drops even registered events", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); + const { client, stateFactory } = makeClient({ enabled: false }); + + client.track("project.created", {}); + await client.flush(); + + expect(stateFactory).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("environment suppression resolves telemetry off under CI and opt-out flags", () => { + vi.stubEnv("PAPERCLIP_TELEMETRY_DISABLED", "1"); + expect(resolveTelemetryConfig().enabled).toBe(false); + vi.unstubAllEnvs(); + + vi.stubEnv("DO_NOT_TRACK", "1"); + expect(resolveTelemetryConfig().enabled).toBe(false); + vi.unstubAllEnvs(); + + vi.stubEnv("GITHUB_ACTIONS", "true"); + expect(resolveTelemetryConfig().enabled).toBe(false); + }); +}); diff --git a/server/src/__tests__/tool-access-connector-telemetry.test.ts b/server/src/__tests__/tool-access-connector-telemetry.test.ts new file mode 100644 index 00000000000..fd41786598a --- /dev/null +++ b/server/src/__tests__/tool-access-connector-telemetry.test.ts @@ -0,0 +1,493 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + companies, + companyMemberships, + companySecretBindings, + companySecretVersions, + companySecrets, + connectionGrants, + createDb, + secretAccessEvents, + toolApplications, + toolCatalogEntries, + toolConnectionInstalls, + toolConnections, + toolProfileBindings, + toolProfileEntries, + toolProfiles, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const track = vi.fn(); +const isRegisteredEventName = vi.fn(() => true); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: () => ({ track, isRegisteredEventName }), +})); + +const { toolAccessService } = await import("../services/tool-access.js"); +const { secretService } = await import("../services/secrets.js"); +const { instanceSettingsService } = await import("../services/instance-settings.js"); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported + ? describe + : describe.skip; + +type Db = ReturnType; + +function createdEvents() { + return track.mock.calls.filter(([name]) => name === "connection.created"); +} + +function updatedEvents() { + return track.mock.calls.filter(([name]) => name === "connection.updated"); +} + +async function createCompany(db: Db) { + const company = await db + .insert(companies) + .values({ + name: `Lifecycle telemetry ${randomUUID()}`, + issuePrefix: `LT${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: actor.actorId, + status: "active", + membershipRole: "admin", + }); + return company; +} + +const actor = { + actorType: "user" as const, + actorId: "lifecycle-telemetry-user", + actorSource: "local_implicit" as const, +}; + +const mcpTool = (name: string) => ({ + name, + description: name, + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, +}); + +/** + * Minimal remote MCP server: answers initialize, the initialized notification, + * and tools/list. Mirrors the fixture the remote-connector lifecycle tests use + * so the direct Composio MCP path here is the real gallery path, not a stub. + */ +function remoteMcpFixture() { + const requests: { url: string; headers: Headers }[] = []; + const remoteHttpRequest = async (url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { id: number; method: string }; + requests.push({ url, headers: new Headers(init.headers) }); + if (body.method === "initialize") { + return Response.json( + { + id: body.id, + jsonrpc: "2.0", + result: { protocolVersion: "2025-06-18", capabilities: {} }, + }, + { headers: { "Mcp-Session-Id": "session" } }, + ); + } + if (body.method === "notifications/initialized") + return new Response(null, { status: 202 }); + return Response.json({ + id: body.id, + jsonrpc: "2.0", + result: { tools: [mcpTool("read"), mcpTool("ask")] }, + }); + }; + return { requests, remoteHttpRequest }; +} + +/** + * Retained legacy broker records (#13758): a `rest_api` parent keyed to the + * catalog slug and a `provider: composio` child. Master keeps such rows only + * for inspection and explicit removal; they cannot execute or reconnect. + */ +async function insertRetiredComposioRecords(db: Db, companyId: string) { + const [application] = await db + .insert(toolApplications) + .values({ companyId, name: "Composio", type: "rest_api", status: "active" }) + .returning(); + const [parent] = await db + .insert(toolConnections) + .values({ + companyId, + applicationId: application!.id, + name: "Composio (legacy broker)", + uid: `composio/${randomUUID()}`, + transport: "rest_api", + authKind: "api_key", + status: "active", + enabled: true, + config: { sourceTemplateKey: "composio" }, + transportConfig: { sourceTemplateKey: "composio" }, + }) + .returning(); + const [child] = await db + .insert(toolConnections) + .values({ + companyId, + applicationId: application!.id, + name: "GitHub (via Composio)", + uid: `composio/github/${randomUUID()}`, + transport: "mcp_remote", + authKind: "none", + status: "active", + enabled: true, + config: { + provider: "composio", + parentConnectionId: parent!.id, + toolkitSlug: "github", + connectedAccountId: "account-github", + }, + transportConfig: {}, + }) + .returning(); + return { parent: parent!, child: child! }; +} + +describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => { + let db!: Db; + let tempDb: Awaited> | null = + null; + let keyDir: string | null = null; + + beforeAll(async () => { + keyDir = await mkdtemp(join(tmpdir(), "lifecycle-telemetry-secrets-")); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key")); + tempDb = await startEmbeddedPostgresTestDatabase( + "paperclip-lifecycle-telemetry-", + ); + db = createDb(tempDb.connectionString); + await instanceSettingsService(db).updateExperimental({ + enableMcpAggregators: true, + }); + }, 30_000); + + afterEach(async () => { + track.mockClear(); + await db.delete(activityLog); + await db.delete(toolProfileEntries); + await db.delete(toolProfileBindings); + await db.delete(toolProfiles); + await db.delete(toolCatalogEntries); + await db.delete(connectionGrants); + await db.delete(toolConnectionInstalls); + await db.delete(toolConnections); + await db.delete(toolApplications); + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + vi.unstubAllEnvs(); + if (keyDir) await rm(keyDir, { recursive: true, force: true }); + }); + + function service(options: Parameters[1] = {}) { + return toolAccessService(db, { + remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], + ...options, + }); + } + + it("createConnection emits one created event with the catalog key from the committed row", async () => { + const company = await createCompany(db); + const svc = service(); + await svc.createConnection(company.id, { + name: "GitHub fixture", + transport: "mcp_remote", + config: { url: "https://fixture.example/mcp", sourceTemplateKey: "github" }, + enabled: true, + status: "active", + }); + const events = createdEvents(); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ + connector_key: "github", + transport: "mcp_remote", + setup_flow: "api", + status: "active", + enabled: true, + }); + }, 30_000); + + it("updateConnection emits committed transitions only; metadata saves stay silent", async () => { + const company = await createCompany(db); + const svc = service(); + const connection = await svc.createConnection(company.id, { + name: "Custom fixture", + transport: "mcp_remote", + config: { url: "https://fixture.example/mcp" }, + enabled: true, + status: "active", + }); + track.mockClear(); + + await svc.updateConnection(connection.id, { enabled: false }); + let events = updatedEvents(); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ + connector_key: "custom", + change_source: "api", + previous_enabled: true, + enabled: false, + previous_status: "active", + status: "active", + }); + + track.mockClear(); + await svc.updateConnection(connection.id, { name: "Renamed fixture" }); + expect(updatedEvents()).toHaveLength(0); + }, 30_000); + + it("direct Composio MCP setup from the gallery emits catalog identity only, never the session URL", async () => { + const company = await createCompany(db); + const remote = remoteMcpFixture(); + const svc = service({ + deploymentMode: "local_trusted", + deploymentExposure: "private", + remoteHttpRequest: remote.remoteHttpRequest, + }); + // The same server path the Apps gallery and inline task cards call + // (POST /companies/:companyId/tools/apps/connect): Composio Connect with an + // externally configured MCP session URL. + const connected = await svc.connectGalleryApp( + company.id, + { + galleryKey: "composio", + connectionMethodKey: "mcp", + link: "https://mcp.composio.dev/session/fixture?token=fixture-secret", + authMode: "none", + }, + actor, + ); + expect(remote.requests.length).toBeGreaterThan(0); + const created = createdEvents(); + expect(created).toHaveLength(1); + expect(created[0]?.[1]).toMatchObject({ + connector_key: "composio", + transport: "mcp_remote", + auth_kind: "none", + setup_flow: "gallery", + status: connected.connection.status, + enabled: connected.connection.enabled, + }); + expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-secret"); + expect(JSON.stringify(track.mock.calls)).not.toContain("mcp.composio.dev"); + + track.mockClear(); + const finished = await svc.finishGalleryAppConnection( + company.id, + connected.connectionId, + { + enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id), + askFirstCatalogEntryIds: [], + access: "all_agents", + }, + actor, + ); + // Connect commits a draft; the finish step is the committed draft -> active + // transition, so it is the one `gallery` update this setup reports. + expect(connected.connection).toMatchObject({ status: "draft", enabled: false }); + expect(finished.connection).toMatchObject({ status: "active", enabled: true }); + const updated = updatedEvents(); + expect(updated).toHaveLength(1); + expect(updated[0]?.[1]).toMatchObject({ + connector_key: "composio", + transport: "mcp_remote", + change_source: "gallery", + previous_status: "draft", + status: "active", + previous_enabled: false, + enabled: true, + }); + expect(createdEvents()).toHaveLength(0); + }, 30_000); + + it("pausing and resuming a direct Composio MCP connection reports api transitions with the catalog key", async () => { + const company = await createCompany(db); + const remote = remoteMcpFixture(); + const svc = service({ + deploymentMode: "local_trusted", + deploymentExposure: "private", + remoteHttpRequest: remote.remoteHttpRequest, + }); + const connected = await svc.connectGalleryApp( + company.id, + { + galleryKey: "composio", + connectionMethodKey: "mcp", + link: "https://mcp.composio.dev/session/fixture?token=fixture-secret", + authMode: "none", + }, + actor, + ); + await svc.finishGalleryAppConnection( + company.id, + connected.connectionId, + { + enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id), + askFirstCatalogEntryIds: [], + access: "all_agents", + }, + actor, + ); + track.mockClear(); + + await svc.updateConnection(connected.connectionId, { enabled: false }); + await svc.updateConnection(connected.connectionId, { enabled: true }); + const updated = updatedEvents(); + expect(updated).toHaveLength(2); + expect(updated.map(([, dims]) => dims)).toEqual([ + expect.objectContaining({ + connector_key: "composio", + transport: "mcp_remote", + change_source: "api", + previous_enabled: true, + enabled: false, + }), + expect.objectContaining({ + connector_key: "composio", + change_source: "api", + previous_enabled: false, + enabled: true, + }), + ]); + // No per-app child rows exist for direct MCP, so nothing else cascades. + expect(createdEvents()).toHaveLength(0); + }, 30_000); + + it("finalizing OAuth access reports the committed draft -> active transition as a gallery step", async () => { + // Direct MCP aggregator OAuth (Composio Connect via DCR) from the Apps + // gallery or an inline task card: the callback stores a personal grant, + // then finalizeOAuthAccess — POST .../apps/:id/finalize-oauth-access, also + // called by connection-intent completion — activates the draft row itself + // before handing off to the finish step. + const company = await createCompany(db); + const svc = service(); + const [application] = await db + .insert(toolApplications) + .values({ companyId: company.id, name: "Composio", type: "mcp_remote", status: "active" }) + .returning(); + const [connection] = await db + .insert(toolConnections) + .values({ + companyId: company.id, + applicationId: application!.id, + name: "Composio", + uid: `composio/${randomUUID()}`, + transport: "mcp_remote", + authKind: "oauth", + credentialPolicy: "per_user", + status: "draft", + enabled: false, + config: { + sourceTemplateKey: "composio", + connectionMethodKey: "mcp", + url: "https://connect.composio.dev/mcp", + }, + transportConfig: { url: "https://connect.composio.dev/mcp" }, + }) + .returning(); + const accessToken = await secretService(db).create(company.id, { + name: `OAuth access token ${randomUUID().slice(0, 8)}`, + key: `tool_app.${randomUUID()}.oauth_access_token`, + provider: "local_encrypted", + value: "fixture-access-token", + }); + await db.insert(connectionGrants).values({ + companyId: company.id, + connectionId: connection!.id, + kind: "user", + subjectUserId: actor.actorId, + status: "active", + credentialSecretRefs: [ + { + secretId: accessToken.id, + versionSelector: "latest", + configPath: "oauth.access_token", + required: true, + label: "OAuth access token", + }, + ], + }); + track.mockClear(); + + const finished = await svc.finalizeOAuthAccess( + company.id, + connection!.id, + { grantKind: "user" }, + actor, + ); + expect(finished.connection).toMatchObject({ status: "active", enabled: true }); + const updated = updatedEvents(); + // Exactly one transition: finalize's own write. The finish step it calls + // afterwards re-reads an already-active row and stays silent. + expect(updated).toHaveLength(1); + expect(updated[0]?.[1]).toMatchObject({ + connector_key: "composio", + transport: "mcp_remote", + auth_kind: "oauth", + change_source: "gallery", + previous_status: "draft", + status: "active", + previous_enabled: false, + enabled: true, + }); + expect(createdEvents()).toHaveLength(0); + expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-access-token"); + }, 30_000); + + it("retained legacy broker records emit only their explicit removal, distinguishable by transport", async () => { + const company = await createCompany(db); + const { parent, child } = await insertRetiredComposioRecords(db, company.id); + const svc = service(); + track.mockClear(); + + await svc.archiveConnection(parent.id); + await svc.archiveConnection(child.id); + const updated = updatedEvents(); + expect(updated).toHaveLength(2); + expect(updated[0]?.[1]).toMatchObject({ + // Legacy parent: catalog slug survives, but `rest_api` marks it as the + // retired broker rather than a direct MCP connection. + connector_key: "composio", + transport: "rest_api", + change_source: "archive", + status: "archived", + }); + expect(updated[1]?.[1]).toMatchObject({ + // Legacy child: no catalog key; the raw toolkit slug never leaves. + connector_key: "custom", + transport: "mcp_remote", + change_source: "archive", + status: "archived", + }); + expect(JSON.stringify(track.mock.calls)).not.toContain("github"); + expect(JSON.stringify(track.mock.calls)).not.toContain("account-github"); + expect(createdEvents()).toHaveLength(0); + }, 30_000); +}); diff --git a/server/src/__tests__/tool-gateway-connector-telemetry.test.ts b/server/src/__tests__/tool-gateway-connector-telemetry.test.ts new file mode 100644 index 00000000000..1ead5d320ef --- /dev/null +++ b/server/src/__tests__/tool-gateway-connector-telemetry.test.ts @@ -0,0 +1,571 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + companies, + companyMemberships, + connectionGrants, + createDb, + heartbeatRuns, + issues, + projects, + toolAccessAuditEvents, + toolActionRequests, + toolApplications, + toolCallEvents, + toolCatalogEntries, + toolConnections, + toolGatewayRateLimitCounters, + toolGatewaySessions, + toolInvocations, + toolPolicies, + toolProfileBindings, + toolProfiles, + toolRuntimeSlots, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +// Proposal wrappers drop unregistered names inside the real client, so the +// integration assertions use a client double that reports the proposed event +// as registered. `track` observes exactly what the emitter would hand the +// real client after schema adoption. +const track = vi.fn(); +const isRegisteredEventName = vi.fn(() => true); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: () => ({ track, isRegisteredEventName }), +})); + +// Deterministic reproduction of "post-execution bookkeeping failure": the +// gateway's success path awaits writeAudit -> logActivity after the succeeded +// save; failing that call lands in the catch that overwrites the row to +// failed. +const failLogActivityForAction = { current: null as string | null }; +vi.mock("../services/activity-log.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logActivity: vi.fn( + async (...args: Parameters) => { + const input = args[1] as { action?: string }; + if ( + failLogActivityForAction.current && + input?.action === failLogActivityForAction.current + ) { + throw new Error(`forced activity write failure: ${input.action}`); + } + return actual.logActivity(...args); + }, + ), + }; +}); + +const { createToolGatewayService } = await import( + "../services/tool-gateway.js" +); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported + ? describe + : describe.skip; + +type Db = ReturnType; + +const SIGNING_SECRET = "connector-telemetry-signing-secret"; + +function mcpSuccessResponse(init: RequestInit): Response { + const body = JSON.parse(String(init.body ?? "{}")); + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: body?.id ?? "fixture", + result: { content: [{ type: "text", text: "ok" }] }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +async function createCompany(db: Db) { + return db + .insert(companies) + .values({ + name: `Connector telemetry ${randomUUID()}`, + issuePrefix: `CT${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createAgent(db: Db, companyId: string) { + return db + .insert(agents) + .values({ + companyId, + name: `Agent ${randomUUID()}`, + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createIssueAndRun(db: Db, companyId: string, agentId: string) { + const project = await db + .insert(projects) + .values({ companyId, name: `Project ${randomUUID()}` }) + .returning() + .then((rows) => rows[0]!); + const issue = await db + .insert(issues) + .values({ + companyId, + projectId: project.id, + title: `Telemetry issue ${randomUUID()}`, + status: "in_progress", + assigneeAgentId: agentId, + }) + .returning() + .then((rows) => rows[0]!); + const run = await db + .insert(heartbeatRuns) + .values({ + companyId, + agentId, + invocationSource: "assignment", + status: "running", + contextSnapshot: { issueId: issue.id, projectId: project.id }, + }) + .returning() + .then((rows) => rows[0]!); + return { issue, run }; +} + +async function allowAllToolsForAgent(db: Db, companyId: string, agentId: string) { + const profile = await db + .insert(toolProfiles) + .values({ + companyId, + profileKey: `telemetry-all-${randomUUID()}`, + name: `Telemetry profile ${randomUUID()}`, + defaultAction: "allow", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(toolProfileBindings).values({ + companyId, + profileId: profile.id, + targetType: "agent", + targetId: agentId, + }); +} + +async function createRemoteMcpTool(db: Db, companyId: string) { + const [application] = await db + .insert(toolApplications) + .values({ + companyId, + applicationKey: `telemetry-app-${randomUUID().slice(0, 8)}`, + name: `Remote app ${randomUUID()}`, + type: "mcp_http", + status: "active", + }) + .returning(); + const config = { + url: "https://mcp.telemetry.test/mcp", + sourceTemplateKey: "github", + }; + const [connection] = await db + .insert(toolConnections) + .values({ + companyId, + applicationId: application!.id, + name: `Remote connection ${randomUUID()}`, + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "active", + enabled: true, + healthStatus: "ok", + config, + transportConfig: config, + credentialRefs: [], + credentialSecretRefs: [], + }) + .returning(); + await db.insert(connectionGrants).values({ + companyId, + connectionId: connection!.id, + kind: "organization", + credentialSecretRefs: [], + status: "active", + isDefault: true, + }); + const [catalogEntry] = await db + .insert(toolCatalogEntries) + .values({ + companyId, + applicationId: application!.id, + connectionId: connection!.id, + entryKind: "tool", + name: `kv_set-${randomUUID()}`, + toolName: "kv_set", + title: "KV Set", + description: "Set a value", + inputSchema: { + type: "object", + properties: { key: { type: "string" }, value: { type: "string" } }, + required: ["key", "value"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + riskLevel: "write", + isReadOnly: false, + isWrite: true, + isDestructive: false, + status: "active", + versionHash: randomUUID(), + }) + .returning(); + return { application: application!, connection: connection!, catalogEntry: catalogEntry! }; +} + +async function settledEvents(expectedCount: number) { + await vi.waitFor(() => { + expect( + track.mock.calls.filter( + ([name]) => name === "connection.invoked", + ).length, + ).toBeGreaterThanOrEqual(expectedCount); + }); + // The emitter runs as detached background work; give any surplus emission a + // chance to land before asserting the exact count. + await new Promise((resolve) => setTimeout(resolve, 150)); + return track.mock.calls.filter( + ([name]) => name === "connection.invoked", + ); +} + +describeEmbeddedPostgres("gateway connector invocation telemetry", () => { + let db!: Db; + let tempDb: Awaited> | null = + null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase( + "paperclip-connector-telemetry-", + ); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + track.mockClear(); + failLogActivityForAction.current = null; + await db.delete(activityLog); + await db.delete(toolCallEvents); + await db.delete(toolRuntimeSlots); + await db.delete(toolGatewaySessions); + await db.delete(toolGatewayRateLimitCounters); + await db.delete(toolActionRequests); + await db.delete(toolInvocations); + await db.delete(toolAccessAuditEvents); + await db.delete(toolPolicies); + await db.delete(toolProfileBindings); + await db.delete(toolProfiles); + await db.delete(toolCatalogEntries); + await db.delete(connectionGrants); + await db.delete(toolConnections); + await db.delete(toolApplications); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(projects); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function fixture(options: Parameters[1] = {}) { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const remoteTool = await createRemoteMcpTool(db, company.id); + await allowAllToolsForAgent(db, company.id, agent.id); + const gateway = createToolGatewayService(db, { + toolActionSigningSecret: SIGNING_SECRET, + remoteHttpRequest: async (_url, init) => mcpSuccessResponse(init), + ...options, + }); + const session = await gateway.createSession({ + companyId: company.id, + agentId: agent.id, + runId: run.id, + }); + const tool = (await gateway.listToolsForSession(session.token)).find( + (candidate) => candidate.connectionId === remoteTool.connection.id, + ); + expect(tool).toBeDefined(); + return { company, agent, run, remoteTool, gateway, session, tool: tool! }; + } + + it("emits exactly one succeeded event for a successful agent execution", async () => { + const f = await fixture(); + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "1" }, + }), + ).resolves.toMatchObject({ status: "completed" }); + + const events = await settledEvents(1); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ + connector_key: "github", + transport: "mcp_remote", + status: "succeeded", + origin: "agent", + }); + }, 30_000); + + it("emits a single failed event when post-success audit work throws (no contradictory pair)", async () => { + const f = await fixture(); + failLogActivityForAction.current = "tool_gateway.call_completed"; + + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "1" }, + }), + ).rejects.toThrow(/forced activity write failure/); + + const [invocation] = await db.select().from(toolInvocations); + expect(invocation?.status).toBe("failed"); + + const events = await settledEvents(1); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ status: "failed", origin: "agent" }); + expect( + events.some(([, dims]) => (dims as { status: string }).status === "succeeded"), + ).toBe(false); + }, 30_000); + + it("emits one failed event when the remote execution itself fails", async () => { + const f = await fixture({ + remoteHttpRequest: async () => + new Response("upstream exploded", { status: 500 }), + }); + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "1" }, + }), + ).rejects.toThrow(); + + const events = await settledEvents(1); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ status: "failed" }); + }, 30_000); + + it("emits one denied event for a policy block and one rate_limited event past the limit", async () => { + const f = await fixture(); + await db.insert(toolPolicies).values({ + companyId: f.company.id, + name: "Block telemetry tool", + policyType: "block", + selectors: { connectionId: f.remoteTool.connection.id }, + priority: 1, + }); + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "1" }, + }), + ).rejects.toMatchObject({ reasonCode: expect.any(String) }); + + let events = await settledEvents(1); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ status: "denied", origin: "agent" }); + + track.mockClear(); + await db.delete(toolPolicies); + await db.insert(toolPolicies).values({ + companyId: f.company.id, + name: "One call only", + policyType: "rate_limit", + selectors: { connectionId: f.remoteTool.connection.id }, + config: { limit: 1, windowSeconds: 60 }, + priority: 1, + }); + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "rate", value: "first" }, + }), + ).resolves.toMatchObject({ status: "completed" }); + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "rate", value: "second" }, + }), + ).rejects.toMatchObject({ status: 429 }); + + events = await settledEvents(2); + expect(events).toHaveLength(2); + expect(events.map(([, dims]) => (dims as { status: string }).status).sort()).toEqual( + ["rate_limited", "succeeded"], + ); + }, 30_000); + + it("approval flow: pending emits nothing, decline emits denied, approve emits succeeded", async () => { + const f = await fixture(); + await db.insert(toolPolicies).values({ + companyId: f.company.id, + name: "Review telemetry tool", + policyType: "require_approval", + selectors: { connectionId: f.remoteTool.connection.id }, + priority: 1, + }); + + await f.gateway + .executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "declined" }, + }) + .then( + () => { + throw new Error("expected approval_required"); + }, + (error) => expect(error).toMatchObject({ reasonCode: "approval_required" }), + ); + // Awaiting approval is in-flight: no completion event may exist yet. The + // approval card itself emits the registered interaction.created event, so + // filter to the proposed connector event. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect( + track.mock.calls.filter(([name]) => name === "connection.invoked"), + ).toHaveLength(0); + + const [pendingRequest] = await db.select().from(toolActionRequests); + await f.gateway.declineActionRequest({ + companyId: f.company.id, + actionRequestId: pendingRequest!.id, + actor: { userId: "board" }, + }); + let events = await settledEvents(1); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ status: "denied", origin: "agent" }); + + track.mockClear(); + await f.gateway + .executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "approved" }, + }) + .then( + () => { + throw new Error("expected approval_required"); + }, + (error) => expect(error).toMatchObject({ reasonCode: "approval_required" }), + ); + const approvable = await db + .select() + .from(toolActionRequests) + .where(eq(toolActionRequests.status, "pending")) + .then((rows) => rows[0]!); + await f.gateway.approveActionRequest({ + companyId: f.company.id, + actionRequestId: approvable.id, + actor: { userId: "board" }, + }); + events = await settledEvents(1); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ status: "succeeded", origin: "agent" }); + }, 40_000); + + it("labels connection-test executions as setup_test", async () => { + const f = await fixture(); + await db.insert(companyMemberships).values({ + companyId: f.company.id, + principalType: "user", + principalId: "board-user", + status: "active", + membershipRole: "member", + }); + const testCall = await f.gateway.executeTestCall({ + companyId: f.company.id, + connectionId: f.remoteTool.connection.id, + agentId: f.agent.id, + userId: "board-user", + toolName: f.tool.name, + parameters: { key: "t", value: "1" }, + }); + expect(testCall).toMatchObject({ decision: "allowed" }); + expect(testCall).not.toHaveProperty("error"); + + const events = await settledEvents(1); + expect(events).toHaveLength(1); + expect(events[0]?.[1]).toMatchObject({ + status: "succeeded", + origin: "setup_test", + }); + }, 30_000); + + it("an idempotent replay returns the stored result without a second event", async () => { + const f = await fixture(); + const idempotencyKey = `telemetry-replay-${randomUUID()}`; + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "1" }, + idempotencyKey, + }), + ).resolves.toMatchObject({ status: "completed" }); + await settledEvents(1); + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "1" }, + idempotencyKey, + }), + ).resolves.toMatchObject({ status: "replayed" }); + + const events = await settledEvents(1); + expect(events).toHaveLength(1); + }, 30_000); + + it("telemetry sink failures never fail the tool call", async () => { + const f = await fixture(); + track.mockImplementation(() => { + throw new Error("sink offline"); + }); + await expect( + f.gateway.executeTool({ + sessionToken: f.session.token, + tool: f.tool.name, + parameters: { key: "a", value: "1" }, + }), + ).resolves.toMatchObject({ status: "completed" }); + const [invocation] = await db.select().from(toolInvocations); + expect(invocation?.status).toBe("succeeded"); + track.mockReset(); + }, 30_000); +}); diff --git a/server/src/services/connector-telemetry.test.ts b/server/src/services/connector-telemetry.test.ts new file mode 100644 index 00000000000..2fef1f55cce --- /dev/null +++ b/server/src/services/connector-telemetry.test.ts @@ -0,0 +1,269 @@ +import { toolConnections, toolInvocations, type Db } from "@paperclipai/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const track = vi.fn(); +const isRegisteredEventName = vi.fn(() => true); +let telemetryClient: { + track: typeof track; + isRegisteredEventName: typeof isRegisteredEventName; +} | null = { track, isRegisteredEventName }; + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: () => telemetryClient, +})); + +const { + connectorKeyForConnection, + emitConnectionCreated, + emitConnectionUpdated, + emitConnectionInvoked, + invocationOrigin, +} = await import("./connector-telemetry.js"); + +type ToolConnectionRow = typeof toolConnections.$inferSelect; +type ToolInvocationRow = typeof toolInvocations.$inferSelect; + +function connectionRow(overrides: Record = {}): ToolConnectionRow { + return { + id: "conn-1", + connectionPurpose: "tool", + transport: "mcp_remote", + authKind: "oauth", + status: "active", + enabled: true, + config: { sourceTemplateKey: "github" }, + ...overrides, + } as unknown as ToolConnectionRow; +} + +function invocationRow(overrides: Record = {}): ToolInvocationRow { + return { + id: "inv-1", + connectionId: "conn-1", + status: "succeeded", + actorType: "agent", + runId: "run-1", + issueId: "issue-1", + gatewayId: "gw-1", + startedAt: new Date("2026-09-17T00:00:00.000Z"), + completedAt: new Date("2026-09-17T00:00:07.400Z"), + ...overrides, + } as unknown as ToolInvocationRow; +} + +function fakeDb( + invocation: ToolInvocationRow | null, + connection: ToolConnectionRow | null, +): Db & { selectCalls: () => number } { + let selectCalls = 0; + return { + selectCalls: () => selectCalls, + select: () => { + selectCalls += 1; + return { + from: () => ({ + innerJoin: () => ({ + where: () => + Promise.resolve( + // Mirror the emitter's single joined read: an invocation with + // no (or a deleted) connection joins to zero rows. + invocation && connection && invocation.connectionId + ? [{ invocation, connection }] + : [], + ), + }), + }), + }; + }, + } as unknown as Db & { selectCalls: () => number }; +} + +beforeEach(() => { + track.mockReset(); + isRegisteredEventName.mockReset(); + isRegisteredEventName.mockReturnValue(true); + telemetryClient = { track, isRegisteredEventName }; +}); + +describe("connectorKeyForConnection", () => { + it("keeps a sourceTemplateKey that resolves in the shared catalog", () => { + expect(connectorKeyForConnection(connectionRow())).toBe("github"); + }); + + it("reports custom for keys outside the catalog", () => { + expect( + connectorKeyForConnection(connectionRow({ config: { sourceTemplateKey: "my-internal-server" } })), + ).toBe("custom"); + }); + + it("reports custom when the key is missing or not a string", () => { + expect(connectorKeyForConnection(connectionRow({ config: {} }))).toBe("custom"); + expect( + connectorKeyForConnection(connectionRow({ config: { sourceTemplateKey: 42 } })), + ).toBe("custom"); + }); +}); + +describe("invocationOrigin", () => { + it("labels user-driven connection tests without run context as setup_test", () => { + expect( + invocationOrigin( + invocationRow({ actorType: "user", runId: null, issueId: null, gatewayId: null }), + ), + ).toBe("setup_test"); + }); + + it("keeps the actor type for run-attached invocations", () => { + expect(invocationOrigin(invocationRow())).toBe("agent"); + expect(invocationOrigin(invocationRow({ actorType: "user" }))).toBe("user"); + }); +}); + +describe("emitConnectionCreated", () => { + it("emits catalog identity and lifecycle state for tool connections", () => { + emitConnectionCreated(connectionRow(), "gallery"); + expect(track).toHaveBeenCalledWith("connection.created", { + connector_key: "github", + transport: "mcp_remote", + auth_kind: "oauth", + setup_flow: "gallery", + status: "active", + enabled: true, + }); + }); + + it("never emits for channel or AI connections", () => { + emitConnectionCreated(connectionRow({ connectionPurpose: "channel" }), "api"); + expect(track).not.toHaveBeenCalled(); + }); + + it("is a no-op without an initialized telemetry client", () => { + telemetryClient = null; + expect(() => emitConnectionCreated(connectionRow(), "gallery")).not.toThrow(); + }); + + it("swallows client failures instead of failing the setup path", () => { + track.mockImplementation(() => { + throw new Error("sink offline"); + }); + expect(() => emitConnectionCreated(connectionRow(), "gallery")).not.toThrow(); + }); +}); + +describe("emitConnectionUpdated", () => { + it("stays silent when the committed write left status and enabled unchanged", () => { + emitConnectionUpdated( + connectionRow(), + { status: "active", enabled: true }, + "api", + ); + expect(track).not.toHaveBeenCalled(); + }); + + it("emits the transition when lifecycle status changes", () => { + emitConnectionUpdated( + connectionRow(), + { status: "draft", enabled: true }, + "oauth_callback", + ); + expect(track).toHaveBeenCalledWith("connection.updated", { + connector_key: "github", + transport: "mcp_remote", + auth_kind: "oauth", + change_source: "oauth_callback", + previous_status: "draft", + status: "active", + previous_enabled: true, + enabled: true, + }); + }); + + it("emits when only the enabled flag flips", () => { + emitConnectionUpdated( + connectionRow({ enabled: false }), + { status: "active", enabled: true }, + "api", + ); + expect(track).toHaveBeenCalledTimes(1); + expect(track.mock.calls[0]?.[1]).toMatchObject({ + previous_enabled: true, + enabled: false, + }); + }); + + it("never emits for non-tool connections even on lifecycle changes", () => { + emitConnectionUpdated( + connectionRow({ connectionPurpose: "channel" }), + { status: "draft", enabled: true }, + "api", + ); + expect(track).not.toHaveBeenCalled(); + }); +}); + +describe("emitConnectionInvoked", () => { + it("emits terminal outcome, origin, and duration from the committed row", async () => { + await emitConnectionInvoked(fakeDb(invocationRow(), connectionRow()), "inv-1"); + expect(track).toHaveBeenCalledWith("connection.invoked", { + connector_key: "github", + transport: "mcp_remote", + status: "succeeded", + origin: "agent", + duration_seconds: 7, + }); + }); + + it("omits duration when the invocation never recorded a start time", async () => { + await emitConnectionInvoked( + fakeDb(invocationRow({ startedAt: null, status: "denied" }), connectionRow()), + "inv-1", + ); + expect(track.mock.calls[0]?.[1]).not.toHaveProperty("duration_seconds"); + expect(track.mock.calls[0]?.[1]).toMatchObject({ status: "denied" }); + }); + + it("ignores in-flight statuses so approval waits never count as failures", async () => { + await emitConnectionInvoked( + fakeDb(invocationRow({ status: "awaiting_approval" }), connectionRow()), + "inv-1", + ); + expect(track).not.toHaveBeenCalled(); + }); + + it("ignores invocations without a connection or with a non-tool connection", async () => { + await emitConnectionInvoked( + fakeDb(invocationRow({ connectionId: null }), connectionRow()), + "inv-1", + ); + await emitConnectionInvoked( + fakeDb(invocationRow(), connectionRow({ connectionPurpose: "channel" })), + "inv-1", + ); + expect(track).not.toHaveBeenCalled(); + }); + + it("resolves without emitting when the invocation row is missing", async () => { + await expect( + emitConnectionInvoked(fakeDb(null, null), "inv-missing"), + ).resolves.toBeUndefined(); + expect(track).not.toHaveBeenCalled(); + }); + + it("reads nothing from the database while the event name is unregistered", async () => { + isRegisteredEventName.mockReturnValue(false); + const db = fakeDb(invocationRow(), connectionRow()); + await emitConnectionInvoked(db, "inv-1"); + expect(db.selectCalls()).toBe(0); + expect(track).not.toHaveBeenCalled(); + expect(isRegisteredEventName).toHaveBeenCalledWith( + "connection.invoked", + ); + }); + + it("loads the invocation and connection with a single read once registered", async () => { + const db = fakeDb(invocationRow(), connectionRow()); + await emitConnectionInvoked(db, "inv-1"); + expect(db.selectCalls()).toBe(1); + expect(track).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/services/connector-telemetry.ts b/server/src/services/connector-telemetry.ts new file mode 100644 index 00000000000..66305e42bd7 --- /dev/null +++ b/server/src/services/connector-telemetry.ts @@ -0,0 +1,226 @@ +import { eq } from "drizzle-orm"; +import { toolConnections, toolInvocations, type Db } from "@paperclipai/db"; +import { getConnectableAppDefinition } from "@paperclipai/shared"; +import { + trackConnectionCreated, + trackConnectionUpdated, + trackConnectionInvoked, +} from "@paperclipai/shared/telemetry"; +import { logger } from "../middleware/logger.js"; +import { getTelemetryClient } from "../telemetry.js"; + +type ToolConnectionRow = typeof toolConnections.$inferSelect; +type ToolInvocationRow = typeof toolInvocations.$inferSelect; + +export type ConnectorSetupFlow = "gallery" | "api" | "example"; +export type ConnectorChangeSource = + | "api" + | "gallery" + | "oauth_callback" + | "credential_refresh" + | "archive" + | "example"; + +/** + * Terminal `tool_invocations.status` values. `pending`, `authorized`, + * `awaiting_approval`, and `executing` are in-flight and never emit: pending + * approval is not a failure. `cancelled` is declared in the schema enum but no + * current code path writes it to an invocation (signing-path cancellations + * land on the action request and fail the invocation); it stays here so a + * future writer is counted without a telemetry change. + */ +const TERMINAL_INVOCATION_STATUSES: ReadonlySet = new Set([ + "succeeded", + "failed", + "denied", + "cancelled", + "timed_out", + "rate_limited", +]); + +/** + * Connector identity for telemetry is the reviewed first-party catalog slug + * only: `config.sourceTemplateKey` validated against the shared app-definitions + * catalog. Anything else — custom MCP servers, user-named connections, keys + * that no longer resolve to a catalog entry — reports the literal `custom` so + * no user- or provider-controlled string leaves the process. + */ +export function connectorKeyForConnection( + connection: Pick, +): string { + const raw = connection.config?.sourceTemplateKey; + const key = typeof raw === "string" ? raw : null; + return key && getConnectableAppDefinition(key) ? key : "custom"; +} + +/** + * Setup-test invocations (Apps → Test tab) are separated from agent usage on + * durable invocation columns, mirroring the gateway's own test-origin + * predicate, so the split survives reloads and approval-driven completion. + */ +export function invocationOrigin( + invocation: Pick< + ToolInvocationRow, + "actorType" | "runId" | "issueId" | "gatewayId" | "connectionId" + >, +): string { + const isTestOrigin = + invocation.actorType === "user" && + invocation.runId === null && + invocation.issueId === null && + invocation.gatewayId === null && + invocation.connectionId !== null; + return isTestOrigin ? "setup_test" : invocation.actorType; +} + +function isToolPurpose(connection: Pick): boolean { + return connection.connectionPurpose === "tool"; +} + +/** + * Emits one proposed `connection.created` event for a tool-purpose + * connection row that a caller already committed. Channel and AI connections + * never emit. This function never throws; a telemetry failure must never fail + * a connection setup path. + */ +export function emitConnectionCreated( + connection: ToolConnectionRow, + setupFlow: ConnectorSetupFlow, +): void { + try { + const client = getTelemetryClient(); + if (!client) return; + if (!isToolPurpose(connection)) return; + trackConnectionCreated(client, { + connector_key: connectorKeyForConnection(connection), + transport: connection.transport, + auth_kind: connection.authKind, + setup_flow: setupFlow, + status: connection.status, + enabled: connection.enabled, + }); + } catch (err) { + logger.warn( + { err, connectionId: connection.id }, + "failed to emit connection.created telemetry", + ); + } +} + +/** + * Emits one proposed `connection.updated` event when a committed + * write changed a tool-purpose connection's persisted lifecycle state + * (`status` or `enabled`). Metadata-only saves, health polls, credential + * rotation, and catalog refreshes do not change either field and therefore + * never emit. This function never throws. + */ +export function emitConnectionUpdated( + connection: ToolConnectionRow, + previous: Pick, + changeSource: ConnectorChangeSource, +): void { + try { + const client = getTelemetryClient(); + if (!client) return; + if (!isToolPurpose(connection)) return; + if ( + previous.status === connection.status && + previous.enabled === connection.enabled + ) { + return; + } + trackConnectionUpdated(client, { + connector_key: connectorKeyForConnection(connection), + transport: connection.transport, + auth_kind: connection.authKind, + change_source: changeSource, + previous_status: previous.status, + status: connection.status, + previous_enabled: previous.enabled, + enabled: connection.enabled, + }); + } catch (err) { + logger.warn( + { err, connectionId: connection.id }, + "failed to emit connection.updated telemetry", + ); + } +} + +/** + * Emits one proposed `connection.invoked` event for an invocation + * a caller already wrote to a terminal status. Despite the name, this is a + * completion event: it records finished invocation attempts and their + * terminal status, never invocation starts. Never await it: like + * `agent.task_run`, this is best-effort background work and must not delay + * the caller's own response or lifecycle writes. + * + * Call placement inside the gateway is deliberate and asymmetric. Failure + * paths call it right after their terminal save. Success paths call it only + * after the post-save bookkeeping (action-request settlement, tool-call + * event, audit) has completed, because a bookkeeping failure lands in a catch + * that overwrites the row to `failed` and emits there — emitting the success + * beforehand would let one execution report both outcomes. + * + * Self-guards, so callers do not have to re-check anything: non-terminal + * statuses, invocations without a tool-purpose connection, and an + * unregistered (still-proposed) event name all return without emitting. The + * registration gate keeps the completion path free of telemetry reads until + * schema adoption: the runtime client drops unregistered names in `track`, + * so there is no point loading rows for an event that cannot be queued. + * + * Delivery is best-effort per execution, not exactly-once. Known residual + * duplications, accepted for the proposal stage: an outer safety-net catch + * can re-save `failed` and emit again when a failure path's own bookkeeping + * throws after its emit (test-invocation approval flow), and a crash-side + * replay that re-writes a terminal status emits again. Deduplication state + * is out of scope for this proposal. + */ +export async function emitConnectionInvoked( + db: Db, + invocationId: string, +): Promise { + try { + const client = getTelemetryClient(); + if (!client) return; + if (!client.isRegisteredEventName("connection.invoked")) return; + + const joined = await db + .select({ invocation: toolInvocations, connection: toolConnections }) + .from(toolInvocations) + .innerJoin( + toolConnections, + eq(toolInvocations.connectionId, toolConnections.id), + ) + .where(eq(toolInvocations.id, invocationId)) + .then((rows) => rows[0] ?? null); + if (!joined) return; + const { invocation, connection } = joined; + if (!TERMINAL_INVOCATION_STATUSES.has(invocation.status)) return; + if (!isToolPurpose(connection)) return; + + const startedAtMs = invocation.startedAt + ? new Date(invocation.startedAt).getTime() + : null; + const completedAtMs = invocation.completedAt + ? new Date(invocation.completedAt).getTime() + : null; + const durationSeconds = + startedAtMs !== null && completedAtMs !== null + ? Math.max(0, Math.round((completedAtMs - startedAtMs) / 1000)) + : undefined; + + trackConnectionInvoked(client, { + connector_key: connectorKeyForConnection(connection), + transport: connection.transport, + status: invocation.status, + origin: invocationOrigin(invocation), + ...(durationSeconds === undefined ? {} : { duration_seconds: durationSeconds }), + }); + } catch (err) { + logger.warn( + { err, invocationId }, + "failed to emit connection.invoked telemetry", + ); + } +} diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index a8fc6224be5..e4dc96b87b8 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2,6 +2,10 @@ import { isRemoteMcpConnectorMethod, connectionPurposeTransportSchema } from "@p import { instanceSettingsService } from "./instance-settings.js"; import { githubBotRequest } from "./chat-github-client.js"; import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js"; +import { + emitConnectionCreated, + emitConnectionUpdated, +} from "./connector-telemetry.js"; import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js"; import { captureRunIdentity } from "./run-identity.js"; import { createHash, randomBytes, randomUUID } from "node:crypto"; @@ -6415,6 +6419,7 @@ export function toolAccessService( return { connection: updatedConnection, applicationArchived }; }); + emitConnectionUpdated(archived.connection, connection, "archive"); // Only now, with every access path closed, revoke the credentials. Each // `secrets.remove` marks the row deleted before it calls the provider, so a @@ -8017,6 +8022,7 @@ export function toolAccessService( await ensureDefaultOrganizationGrant(updated); await syncCredentialBindings(updated); await ensureRuntimeSlot(updated); + emitConnectionUpdated(updated, existing, "example"); return { row: updated, created: false }; } const connectionId = randomUUID(); @@ -8045,6 +8051,7 @@ export function toolAccessService( await ensureDefaultOrganizationGrant(created); await syncCredentialBindings(created); await ensureRuntimeSlot(created); + emitConnectionCreated(created, "example"); return { row: created, created: true }; } @@ -10387,7 +10394,10 @@ export function toolAccessService( ), ) .returning(); - if (updated) await syncCredentialBindings(updated); + if (updated) { + await syncCredentialBindings(updated); + emitConnectionUpdated(updated, connection, "credential_refresh"); + } return updated ?? null; } @@ -11275,6 +11285,11 @@ export function toolAccessService( ) .returning(); await syncCredentialBindings(reauthorizationRequired); + emitConnectionUpdated( + reauthorizationRequired, + latestConnection, + "credential_refresh", + ); } else { const activeGrantRefs = await db .select({ @@ -12724,6 +12739,15 @@ export function toolAccessService( }) .returning(); } + if (revivedConnectionPrevious) { + emitConnectionUpdated( + connectionRow, + revivedConnectionPrevious, + "gallery", + ); + } else { + emitConnectionCreated(connectionRow, "gallery"); + } if (personalIdentityUserId) { // "Just me" (PAP-17835 seam #4). The credential is committed straight to // the caller's own grant; the connection row keeps only the header @@ -13749,6 +13773,11 @@ export function toolAccessService( return { profileId, profileBindings, policies, updatedConnection }; }); + emitConnectionUpdated( + transactionResult.updatedConnection, + connection, + "gallery", + ); const details = await profileDetails( transactionResult.profileId, @@ -14859,6 +14888,10 @@ export function toolAccessService( stateRow.connectionId, stateRow.companyId, ); + const preCloudCallbackLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; // The connection lifecycle, not the incidental presence of its app profile, // distinguishes setup from reauthorization. New connections and connections // revived after removal are drafts until this callback completes. A profile @@ -15220,6 +15253,10 @@ export function toolAccessService( .where(eq(issueThreadInteractions.id, stateRow.interactionId)); } }); + // The transaction above committed the connection's lifecycle write; a + // Paperclip Cloud connector callback is the managed variant of an OAuth + // callback completion. + emitConnectionUpdated(connection, preCloudCallbackLifecycle, "oauth_callback"); if (githubMetadata) { const [githubGrant] = await db .select({ id: connectionGrants.id }) @@ -15452,6 +15489,10 @@ export function toolAccessService( : null, }); } + const preActivationLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; [connection] = await db .update(toolConnections) .set({ @@ -15468,6 +15509,11 @@ export function toolAccessService( ), ) .returning(); + emitConnectionUpdated( + connection, + preActivationLifecycle, + "oauth_callback", + ); await db .update(toolApplications) .set({ status: "active", updatedAt: now() }) @@ -15566,6 +15612,10 @@ export function toolAccessService( // Reauthorization refreshes credentials and catalog without rebuilding the // operator's action profile, policy rules, or access bindings. const shouldFinalizeDefaults = connection.status === "draft"; + const preCallbackLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey @@ -15841,6 +15891,11 @@ export function toolAccessService( tx, ); }); + emitConnectionUpdated( + connection, + preCallbackLifecycle, + "oauth_callback", + ); // Personal OAuth used to return immediately after saving the grant. That // left the connection draft/paused and its catalog empty, so the person @@ -16059,6 +16114,11 @@ export function toolAccessService( await ensureDefaultOrganizationGrant(connection, tx); await syncCredentialBindings(connection, [], tx); }); + emitConnectionUpdated( + connection, + preCallbackLifecycle, + "oauth_callback", + ); await checkConnectionHealth(connection.id, input.actor); const refresh = await refreshCatalog(connection.id, input.actor, { @@ -16115,6 +16175,10 @@ export function toolAccessService( if (requestingAgentId) await assertAgentsInCompany(companyId, [requestingAgentId]); let connection = await getConnectionRow(connectionId, companyId); + const preFinalizeLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; if (connection.authKind !== "oauth") throw badRequest("This connection does not use browser sign-in"); if (connection.status === "archived") @@ -16424,6 +16488,12 @@ export function toolAccessService( for (const secretId of personalSecretIds) await secrets.remove(secretId); } + // Both identity branches above commit their own lifecycle write (draft -> + // active) before `finishGalleryAppConnection` re-reads the row, so that + // step alone would never observe this transition. This is the OAuth + // access-finalization step of the same gallery setup flow (Apps and inline + // task cards both reach it), hence `gallery`. + emitConnectionUpdated(connection, preFinalizeLifecycle, "gallery"); const catalog = await db .select() .from(toolCatalogEntries) @@ -17349,6 +17419,7 @@ export function toolAccessService( await ensureDefaultOrganizationGrant(row); await syncCredentialBindings(row); await ensureRuntimeSlot(row); + emitConnectionCreated(row, "api"); return toConnection(row); }, @@ -18239,6 +18310,7 @@ export function toolAccessService( .returning(); await syncCredentialBindings(row); await ensureRuntimeSlot(row); + emitConnectionUpdated(row, existing, "api"); return toConnection(row); }, diff --git a/server/src/services/tool-action-review.ts b/server/src/services/tool-action-review.ts index 01450174b22..b429b35ce69 100644 --- a/server/src/services/tool-action-review.ts +++ b/server/src/services/tool-action-review.ts @@ -9,6 +9,7 @@ import { } from "@paperclipai/db"; import { conflict, forbidden, notFound } from "../errors.js"; import { assertIssueThreadInteractionResolverAudience } from "./issue-thread-interaction-resolution.js"; +import { emitConnectionInvoked } from "./connector-telemetry.js"; import { toolAccessPolicyService } from "./tool-access-policy.js"; import { logActivity, @@ -237,6 +238,11 @@ export async function commitToolActionReview( } return updated; }); + // A rejection writes the invocation's terminal `denied` status inside the + // transaction above; emit only after that commit. Approvals stay in-flight + // and reach their terminal status in the gateway execution paths. + if (input.decision === "rejected") + void emitConnectionInvoked(db, source.invocationId); for (const publication of publications) publishActivity(publication); return result; } diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 56ab25c088a..c815717f19b 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -8,6 +8,7 @@ import { githubGuestBotConnectionForSession, githubBotToolsForSession } from "./ import { githubChatReviewService } from "./chat-github-reviews.js"; import { runIdentityContexts } from "@paperclipai/db"; import { captureRunIdentity } from "./run-identity.js"; +import { emitConnectionInvoked } from "./connector-telemetry.js"; import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js"; import { extractRemoteMcpPending } from "./remote-mcp-pending.js"; import { logger } from "../middleware/logger.js"; @@ -2223,6 +2224,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, input.invocation.id)); + void emitConnectionInvoked(db, input.invocation.id); await writeToolCallEvent({ invocationId: input.invocation.id, actionRequestId: input.actionRequest?.id ?? null, @@ -2260,6 +2262,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, input.invocation.id)); + void emitConnectionInvoked(db, input.invocation.id); throw new ToolGatewayHttpError( 500, "Approval request was not created", @@ -2309,6 +2312,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, input.invocation.id)); + void emitConnectionInvoked(db, input.invocation.id); throw new ToolGatewayHttpError( 500, error.message, @@ -2475,6 +2479,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, input.invocation.id)); + void emitConnectionInvoked(db, input.invocation.id); throw new ToolGatewayHttpError( 409, "The approval request was resolved before it could be signed", @@ -7177,6 +7182,10 @@ export function createToolGatewayService( execution: connectedMcpExecution.execution, }, }); + // After the bookkeeping above: a throw there is caught below, overwrites + // the row to failed, and emits — an earlier success emit would make one + // execution report both outcomes. + void emitConnectionInvoked(db, args.invocationId); return { decision: "allowed" as const, invocationId: args.invocationId, @@ -7206,6 +7215,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, args.invocationId)); + void emitConnectionInvoked(db, args.invocationId); await writeToolCallEvent({ invocationId: args.invocationId, session: args.session, @@ -7320,6 +7330,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, invocation.id)); + void emitConnectionInvoked(db, invocation.id); await reflectToolActionInteractionLifecycle({ actionRequestId, status: "failed", @@ -7363,6 +7374,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, invocation.id)); + void emitConnectionInvoked(db, invocation.id); await reflectToolActionInteractionLifecycle({ actionRequestId, status: "failed", @@ -7528,6 +7540,7 @@ export function createToolGatewayService( return true; }); if (!settled) return { reasonCode, message, settled: false }; + void emitConnectionInvoked(db, input.invocationId); await reflectToolActionInteractionLifecycle({ actionRequestId: input.actionRequestId, status: "failed", @@ -7580,6 +7593,7 @@ export function createToolGatewayService( updatedAt: now, }) .where(eq(toolInvocations.id, input.invocationId)); + void emitConnectionInvoked(db, input.invocationId); await reflectToolActionInteractionLifecycle({ actionRequestId: expired.id, status: "expired", @@ -7976,6 +7990,7 @@ export function createToolGatewayService( updatedAt: now, }) .where(eq(toolInvocations.id, invocation.id)); + void emitConnectionInvoked(db, invocation.id); await db .update(toolActionRequests) .set({ status: "executed", resolvedAt: now, updatedAt: now }) @@ -9045,6 +9060,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, invocationId)); + void emitConnectionInvoked(db, invocationId); throw new ToolGatewayHttpError( 500, error.message, @@ -9117,6 +9133,9 @@ export function createToolGatewayService( } if (!accessDecision.allowed) { + // recordInvocation inserted this row already terminal (denied or + // rate_limited), so this is its only completion boundary. + void emitConnectionInvoked(db, invocationId); await writeAudit({ session, companyId: input.companyId, @@ -9316,6 +9335,7 @@ export function createToolGatewayService( updatedAt: now, }) .where(eq(toolInvocations.id, row.invocationId)); + void emitConnectionInvoked(db, row.invocationId); await reflectToolActionInteractionLifecycle({ actionRequestId: row.id, status, @@ -10281,6 +10301,9 @@ export function createToolGatewayService( }); } if (!accessDecision.allowed) { + // recordInvocation inserted this row already terminal (denied or + // rate_limited), so this is its only completion boundary. + void emitConnectionInvoked(db, invocationId); await writeAudit({ session, companyId: session.companyId, @@ -10485,6 +10508,10 @@ export function createToolGatewayService( execution: connectedMcpExecution?.execution ?? undefined, }, }); + // After the bookkeeping above: a throw there is caught below, overwrites + // the row to failed, and emits — an earlier success emit would make one + // execution report both outcomes. + void emitConnectionInvoked(db, invocationId); return { invocationId, status: "completed" as const, @@ -10541,6 +10568,7 @@ export function createToolGatewayService( updatedAt: completedAt, }) .where(eq(toolInvocations.id, invocationId)); + void emitConnectionInvoked(db, invocationId); if (input.approvedActionRequestId) { const [failedRequest] = await db .update(toolActionRequests) @@ -10722,6 +10750,9 @@ export function createToolGatewayService( } if (!accessDecision.allowed) { + // recordInvocation inserted this row already terminal (denied or + // rate_limited), so this is its only completion boundary. + void emitConnectionInvoked(db, invocationId); await writeAudit({ session: sessionLike, companyId: input.runContext.companyId, @@ -10836,6 +10867,10 @@ export function createToolGatewayService( resultSummary: resultValidation.summary, }, }); + // After the bookkeeping above: a throw there is caught below, overwrites + // the row to failed, and emits — an earlier success emit would make one + // execution report both outcomes. + void emitConnectionInvoked(db, invocationId); return resultValidation.value as typeof result; } catch (err) { const status = err instanceof ToolGatewayHttpError ? err.status : 502; @@ -10856,6 +10891,7 @@ export function createToolGatewayService( updatedAt: new Date(), }) .where(eq(toolInvocations.id, invocationId)); + void emitConnectionInvoked(db, invocationId); await writeToolCallEvent({ invocationId, session: sessionLike, From a6448cd060936e29656f45f374eca6d968216dcd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:43:46 -0700 Subject: [PATCH 02/16] fix(runner): tolerate Codex account notifications during active turns (#13902) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Rust runner connects task runs to Codex app-server sessions. > - Codex sends account notifications on the connection during authentication updates. > - These notifications have no task, thread, or turn identity. > - The runner treated a valid account update as an invalid task event and stopped the run. > - This pull request classifies account updates as connection information while preserving task identity checks. > - Sandbox images can also contain an older runner with compatible metadata. The server must compare its bytes before reuse. > - Slack conversations use the deployed runner and can complete when Codex refreshes account state. ## Linked Issues or Issue Description Related: #13853. That change fixes the supported Codex version range. This fixes a separate notification failure after the version check succeeds. **What happened?** An active Codex task stopped with `thread_binding_mismatch` when the provider emitted `account/updated` without a thread ID. Slack showed that the agent stopped before completing its turn. **Expected behavior** Connection-level account updates must not stop a task or acquire task authority. Account details must not appear in task output. **Steps to reproduce** 1. Start a task with the native Codex runner. 2. Emit `account/updated` during the turn, with `authMode` and `planType` but no thread ID. 3. Observe that the old runner rejects the notification and fails the turn. **Paperclip version or commit** Reproduced after #13853. The fix is based on `b648d8cdd`. **Deployment mode** Cloud staging with a remote Codex runner and a Slack chat connection. ## What Changed - Classify `account/updated` and `account/login/completed` as connection information. - Reject account notifications that contain execution identity fields. - Reuse the existing bounded diagnostic path without publishing account payloads. - Test both notification types during two consecutive turns. Preserve existing identity rejection tests. - Compare preinstalled sandbox runner bytes with the controller artifact. Stage the deployed binary after a mismatch, failed checksum, or timeout. Keep exact retained artifact reuse. ## Verification - `cargo test --release --locked -p paperclip-runner-core`: passed, 582 test executions; two existing ignored cases. - `cargo test --release --locked -p paperclip-runner-core --test codex_provider`: 89 passed; two existing ignored cases. - `cargo fmt --all` and `git diff --check`: passed. - Repository-wide `pnpm -r typecheck`: passed. Server typecheck also passed after the artifact-selection change. - Server executor suite: 456 tests passed, including preinstalled digest match, mismatch, checksum failure, and timeout. - Repository-wide Vitest and build are running. - Deployed `5648d90d5dd762c5a8b697face269f6eea9cd59d` to one staging stack and verified its serving SHA. - Retried the failed conversation through the Slack UI. The agent replied and the task completed. - Sent a fresh Slack mention asking which bots belong to the channel. Verified successful `slack_members` and `slack_user` calls, a successful run, and the delivered Slack answer. - Sent a follow-up in the same Slack thread without another mention. The agent returned the requested names from context, with one final reply. ## Risks - Only two known connection-level notification methods change behavior. Unknown authoritative methods and malformed or conflicting identities still fail closed. - An older sandbox runner now requires one upload when its bytes differ from the controller artifact. Remote OS and architecture checks still apply. - No schema, credential, permission, Codex version-range, or UI changes. The Codex minimum remains 0.149.0. ## Model Used OpenAI GPT-6 (`gpt-6-astra`) through Codex, with reasoning, code editing, terminal tools, and browser verification. The context-window size is not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/bin/fake-codex-app-server.rs | 8 +++ .../crates/runner-core/src/codex_provider.rs | 46 +++++++++++++- .../runner-core/tests/codex_provider.rs | 48 +++++++++++++++ .../native-session-executor.test.ts | 12 ++-- .../native-runtime/native-session-executor.ts | 61 ++++++++++--------- 5 files changed, 138 insertions(+), 37 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index aeb637022e1..bac103d0ff0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -1471,6 +1471,14 @@ fn run() -> Result<(), Box> { "method": "turn/started", "params": {"turn": {"id": provider_turn_id}} }))?; + if args.iter().any(|value| value == "--account-notifications") { + send(json!({"method":"account/updated", "params":{ + "authMode":"chatgpt", "planType":"pro" + }}))?; + send(json!({"method":"account/login/completed", "params":{ + "loginId":"fixture-login", "success":true, "error":null + }}))?; + } if args.iter().any(|value| value == "--helper-tool-requests") { if !args.iter().any(|value| value == "--foreign-helper-tool") { send(json!({"method":"item/completed", "params":{ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index bfa72a01c71..549b6d88908 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -3383,8 +3383,18 @@ fn classify_notification_thread( "Codex notification has malformed turn identity", )); } - // This connection-level notification carries no task authority. Codex can - // emit it while loading skills during the first turn. + // Account and skill updates describe the provider connection, not a task. + // Codex can emit them during startup or credential refresh without thread + // or turn IDs. Never let them acquire execution authority or expose their + // account payload as task output. + if matches!(method, "account/updated" | "account/login/completed") { + if contains_provider_work_binding(params) { + return Err(LocalRunnerError::invalid( + "Codex account notification contains execution identity", + )); + } + return Ok(NotificationThread::UnrelatedInformation); + } if method == "skills/changed" && !contains_provider_work_binding(params) { return Ok(NotificationThread::UnrelatedInformation); } @@ -4986,6 +4996,38 @@ mod notification_identity_tests { } } + #[test] + fn account_notifications_are_connection_information_without_execution_authority() { + for (method, params) in [ + ( + "account/updated", + json!({"authMode":"chatgpt", "planType":"pro"}), + ), + ( + "account/login/completed", + json!({"loginId":null, "success":true, "error":null}), + ), + ] { + assert_eq!( + classify_notification_thread(method, "root", &BTreeSet::new(), ¶ms).unwrap(), + NotificationThread::UnrelatedInformation + ); + for invalid in [ + json!({"threadId":"other"}), + json!({"turnId":"unbound"}), + json!({"itemId":"unbound"}), + json!({"nested":{"request":{"id":"unbound"}}}), + json!({"threadId":7}), + json!({"threadId":"root", "thread":{"id":"other"}}), + ] { + assert!( + classify_notification_thread(method, "root", &BTreeSet::new(), &invalid) + .is_err() + ); + } + } + } + #[test] fn child_mcp_startup_before_lineage_has_no_execution_authority() { let params = diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index b32714c0cb7..ff4edc2e33b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -588,6 +588,54 @@ fn codex_transport_buffers_notifications_while_waiting_for_responses() { fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); } +#[test] +fn codex_account_updates_do_not_interrupt_turns_or_publish_account_details() { + let directory = temporary_directory("account-notifications"); + let config = provider_config(&directory, &["--account-notifications"]); + let mut provider = CodexProvider::start(&config, None).expect("start fake Codex provider"); + for _ in 0..2 { + provider + .start_turn("Are you there?", &config.cwd) + .expect("start provider turn"); + let mut completed = false; + let mut account_notices = 0; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + match provider.poll().expect("poll provider event") { + Some(CodexProviderEvent::ProtocolFailure { diagnostic }) => { + panic!("account notification interrupted the turn: {diagnostic}"); + } + Some(CodexProviderEvent::Notification { method, params }) => { + if params["providerMethod"] + .as_str() + .is_some_and(|method| method.starts_with("account/")) + { + account_notices += 1; + assert_eq!(method, "warning"); + assert_eq!(params["classification"], "unrelated_information"); + assert!(!params.to_string().contains("fixture-login")); + assert!(params.get("authMode").is_none()); + assert!(params.get("planType").is_none()); + } + if method == "turn/completed" { + completed = true; + break; + } + } + _ => {} + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!( + completed, + "the real provider boundary must deliver the terminal" + ); + assert_eq!(account_notices, 2); + } + provider.shutdown().expect("stop provider"); + fs::remove_dir_all(directory).expect("remove account notification test directory"); +} + #[test] fn codex_goal_autostart_binds_the_provider_turn_authority() { let directory = temporary_directory("goal-autostart"); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 3be76308f40..4bd73dce899 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -10396,7 +10396,7 @@ describe("runnerd provider runtime wiring", () => { }); it.each([ - ...["current", "stale", "missing", "retained", "retained-mismatch", "retained-error", "retained-timeout", "retained-explicit"] + ...["current", "preinstalled-exact", "preinstalled-mismatch", "preinstalled-error", "preinstalled-timeout", "stale", "missing", "retained", "retained-mismatch", "retained-error", "retained-timeout", "retained-explicit"] .map((image) => ({ image, version: "0.156.0", compatible: true })), ...["0.149.0", "0.149.1", "0.153.4", "0.156.1"] .map((version) => ({ image: "current", version, compatible: true })), @@ -10405,11 +10405,11 @@ describe("runnerd provider runtime wiring", () => { ])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version, compatible }) => { const retained = image.startsWith("retained"); const exactRetained = image === "retained" || image === "retained-explicit"; - const needsReplacement = image !== "current" && !exactRetained; + const needsReplacement = image !== "current" && image !== "preinstalled-exact" && !exactRetained; // The mocked remote executes metadata probes; artifact staging only needs bytes. // Keep this regression independent of a locally compiled Rust runner binary. const controllerArtifact = join(isolatedStateDirectory, "paperclip-runnerd"); - if (needsReplacement || retained) { + if (needsReplacement || retained || image === "preinstalled-exact") { await writeFile(controllerArtifact, "fixture runner artifact"); state.resolveRunnerBinary.mockReturnValueOnce(controllerArtifact); } @@ -10420,10 +10420,10 @@ describe("runnerd provider runtime wiring", () => { let stdout = ""; const script = command.args?.[1] ?? ""; if (script.includes('sha256sum "$1"')) { - if (image === "retained-error") throw new Error("checksum unavailable"); + if (image.endsWith("-error")) throw new Error("checksum unavailable"); return { - exitCode: 0, signal: null, timedOut: image === "retained-timeout", stderr: "", - stdout: `${createHash("sha256").update(image === "retained-mismatch" ? "stale artifact" : "fixture runner artifact").digest("hex")} ${command.args?.[3]}\n`, + exitCode: 0, signal: null, timedOut: image.endsWith("-timeout"), stderr: "", + stdout: `${createHash("sha256").update(image.endsWith("-mismatch") ? "stale artifact" : "fixture runner artifact").digest("hex")} ${command.args?.[3]}\n`, }; } else if (command.args?.[0] === "--build-metadata") { stdout = JSON.stringify({ diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 48de155f837..e6ec8839124 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -10852,9 +10852,33 @@ async function createRunnerdBackendWithinSessionClaim( remotePrepared = true; return; } - // A resumed sandbox may already contain the exact controller-owned binary. - // Do not upload it every turn, but never treat compatible metadata alone as - // proof of artifact identity (especially for an explicit operator override). + // Compatibility metadata does not prove artifact identity. Reuse only the + // exact controller-owned bytes when the controller artifact is available. + const matchesControllerRunnerArtifact = async (executable: string): Promise => { + const expected = createHash("sha256") + .update(readFileSync(controllerRunnerBinary)) + .digest("hex"); + try { + const probe = await remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', + "paperclip-runner-artifact", + executable, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + return probe.exitCode === 0 && !probe.timedOut && + /^[a-f0-9]{64}\s/.test(probe.stdout) && + probe.stdout.trim().split(/\s+/)[0] === expected; + } catch { + // An unavailable checksum uses the verified staging path. + return false; + } + }; let runnerArtifactPrepared = false; if ( sandboxLeaseAcquisition?.outcome === "resumed" && @@ -10863,32 +10887,7 @@ async function createRunnerdBackendWithinSessionClaim( runnerArtifactPrepared = await measureNativeRunnerSpan( input.trace, "runner.artifact.verify_retained", - async () => { - const expected = createHash("sha256") - .update(readFileSync(controllerRunnerBinary)) - .digest("hex"); - try { - const probe = await remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', - "paperclip-runner-artifact", - remoteBinary, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - return probe.exitCode === 0 && !probe.timedOut && - /^[a-f0-9]{64}\s/.test(probe.stdout) && - probe.stdout.trim().split(/\s+/)[0] === expected; - } catch { - // Missing binaries, checksum tools, or a failed probe use the - // ordinary verified staging path; none authorizes cached execution. - return false; - } - }, + () => matchesControllerRunnerArtifact(remoteBinary), ); } const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null; @@ -10905,6 +10904,10 @@ async function createRunnerdBackendWithinSessionClaim( "runner.artifact.verify_preinstalled", () => verifyRemoteRunner(requiredMode, preinstalledRunner), ); + if (existsSync(controllerRunnerBinary) && + !await matchesControllerRunnerArtifact(preinstalledRunner)) { + throw new Error("runner_remote_preinstalled_artifact_mismatch"); + } await measureNativeRunnerSpan( input.trace, "runner.artifact.link", From fff410dfe777ae0427385e8297df992ba9aed4ce Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 16:23:42 -0700 Subject: [PATCH 03/16] fix(ui): retain bounded context for browser render errors (#13904) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its browser error boundaries recover from failed renders and report the exception when error monitoring is enabled. > - The boundaries already receive the React component stack, but discard it before reporting the error. > - A minified DOM insertion error can therefore lack enough context to identify the affected component. > - Browser translation can replace text nodes that React still uses as insertion anchors. > - This pull request preserves bounded component names and browser state for the next failure. > - Maintainers can locate the failed component without collecting page content or customer URLs. ## Linked Issues or Issue Description Related: #13719 attributes browser errors to the loaded release. #13784 supplies the browser environment. This change adds context to error-boundary reports. **What happened?** A browser error boundary reports a DOM `NotFoundError` with only a minified JavaScript stack. The React component trace is logged to the console, which production builds remove. Browser translation is a plausible cause, but the report cannot identify the component or confirm the translation marker. **Expected behavior** An error-boundary report includes a bounded component trace and limited browser state. It excludes component props, text, HTML, element IDs, arbitrary CSS classes, page URLs, and query strings. **Steps to reproduce** 1. Render a component with conditional content before a text node. 2. Replace that text node with a translation element outside React. 3. Enable the preceding conditional content. React tries to insert before the detached text node and throws `NotFoundError`. 4. The boundary shows its recovery UI, but the old report loses the component trace. **Paperclip version or commit** Based on `6681c71b40`. The regression test reproduces the DOM mutation with the installed React version. **Deployment mode** Built browser UI with optional Sentry monitoring enabled for the signed-in session. ## What Changed - Pass the component stack and boundary kind from both error boundaries. - Keep at most 40 component names from at most 16 KiB of stack input. Drop locations and unrecognized lines. - Snapshot document readiness, visibility, and the browser translation root-class marker before the asynchronous reporting queue runs. - Attach diagnostics to that event only. Preserve the existing monitoring gate, sign-out behavior, and original exception if diagnostics fail. - Preserve function names in production bundles. Test the actual Vite production pipeline. - Document the fields, privacy limits, and translation-marker limitations. ## Verification - Focused diagnostics, boundary, real Sentry SDK, and production-build tests: 49 passed. - The translation DOM-mutation test reproduces `NotFoundError`, verifies the failed component trace, and keeps the recovery UI usable. - The real SDK test checks emitted events, private fixture exclusion, event isolation, and no capture after sign-out. Its transport stays in-process. - `pnpm check:token-gates`: passed. - [Greptile review](https://github.com/paperclipai/paperclip/pull/13904#issuecomment-5804220318): 5/5 on `92a2a23d9c`, with no review threads. - `pnpm -r typecheck` and `pnpm build`: passed. - Full CI unit and integration test matrix: passed, including all server, chat, workspace, runner, and serialized suites. - Local `pnpm test:run` was started, then stopped after the equivalent full CI matrix passed. The unsharded local run was not completed and is not claimed as a pass. - `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/project-repositories.spec.ts --repeat-each=3 --trace=on --reporter=line`: six tests passed against the production build. - Initial CI browser shard 8 timed out after the repository form replaced an enabled Save button with a disabled one before the click. The retained page snapshot shows the original repository selection. No error-boundary fallback appeared. [CI attempt 2](https://github.com/paperclipai/paperclip/actions/runs/35929990870/attempts/2) passed the failed jobs on the same commit. The full PR check matrix is green. This confirms an intermittent failure, but does not establish the cause of the first failure. - Full browser builds with and without name preservation passed. The initial JavaScript chunk grows from 1,571,919 to 1,668,453 gzip bytes (+6.1%). Total JavaScript across all chunks grows by 219,610 gzip bytes (+5.3%). ## Risks - This adds diagnostics. It reproduces a translation failure mode but does not identify or repair the specific application component from a past report. - The root-class marker is a hint. Other translation tools may omit it, and its presence does not prove causation. - Name preservation increases bundle size as measured above. No source maps are published by this change. - The error is still reported. DOM operations, browser translation, and recovery behavior are unchanged. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code editing, terminal tools, and test execution. The exact serving model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/observability.md | 19 ++++ ui/src/components/AppErrorBoundary.test.tsx | 48 ++++++++- ui/src/components/AppErrorBoundary.tsx | 2 +- ui/src/components/RouteErrorBoundary.test.tsx | 6 +- ui/src/components/RouteErrorBoundary.tsx | 2 +- ui/src/lib/browser-error-context.test.ts | 73 ++++++++++++++ ui/src/lib/browser-error-context.ts | 57 +++++++++++ ui/src/lib/sentry.test.ts | 97 +++++++++++++++++++ ui/src/lib/sentry.ts | 13 ++- ui/src/lib/vite-component-names.test.ts | 41 ++++++++ ui/vite.config.ts | 3 + 11 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 ui/src/lib/browser-error-context.test.ts create mode 100644 ui/src/lib/browser-error-context.ts create mode 100644 ui/src/lib/vite-component-names.test.ts diff --git a/doc/observability.md b/doc/observability.md index e4fd4d61787..7b5bef23c88 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -411,6 +411,25 @@ not inherit a previous run's identity or fingerprint. The browser sends no page URL, no referrer, no user agent, and no breadcrumb. +Application and route error-boundary reports also include: + +- `react_error_boundary`: `app` or `route`. +- `react.componentStack`: up to 40 React component names. Frame locations, + URLs, arguments, and unrecognized lines are omitted. Parsing examines at + most 16 KiB of input. Production builds preserve function names so this + trace remains useful after minification; this adds some bundle size. +- `browser_state`: document readiness, visibility, and a boolean indicating + the `translated-ltr` or `translated-rtl` root class used by browser translation. + The marker is evidence of DOM translation, not proof of the error's cause; + its absence does not exclude other translators or DOM-changing extensions. + +These fields are captured at the failure, before asynchronous reporting, and +attached only to that event. They include no component props, DOM text, HTML, +element identifiers, arbitrary CSS classes, route, or query string. Failed +diagnostic reads do not prevent the original exception from being reported. +The monitoring gate and sign-out behavior still apply. This context does not +suppress errors, change DOM operations, or disable browser translation. + ### Fail-open behavior A failed Sentry import or a failed init never stops the server and never diff --git a/ui/src/components/AppErrorBoundary.test.tsx b/ui/src/components/AppErrorBoundary.test.tsx index 285b9c595fa..ecb0d9a2dfe 100644 --- a/ui/src/components/AppErrorBoundary.test.tsx +++ b/ui/src/components/AppErrorBoundary.test.tsx @@ -8,7 +8,7 @@ import { AppErrorBoundary } from "./AppErrorBoundary"; const captureBrowserExceptionMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/sentry", () => ({ - captureBrowserException: (error: unknown) => captureBrowserExceptionMock(error), + captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args), })); function BoomRender(): never { @@ -36,6 +36,7 @@ describe("AppErrorBoundary", () => { afterEach(() => { consoleErrorSpy.mockRestore(); container.remove(); + document.documentElement.classList.remove("translated-ltr"); captureBrowserExceptionMock.mockClear(); }); @@ -50,7 +51,9 @@ describe("AppErrorBoundary", () => { }); expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); - expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error)); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), { + boundary: "app", componentStack: expect.stringContaining("BoomRender"), + }); expect( Array.from(container.querySelectorAll("button")).some( (button) => button.textContent === "Reload page", @@ -119,4 +122,45 @@ describe("AppErrorBoundary", () => { root.unmount(); }); }); + + it("preserves the original DOM insertion error and its failing component", () => { + function BrokenDomInsertion() { + useEffect(() => { + document.createElement("div").insertBefore( + document.createElement("span"), document.createElement("span"), + ); + }, []); + return null; + } + const root = createRoot(container); + act(() => root.render()); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "NotFoundError" }), + { boundary: "app", componentStack: expect.stringContaining("BrokenDomInsertion") }, + ); + expect(container.textContent).toContain("Paperclip hit an error"); + act(() => root.unmount()); + }); + + it("reports the component when translation replaces a React-owned insertion anchor", () => { + function TranslatedStatus({ showIcon }: { showIcon: boolean }) { + return
{showIcon &&
; + } + const root = createRoot(container); + act(() => root.render()); + const parent = container.querySelector('[data-testid="translated-status"]')!; + // Translation replaces the text node; React still holds that old node as + // the insertion anchor when it later adds a sibling before the text. + const translated = document.createElement("font"); + translated.textContent = "Translated status"; + parent.replaceChild(translated, parent.firstChild!); + document.documentElement.classList.add("translated-ltr"); + act(() => root.render()); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "NotFoundError" }), + { boundary: "app", componentStack: expect.stringContaining("TranslatedStatus") }, + ); + expect(container.textContent).toContain("Reload page"); + act(() => root.unmount()); + }); }); diff --git a/ui/src/components/AppErrorBoundary.tsx b/ui/src/components/AppErrorBoundary.tsx index 68884f06141..671259edbdb 100644 --- a/ui/src/components/AppErrorBoundary.tsx +++ b/ui/src/components/AppErrorBoundary.tsx @@ -25,7 +25,7 @@ export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErro override componentDidCatch(error: unknown, info: ErrorInfo): void { console.error("App shell crashed", { error, componentStack: info.componentStack }); - captureBrowserException(error); + captureBrowserException(error, { boundary: "app", componentStack: info.componentStack }); } override render() { diff --git a/ui/src/components/RouteErrorBoundary.test.tsx b/ui/src/components/RouteErrorBoundary.test.tsx index ba8f7d34cdd..6177da5f2a5 100644 --- a/ui/src/components/RouteErrorBoundary.test.tsx +++ b/ui/src/components/RouteErrorBoundary.test.tsx @@ -15,7 +15,7 @@ vi.mock("@/lib/router", () => ({ })); vi.mock("@/lib/sentry", () => ({ - captureBrowserException: (error: unknown) => captureBrowserExceptionMock(error), + captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args), })); function Boom(): never { @@ -52,7 +52,9 @@ describe("RouteErrorBoundary", () => { }); expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); - expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error)); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), { + boundary: "route", componentStack: expect.stringContaining("Boom"), + }); expect( Array.from(container.querySelectorAll("button")).some( (button) => button.textContent === "Go back", diff --git a/ui/src/components/RouteErrorBoundary.tsx b/ui/src/components/RouteErrorBoundary.tsx index 2c2bd9403aa..5787df15561 100644 --- a/ui/src/components/RouteErrorBoundary.tsx +++ b/ui/src/components/RouteErrorBoundary.tsx @@ -22,7 +22,7 @@ class RouteErrorBoundaryInner extends Component { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.documentElement.className = ""; +}); + +describe("browser error context", () => { + it("keeps React component names without locations or unrecognized content", () => { + const stack = ` + at TaskDetail (https://tenant.example/assets/app.js?token=private-value:42:7) + at div () + in AppShell (created by Router) +TaskPanel@https://tenant.example/tasks/private-task:12:3 + at ErrorBoundary (/workspace/private/src/Boundary.tsx:15:9) + at https://tenant.example/private-page:17:3 +unrecognized private content`; + expect(sanitizeComponentStack(stack)).toBe( + "\n at TaskDetail\n at div\n at AppShell\n at TaskPanel\n at ErrorBoundary", + ); + }); + + it("bounds malformed and oversized component stacks", () => { + expect(sanitizeComponentStack(` at ${"a".repeat(101)} (private)`)).toBeUndefined(); + expect(sanitizeComponentStack("x".repeat(16_384) + "\n at SecretSuffix")).toBeUndefined(); + expect(sanitizeComponentStack("\n at TaskDetail (private)".repeat(100))?.match(/at TaskDetail/g)).toHaveLength(40); + }); + + it.each([undefined, null, "", "private unrecognized data"])("omits absent or unrecognized stacks: %s", (stack) => { + expect(sanitizeComponentStack(stack)).toBeUndefined(); + }); + + it.each(["translated-ltr", "translated-rtl"])("records only a translation-marker boolean for %s", (marker) => { + document.documentElement.className = `${marker} private-customer-class`; + const result = buildBrowserErrorContext({ boundary: "route", componentStack: "\n at TaskDetail" }); + expect(result).toMatchObject({ + tags: { react_error_boundary: "route" }, + contexts: { react: { componentStack: "\n at TaskDetail" }, browser_state: { translation_marker: true } }, + }); + expect(JSON.stringify(result)).not.toContain("private-customer-class"); + }); + + it("captures a snapshot rather than reading changed DOM state later", () => { + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + vi.spyOn(document, "readyState", "get").mockReturnValue("interactive"); + const result = buildBrowserErrorContext({ boundary: "app" }); + document.documentElement.classList.add("translated-ltr"); + expect(result.contexts.browser_state).toEqual({ + ready_state: "interactive", visibility_state: "hidden", translation_marker: false, + }); + }); + + it("keeps the component trace when browser-state reads throw", () => { + vi.spyOn(document, "readyState", "get").mockImplementation(() => { throw new Error("unavailable"); }); + vi.spyOn(document.documentElement, "classList", "get").mockImplementation(() => { throw new Error("unavailable"); }); + expect(buildBrowserErrorContext({ boundary: "app", componentStack: "\n at TaskDetail" })).toEqual({ + tags: { react_error_boundary: "app" }, + contexts: { + react: { componentStack: "\n at TaskDetail" }, + browser_state: { visibility_state: document.visibilityState }, + }, + }); + }); + + it("does not require a browser document", () => { + vi.stubGlobal("document", undefined); + expect(buildBrowserErrorContext({ boundary: "app" }).contexts.browser_state).toEqual({}); + }); +}); diff --git a/ui/src/lib/browser-error-context.ts b/ui/src/lib/browser-error-context.ts new file mode 100644 index 00000000000..b0b7b896448 --- /dev/null +++ b/ui/src/lib/browser-error-context.ts @@ -0,0 +1,57 @@ +export interface BrowserErrorDetails { + boundary: "app" | "route"; + componentStack?: string | null; +} + +const MAX_STACK_LENGTH = 16_384; +const MAX_COMPONENT_FRAMES = 40; + +/** Keep component names only: React stack locations can contain tenant URLs. */ +export function sanitizeComponentStack(stack: string | null | undefined): string | undefined { + if (!stack) return undefined; + const frames: string[] = []; + for (const line of stack.slice(0, MAX_STACK_LENGTH).split("\n").slice(0, MAX_COMPONENT_FRAMES + 1)) { + // V8/React and Firefox/Safari component-frame formats. Never retain the + // location, arguments, or an unrecognized line as a fallback. + const name = /^\s*(?:at|in) ([A-Za-z_$][\w$.-]{0,99})(?:\s|$)/.exec(line)?.[1] + ?? /^([A-Za-z_$][\w$.-]{0,99})@/.exec(line)?.[1]; + if (name) frames.push(` at ${name}`); + if (frames.length === MAX_COMPONENT_FRAMES) break; + } + return frames.length ? `\n${frames.join("\n")}` : undefined; +} + +/** A bounded snapshot, taken at the error rather than when the capture queue runs. */ +export function buildBrowserErrorContext(details: BrowserErrorDetails) { + const componentStack = sanitizeComponentStack(details.componentStack); + const browserState: { + ready_state?: "loading" | "interactive" | "complete"; + visibility_state?: "visible" | "hidden"; + translation_marker?: boolean; + } = {}; + if (typeof document !== "undefined") { + // Each read is optional. A DOM wrapper or extension must not prevent the + // original exception and component trace from reaching the error monitor. + try { + const state = document.readyState; + if (state === "loading" || state === "interactive" || state === "complete") { + browserState.ready_state = state; + } + } catch { /* diagnostic unavailable */ } + try { + const state = document.visibilityState; + if (state === "visible" || state === "hidden") browserState.visibility_state = state; + } catch { /* diagnostic unavailable */ } + try { + const classes = document.documentElement.classList; + browserState.translation_marker = classes.contains("translated-ltr") || classes.contains("translated-rtl"); + } catch { /* diagnostic unavailable */ } + } + return { + tags: { react_error_boundary: details.boundary }, + contexts: { + react: componentStack ? { componentStack } : {}, + browser_state: browserState, + }, + }; +} diff --git a/ui/src/lib/sentry.test.ts b/ui/src/lib/sentry.test.ts index a887cff0965..734be7edf19 100644 --- a/ui/src/lib/sentry.test.ts +++ b/ui/src/lib/sentry.test.ts @@ -276,6 +276,103 @@ describe("captureBrowserException", () => { expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error)); }); + + it("sends sanitized per-error context captured before the queue runs", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, captureBrowserException } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + document.documentElement.classList.add("translated-ltr"); + const error = new DOMException("insertBefore failed", "NotFoundError"); + captureBrowserException(error, { + boundary: "route", componentStack: "\n at TaskDetail (https://tenant.example/tasks/private-id?token=private-value:1:2)", + }); + document.documentElement.classList.remove("translated-ltr"); + await Promise.resolve(); + await Promise.resolve(); + expect(mocks.captureException).toHaveBeenCalledWith(error, { + tags: { react_error_boundary: "route" }, + contexts: { + react: { componentStack: "\n at TaskDetail" }, + browser_state: expect.objectContaining({ translation_marker: true }), + }, + }); + expect(JSON.stringify(mocks.captureException.mock.calls)).not.toContain("private-value"); + captureBrowserException(new Error("unrelated error")); + // Initialization is idempotent and provides a barrier on the same queue. + await initBrowserErrorMonitoring(DSN); + expect(mocks.captureException).toHaveBeenLastCalledWith(expect.any(Error)); + }); + + it("still captures the original error if component diagnostics fail", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, captureBrowserException } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + const error = new Error("original error"); + expect(() => captureBrowserException(error, { + boundary: "app", + get componentStack(): string { throw new Error("diagnostic failure"); }, + })).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + expect(mocks.captureException).toHaveBeenCalledWith(error); + }); +}); + +describe("browser error diagnostics with the real SDK", () => { + it("emits bounded component and translation context without page data or scope leakage", async () => { + const Sentry = await vi.importActual("@sentry/browser"); + const events: Array> = []; + vi.doMock("@sentry/browser", () => ({ + ...Sentry, + init: (options: Parameters[0]) => Sentry.init({ + ...options, + transport: () => ({ send: async () => ({}), flush: async () => true }), + beforeSend: (event) => { events.push(event as unknown as Record); return event; }, + }), + })); + const gate = await importFreshSentry(); + await gate.initBrowserErrorMonitoring(DSN, "staging"); + const previousPath = window.location.pathname + window.location.search; + window.history.replaceState({}, "", "/tasks/private-task?token=private-capability"); + document.documentElement.classList.add("translated-ltr", "private-customer-class"); + try { + gate.captureBrowserException(new DOMException("insertBefore failed", "NotFoundError"), { + boundary: "route", + componentStack: "\n at TaskDetail (https://tenant.example/tasks/private-task?token=private-capability:3:4)", + }); + document.documentElement.classList.remove("translated-ltr"); + gate.captureBrowserException(new Error("unrelated failure")); + await gate.initBrowserErrorMonitoring(DSN, "staging"); + await Sentry.flush(2000); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + environment: "staging", + tags: { react_error_boundary: "route" }, + contexts: { + react: { componentStack: "\n at TaskDetail" }, + browser_state: { translation_marker: true }, + }, + }); + expect(events[1]).not.toHaveProperty("contexts.react"); + expect(events[1]).not.toHaveProperty("contexts.browser_state"); + expect(events[1]).not.toHaveProperty("tags.react_error_boundary"); + for (const event of events) { + expect(event).not.toHaveProperty("request"); + expect(event.breadcrumbs).toBeUndefined(); + } + for (const secret of ["tenant.example", "private-task", "private-capability", "private-customer-class"]) { + expect(JSON.stringify(events)).not.toContain(secret); + } + await gate.teardownBrowserErrorMonitoring(); + gate.captureBrowserException(new Error("after sign-out"), { boundary: "app", componentStack: "\n at TaskDetail" }); + await gate.teardownBrowserErrorMonitoring(); + expect(events).toHaveLength(2); + } finally { + await gate.teardownBrowserErrorMonitoring(); + document.documentElement.classList.remove("translated-ltr", "private-customer-class"); + window.history.replaceState({}, "", previousPath); + } + }); }); /** diff --git a/ui/src/lib/sentry.ts b/ui/src/lib/sentry.ts index 0c3a8e6c63b..7d714793dca 100644 --- a/ui/src/lib/sentry.ts +++ b/ui/src/lib/sentry.ts @@ -38,6 +38,8 @@ // (`GlobalHandlers`), the two React error boundaries, deduplicates a repeat // event (`Dedupe`), and links a caused-by chain (`LinkedErrors`). +import { buildBrowserErrorContext, type BrowserErrorDetails } from "./browser-error-context"; + let queue: Promise = Promise.resolve(); /** Run gate operations one at a time, in call order. */ @@ -135,10 +137,17 @@ export function teardownBrowserErrorMonitoring(): Promise { * control flow. A no-op before the gate opens, when the gate never opens (no * DSN on the session), or when bootstrap failed. */ -export function captureBrowserException(error: unknown): void { +export function captureBrowserException(error: unknown, details?: BrowserErrorDetails): void { + let context: ReturnType | undefined; + try { + if (details) context = buildBrowserErrorContext(details); + } catch { + // Diagnostics must never replace the original exception or its recovery UI. + } void enqueue(async () => { try { - sentry?.captureException(error); + if (context) sentry?.captureException(error, context); + else sentry?.captureException(error); } catch (err) { // eslint-disable-next-line no-console console.error("[paperclip] Sentry captureBrowserException failed", err); diff --git a/ui/src/lib/vite-component-names.test.ts b/ui/src/lib/vite-component-names.test.ts new file mode 100644 index 00000000000..bdccf05b82d --- /dev/null +++ b/ui/src/lib/vite-component-names.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; +import { build } from "vite"; +import { expect, it } from "vitest"; + +it("keeps component names through the production bundle for React error traces", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-component-names-")); + try { + await mkdir(path.join(root, "public")); + await writeFile(path.join(root, "public/sw.js"), 'const buildId = "__PAPERCLIP_BUILD_ID__";'); + const entry = path.join(root, "entry.js"); + await writeFile(entry, ` + function DiagnosticComponent() { return null; } + class DiagnosticBoundary { render() { return null; } } + const DiagnosticArrow = () => null; + globalThis.componentNames = [DiagnosticComponent.name, DiagnosticBoundary.name, DiagnosticArrow.name]; + `); + const result = await build({ + configFile: fileURLToPath(new URL("../../vite.config.ts", import.meta.url)), + root, + mode: "production", + logLevel: "silent", + build: { + outDir: path.join(root, "dist"), + rolldownOptions: { input: entry, output: { format: "iife" } }, + }, + }); + const output = (Array.isArray(result) ? result[0] : result) as { output: Array<{ type: string; fileName: string }> }; + const chunk = output.output.find((item) => item.type === "chunk"); + expect(chunk).toBeDefined(); + const code = await readFile(path.join(root, "dist", chunk!.fileName), "utf8"); + const scope: { componentNames?: string[] } = {}; + runInNewContext(code, scope); + expect(scope.componentNames).toEqual(["DiagnosticComponent", "DiagnosticBoundary", "DiagnosticArrow"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 2ecc84ec774..6ac1be642c9 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -22,6 +22,9 @@ export default defineConfig(({ mode }) => ({ esbuild: mode === "production" ? { + // React's component trace uses function names. Keep those useful in + // error reports without publishing source maps or page context. + keepNames: true, drop: ["console", "debugger"], legalComments: "none", } From 794b09f8341394cbd66fbb572e00fab2bad55b71 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 16:35:59 -0700 Subject: [PATCH 04/16] fix(ui): alphabetize experimental settings and hide empty groups (#13905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path Paperclip operators use Experimental settings to find and manage optional controls. New cards have accumulated outside alphabetical order, and operator-hidden cards leave empty developer and legacy sections. Sort the displayed controls within their existing groups and remove groups with no visible controls. ## Related Issues **What existing behavior does this improve?** The Instance Settings → Experimental page. **Current behavior** Agent Chat follows Cases, MCP aggregators precedes External Objects, and hidden developer/legacy controls leave empty headings. The worktree execution card also ignores its operator visibility key. **Proposed behavior** Cards appear alphabetically by their displayed title within each section. Empty developer and legacy sections disappear. The worktree execution card follows the same operator visibility policy as other experimental controls. **Reason and benefit** Operators can scan the list predictably. Hosted installations show only the controls their operator permits, without empty sections or a worktree-only exception. No matching sorting PR was found in the duplicate search. This is a small improvement to an existing settings page and does not add a roadmap feature. ## What Changed - Reorder existing cards without changing their values or mutation handlers. - Hide empty developer/legacy sections and respect the hidden worktree-execution key. - Cover alphabetical ordering, conditional isolated-workspace controls, a restricted three-control policy, and partly visible sections. - Document sorting and operator visibility. ## Verification - `pnpm --dir ui exec vitest run src/pages/InstanceExperimentalSettings.test.tsx`: 45 tests passed. - `pnpm check:token-gates`: passed. - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - `pnpm --filter @paperclipai/ui typecheck`: passed. - All PR CI checks passed on `88011049be`, including the chat integration shards, full typecheck, build, browser tests, and policy checks. Greptile is 5/5 with no review threads. - Local `pnpm test:run` reported eight failures in unchanged chat, company-skills, email-channel, and workspace exposure suites. Stopped the remaining local run after CI completed successfully. Isolated chat rechecks were skipped by the host database support gate and do not count as passes. All matching CI shards passed; the full local suite is not claimed as green. - Frozen installation is blocked on the base branch by existing overrides/patch configuration drift from the lockfile. Local validation uses `pnpm@9.15.4 install --no-frozen-lockfile`; the original lockfile and manifests are unchanged in this PR. - Diff reviewed for secrets, private references, and run artifacts. ## Risks Settings only change position or visibility. Existing values, managed locks, API contracts, and feature dependencies are unchanged. Each section keeps its own alphabetical list. Reverting this change restores the previous presentation. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the enhancement template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run the relevant tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../board-operator/experimental-features.md | 3 + .../InstanceExperimentalSettings.test.tsx | 55 ++- ui/src/pages/InstanceExperimentalSettings.tsx | 448 +++++++++--------- 3 files changed, 288 insertions(+), 218 deletions(-) diff --git a/docs/guides/board-operator/experimental-features.md b/docs/guides/board-operator/experimental-features.md index 32602a7545c..485f9eb94ed 100644 --- a/docs/guides/board-operator/experimental-features.md +++ b/docs/guides/board-operator/experimental-features.md @@ -18,6 +18,9 @@ If you need stable behavior for an important workflow, do not rely on an experim ## Where you enable them Board operators enable or disable experiments from **Instance Settings > Experimental** in the app. +Controls are listed alphabetically within each section. Hosting operators can hide +controls they manage; empty developer and legacy sections are omitted. Hidden +controls retain their configured values. The CLI exposes the same surface: diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 0fe57b827f0..c965a20878e 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { INSTANCE_FEATURE_KEYS } from "@paperclipai/shared"; import type { InstanceExperimentalSettings as InstanceExperimentalSettingsPayload, InstanceExperimentalSettingsWithManaged, @@ -968,6 +969,17 @@ describe("InstanceExperimentalSettings — card ordering and headings (PAP-393)" expect(sections.at(-1)?.textContent).toContain("Goals Sidebar Link"); }); + it("alphabetizes cards by their displayed title within each section", async () => { + setWorktreeRuntimeMeta(true); + await renderPage({ ...defaultExperimentalSettings(), enableIsolatedWorkspaces: true }); + + for (const section of container.querySelectorAll("section")) { + const titles = [...section.querySelectorAll("h3")].map((heading) => heading.textContent ?? ""); + expect(titles.length).toBeGreaterThan(1); + expect(titles).toEqual([...titles].sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))); + } + }); + it("renders setting cards without a background color", async () => { await renderPage(defaultExperimentalSettings()); @@ -1001,11 +1013,15 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { root = null; queryClient?.clear(); container.remove(); + setWorktreeRuntimeMeta(false); vi.clearAllMocks(); }); - async function renderPage(hiddenSettings?: string[]) { - mockInstanceSettingsApi.getExperimental.mockResolvedValue(defaultExperimentalSettings()); + async function renderPage( + hiddenSettings?: string[], + settings: InstanceExperimentalSettingsWithManaged = defaultExperimentalSettings(), + ) { + mockInstanceSettingsApi.getExperimental.mockResolvedValue(settings); root = createRoot(container); queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.health, { @@ -1030,6 +1046,41 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { expect(container.textContent).not.toContain("Show the Apps navigation"); }); + it("keeps only permitted controls in alphabetical order, including when hidden features are enabled", async () => { + setWorktreeRuntimeMeta(true); + const visible = new Set(["enableExternalObjects", "enableMcpAggregators", "enableSimplifiedEnglishInteractions"]); + await renderPage( + INSTANCE_FEATURE_KEYS.filter((key) => !visible.has(key)).map((key) => `instance.experimental.${key}`), + { + ...defaultExperimentalSettings(), + enableIsolatedWorkspaces: true, + enableIsolatedWorkspacesByDefault: true, + enablePaperclipDeveloperMode: true, + managedKeys: { + enableIsolatedWorkspacesByDefault: { managed: true, managedBy: "paperclip-cloud" }, + }, + }, + ); + + expect([...container.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([ + "Enable External Objects", + "MCP aggregators", + "Simplified English Interactions", + ]); + expect([...container.querySelectorAll("section h2")].map((heading) => heading.textContent)).toEqual([ + "Experimental features", + ]); + expect(container.querySelectorAll('button[role="switch"]')).toHaveLength(3); + expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); + }); + + it("retains a section when one of its controls is visible", async () => { + const visible = new Set(["enablePaperclipDeveloperMode", "enableGoalsSidebarLink"]); + await renderPage(INSTANCE_FEATURE_KEYS.filter((key) => !visible.has(key)).map((key) => `instance.experimental.${key}`)); + expect(container.querySelector('[aria-labelledby="developer-mode-heading"] h3')?.textContent).toBe("Paperclip Developer Mode"); + expect(container.querySelector('[aria-labelledby="legacy-heading"] h3')?.textContent).toBe("Goals Sidebar Link"); + }); + it("shows every toggle when nothing is hidden", async () => { await renderPage(); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 45b8eb019c9..b334c783d54 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -121,6 +121,7 @@ function ExperimentalToggleCard({ export function InstanceExperimentalSettings() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); + const { hidden: hiddenSettings } = useHiddenSettings(); const [actionError, setActionError] = useState(null); useEffect(() => { @@ -237,6 +238,17 @@ export function InstanceExperimentalSettings() { experimentalQuery.data?.enableFirstTaskPlanProposal === true; const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; + const isVisible = (key: InstanceFeatureKey) => !hiddenSettings.has(experimentalSettingKey(key)); + const showWorktreeRunExecution = inWorktree && isVisible("enableWorktreeRunExecution"); + const showDeveloperSection = showWorktreeRunExecution || ([ + "autoRestartDevServerWhenIdle", + "enableManagedSandboxOnly", + "enablePaperclipDeveloperMode", + "enableServerInfoDebugView", + "enableSmokeLab", + "enableIssuePlanDecompositions", + ] satisfies InstanceFeatureKey[]).some(isVisible); + const showLegacySection = isVisible("enableClassicTaskInterface") || isVisible("enableGoalsSidebarLink"); return (
@@ -281,6 +293,18 @@ export function InstanceExperimentalSettings() {

+ toggleMutation.mutate({ enableAgentChat: checked })} + disabled={toggleMutation.isPending} + settingKey="enableAgentChat" + managed={managedKeys.enableAgentChat} + ariaLabel="Toggle agent chat experimental setting" + /> + - toggleMutation.mutate({ enableAgentChat: checked })} - disabled={toggleMutation.isPending} - settingKey="enableAgentChat" - managed={managedKeys.enableAgentChat} - ariaLabel="Toggle agent chat experimental setting" - /> - toggleMutation.mutate({ enableMcpAggregators: checked })} - disabled={toggleMutation.isPending} - settingKey="enableMcpAggregators" - managed={managedKeys.enableMcpAggregators} - ariaLabel="Toggle MCP aggregators experimental setting" - /> - {SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? ( - {enableIsolatedWorkspaces && ( - - toggleMutation.mutate({ enableIsolatedWorkspacesByDefault: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableIsolatedWorkspacesByDefault" - managed={managedKeys.enableIsolatedWorkspacesByDefault} - ariaLabel="Toggle isolated workspaces by default experimental setting" - /> - )} - + + toggleMutation.mutate({ enableFirstTaskPlanProposal: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableFirstTaskPlanProposal" + managed={managedKeys.enableFirstTaskPlanProposal} + ariaLabel="Toggle first task plan proposal experimental setting" + /> + + toggleMutation.mutate({ enableMcpAggregators: checked })} + disabled={toggleMutation.isPending} + settingKey="enableMcpAggregators" + managed={managedKeys.enableMcpAggregators} + ariaLabel="Toggle MCP aggregators experimental setting" + /> + - - toggleMutation.mutate({ enableFirstTaskPlanProposal: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableFirstTaskPlanProposal" - managed={managedKeys.enableFirstTaskPlanProposal} - ariaLabel="Toggle first task plan proposal experimental setting" - /> - + {enableIsolatedWorkspaces && ( + + toggleMutation.mutate({ enableIsolatedWorkspacesByDefault: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableIsolatedWorkspacesByDefault" + managed={managedKeys.enableIsolatedWorkspacesByDefault} + ariaLabel="Toggle isolated workspaces by default experimental setting" + /> + )} -
-
-

- Paperclip Developer Mode -

-

- Internal tools for developing, testing, and debugging Paperclip. -

-
+ {showDeveloperSection ? ( +
+
+

+ Paperclip Developer Mode +

+

+ Internal tools for developing, testing, and debugging Paperclip. +

+
- - toggleMutation.mutate({ enablePaperclipDeveloperMode: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enablePaperclipDeveloperMode" - managed={managedKeys.enablePaperclipDeveloperMode} - ariaLabel="Toggle Paperclip developer mode experimental setting" - /> + + toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked }) + } + disabled={toggleMutation.isPending} + settingKey="autoRestartDevServerWhenIdle" + managed={managedKeys.autoRestartDevServerWhenIdle} + ariaLabel="Toggle guarded dev-server auto-restart" + /> - - toggleMutation.mutate({ enableManagedSandboxOnly: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableManagedSandboxOnly" - managed={managedKeys.enableManagedSandboxOnly} - ariaLabel="Toggle managed environment only experimental setting" - /> + + toggleMutation.mutate({ enableManagedSandboxOnly: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableManagedSandboxOnly" + managed={managedKeys.enableManagedSandboxOnly} + ariaLabel="Toggle managed environment only experimental setting" + /> - {inWorktree ? ( - -
-
-
-
-

Run tasks in this worktree

- {worktreeRunExecutionManaged ? : null} -
-

- This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs - here. Only tasks created after enabling will run automatically — copied/pre-existing tasks stay - parked. Toggling off and on resets the cutoff. -

-
- { - if (worktreeRunExecutionManaged) return; - toggleMutation.mutate({ enableWorktreeRunExecution: checked }); - }} - disabled={toggleMutation.isPending || worktreeRunExecutionManaged} - aria-label="Toggle worktree run execution setting" - /> -
+ + toggleMutation.mutate({ enablePaperclipDeveloperMode: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enablePaperclipDeveloperMode" + managed={managedKeys.enablePaperclipDeveloperMode} + ariaLabel="Toggle Paperclip developer mode experimental setting" + /> - {worktreeRunExecutionState.kind === "armed" ? ( -
- - - Running tasks created after{" "} - - {formatActivationTimestamp(worktreeRunExecutionState.activatedAt)} - - . - -
- ) : null} - - {worktreeRunExecutionState.kind === "fail_closed" ? ( -
- -
-

Execution is suppressed — effectively off.

-

- {worktreeRunExecutionState.reason === "instance_mismatch" - ? "This setting was armed in a different instance and copied here, so no tasks run automatically." - : "This setting is missing its activation cutoff, so no tasks run automatically."}{" "} - Toggle it off and back on to arm execution for tasks created here. + {showWorktreeRunExecution ? ( + +

+
+
+
+

Run tasks in this worktree

+ {worktreeRunExecutionManaged ? : null} +
+

+ This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs + here. Only tasks created after enabling will run automatically — copied/pre-existing tasks stay + parked. Toggling off and on resets the cutoff.

+ { + if (worktreeRunExecutionManaged) return; + toggleMutation.mutate({ enableWorktreeRunExecution: checked }); + }} + disabled={toggleMutation.isPending || worktreeRunExecutionManaged} + aria-label="Toggle worktree run execution setting" + />
- ) : null} -
- - ) : null} - - toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked }) - } - disabled={toggleMutation.isPending} - settingKey="autoRestartDevServerWhenIdle" - managed={managedKeys.autoRestartDevServerWhenIdle} - ariaLabel="Toggle guarded dev-server auto-restart" - /> + {worktreeRunExecutionState.kind === "armed" ? ( +
+ + + Running tasks created after{" "} + + {formatActivationTimestamp(worktreeRunExecutionState.activatedAt)} + + . + +
+ ) : null} - - toggleMutation.mutate({ enableServerInfoDebugView: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableServerInfoDebugView" - managed={managedKeys.enableServerInfoDebugView} - ariaLabel="Toggle server info debug view experimental setting" - /> + {worktreeRunExecutionState.kind === "fail_closed" ? ( +
+ +
+

Execution is suppressed — effectively off.

+

+ {worktreeRunExecutionState.reason === "instance_mismatch" + ? "This setting was armed in a different instance and copied here, so no tasks run automatically." + : "This setting is missing its activation cutoff, so no tasks run automatically."}{" "} + Toggle it off and back on to arm execution for tasks created here. +

+
+
+ ) : null} +
+ + ) : null} - toggleMutation.mutate({ enableSmokeLab: checked })} - disabled={toggleMutation.isPending} - settingKey="enableSmokeLab" - managed={managedKeys.enableSmokeLab} - ariaLabel="Toggle smoke lab experimental setting" - /> + + toggleMutation.mutate({ enableServerInfoDebugView: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableServerInfoDebugView" + managed={managedKeys.enableServerInfoDebugView} + ariaLabel="Toggle server info debug view experimental setting" + /> - - toggleMutation.mutate({ enableIssuePlanDecompositions: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableIssuePlanDecompositions" - managed={managedKeys.enableIssuePlanDecompositions} - ariaLabel="Toggle task plan decomposition panel experimental setting" - /> -
+ toggleMutation.mutate({ enableSmokeLab: checked })} + disabled={toggleMutation.isPending} + settingKey="enableSmokeLab" + managed={managedKeys.enableSmokeLab} + ariaLabel="Toggle smoke lab experimental setting" + /> -
-
-

- Legacy -

-

These features are going to be removed.

-
+ + toggleMutation.mutate({ enableIssuePlanDecompositions: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableIssuePlanDecompositions" + managed={managedKeys.enableIssuePlanDecompositions} + ariaLabel="Toggle task plan decomposition panel experimental setting" + /> +
+ ) : null} - - toggleMutation.mutate({ enableClassicTaskInterface: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableClassicTaskInterface" - managed={managedKeys.enableClassicTaskInterface} - ariaLabel="Toggle classic task interface experimental setting" - /> + {showLegacySection ? ( +
+
+

+ Legacy +

+

These features are going to be removed.

+
- - toggleMutation.mutate({ enableGoalsSidebarLink: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableGoalsSidebarLink" - managed={managedKeys.enableGoalsSidebarLink} - ariaLabel="Toggle goals sidebar link experimental setting" - /> -
+ + toggleMutation.mutate({ enableClassicTaskInterface: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableClassicTaskInterface" + managed={managedKeys.enableClassicTaskInterface} + ariaLabel="Toggle classic task interface experimental setting" + /> + + + toggleMutation.mutate({ enableGoalsSidebarLink: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableGoalsSidebarLink" + managed={managedKeys.enableGoalsSidebarLink} + ariaLabel="Toggle goals sidebar link experimental setting" + /> +
+ ) : null}
); } From db8f8fe5b73a2697684a30261b0d306a9c631aba Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:43:21 -0700 Subject: [PATCH 05/16] fix(evals): select Grok subscription protocol credentials explicitly (#13901) ## Thinking Path > - Paperclip manages agent work through shared runner contracts. > - Direct protocol evals qualify provider behavior against a mock control plane. > - Grok supports API keys and company subscription credentials. > - The hosted protocol workflow selected an API key for every Grok cell. > - Product subscription support did not enable subscription protocol runs. > - This change adds explicit subscription selection and checks the recorded authentication mode. ## Linked Issues or Issue Description Refs #13878, #13882, #12618. The direct Grok protocol roster cannot run with subscription authentication through the trusted default-branch workflow. Add an explicit selector while keeping API-key dispatches compatible. Keep the actor allowlist, protected environment, immutable source revisions, and publication gates. ## What Changed - Add `grok_authentication` with `api_key` and `subscription` choices. Keep `api_key` as the compatibility default. - Deliver the protected `GROK_AUTH_JSON` secret only to a subscription-selected Grok cell. Do not provide an API key to that cell. - Read authentication mode from the pinned eval program's actual roster summary. Retain it in the cell, catalog, campaign roster, and result. - Reject missing or mismatched authentication evidence during aggregation. Preserve cell metadata and an allowlisted failure reason before failing a cell, so malformed evidence cannot hide the retained attempt. - Document credential setup, source separation, and temporary-secret cleanup. ## Verification - `node --test packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs packages/paperclip-runner/scripts/runner-protocol-eval-workflow-security.test.mjs scripts/__tests__/release-verify-workflow.test.mjs`: 34 tests passed. - Validated all 39 Grok cells at eval revision `3213dbec7e8ca1865ea95e6db7e7d34b095eb47a`; every selected cell requests only the subscription credential. Validation made zero provider calls. - Negative coverage rejects invalid selectors and missing or API authentication evidence in an otherwise passing subscription attempt. - `git diff --check` passed. All 53 current-head checks passed; the unchanged callback-drain timing test passed its bounded rerun, and the failed attempt is retained. Greptile reviewed `ecd3dcc0e998f07cf56fcb1f087946f50f388bec` at 5/5 with no remaining findings. - No Docker or broad builds ran on the developer machine. CI performs repository checks on the configured fleet. ## Risks Grok runs require an eval revision that records `authenticationMode` in the roster summary. Missing evidence fails closed. The credential contains account access and refresh tokens; an owner must approve its delivery to the protected environment before a live run. The change adds no PR trigger or authorization bypass. Live subscription protocol qualification remains pending this workflow reaching master. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The exact serving model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../workflows/runner-protocol-live-evals.yml | 30 ++++++++- .../docs/runner-protocol-live-evals.md | 14 ++++ .../scripts/runner-protocol-eval-campaign.mjs | 35 ++++++++-- .../runner-protocol-eval-campaign.test.mjs | 65 +++++++++++++++++++ ...r-protocol-eval-workflow-security.test.mjs | 57 +++++++++++++++- .../release-verify-workflow.test.mjs | 5 +- 6 files changed, 198 insertions(+), 8 deletions(-) diff --git a/.github/workflows/runner-protocol-live-evals.yml b/.github/workflows/runner-protocol-live-evals.yml index 73763827db6..fb063185a47 100644 --- a/.github/workflows/runner-protocol-live-evals.yml +++ b/.github/workflows/runner-protocol-live-evals.yml @@ -22,6 +22,14 @@ on: description: "Optional lower concurrency (at least 2, no higher than the configured campaign limit)" type: string required: false + grok_authentication: + description: "Explicit Grok credential source; subscription uses protected GROK_AUTH_JSON" + type: choice + options: + - api_key + - subscription + default: api_key + required: false max_infrastructure_retries: description: "Automatic retries only for explicitly retryable infrastructure failures (0-3)" type: number @@ -256,6 +264,7 @@ jobs: MAX_PARALLEL_LIMIT: ${{ needs.authorize.outputs.max_parallel_limit }} REQUESTED_MAX_PARALLEL: ${{ inputs.max_parallel }} ROSTERS: ${{ inputs.rosters || 'all' }} + GROK_AUTHENTICATION: ${{ inputs.grok_authentication || 'api_key' }} run: | set -euo pipefail if ! [[ "$MAX_PARALLEL" =~ ^[1-9][0-9]*$ ]] || [ "$MAX_PARALLEL" -lt 2 ] || [ "$MAX_PARALLEL" -gt "$MAX_PARALLEL_LIMIT" ]; then @@ -272,6 +281,7 @@ jobs: node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs catalog \ --evals-root .paperclip-evals \ --rosters "$ROSTERS" \ + --grok-authentication "$GROK_AUTHENTICATION" \ --campaign-id "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ --max-parallel "$MAX_PARALLEL" \ --output runner-protocol-eval-catalog.json @@ -542,6 +552,7 @@ jobs: ANTHROPIC_API_KEY: ${{ matrix.credentialName == 'ANTHROPIC_API_KEY' && secrets.ANTHROPIC_API_KEY || '' }} OPENROUTER_API_KEY: ${{ matrix.credentialName == 'OPENROUTER_API_KEY' && secrets.OPENROUTER_API_KEY || '' }} XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }} + PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }} PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID }} PAPERCLIP_CLAUDE_MANAGED_AGENT_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_ID }} PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION }} @@ -582,19 +593,36 @@ jobs: --runner-package runner-protocol-build/extracted/paperclip-runner.tgz \ --runnerd runner-protocol-build/extracted/paperclip-runnerd \ --runs-root cell-output/runs \ + --summary-path cell-output/roster-summary.json \ --max-infrastructure-retries "$MAX_INFRASTRUCTURE_RETRIES" \ --run-id "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${CELL_ID}" status=$? set -e CELL_EXIT_CODE="$status" node --input-type=module <<'NODE' - import { writeFileSync } from "node:fs"; + import { readFileSync, writeFileSync } from "node:fs"; + let authenticationMode; + let authenticationEvidenceFailure; + if (["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"].includes(process.env.CREDENTIAL_NAME)) { + const expected = process.env.CREDENTIAL_NAME === "XAI_API_KEY" ? "api_key" : "subscription"; + try { + const observed = JSON.parse(readFileSync("cell-output/roster-summary.json", "utf8")).authenticationMode; + // Never copy arbitrary provider output into cell metadata or errors. + if (["api_key", "subscription"].includes(observed)) authenticationMode = observed; + if (authenticationMode !== expected) authenticationEvidenceFailure = "grok_authentication_evidence_mismatch"; + } catch { + authenticationEvidenceFailure = "grok_authentication_evidence_unreadable"; + } + } writeFileSync("cell-output/cell.json", `${JSON.stringify({ schema: "paperclip.runner-protocol-eval.cell/v1", cellId: process.env.CELL_ID, rosterFile: process.env.ROSTER_FILE, caseId: process.env.CASE_ID, + ...(authenticationMode ? { authenticationMode } : {}), + ...(authenticationEvidenceFailure ? { authenticationEvidenceFailure } : {}), exitCode: Number(process.env.CELL_EXIT_CODE), }, null, 2)}\n`, { mode: 0o600 }); + if (authenticationEvidenceFailure) throw new Error(authenticationEvidenceFailure); NODE exit "$status" diff --git a/packages/paperclip-runner/docs/runner-protocol-live-evals.md b/packages/paperclip-runner/docs/runner-protocol-live-evals.md index 5480b531099..36b36fbded7 100644 --- a/packages/paperclip-runner/docs/runner-protocol-live-evals.md +++ b/packages/paperclip-runner/docs/runner-protocol-live-evals.md @@ -119,6 +119,20 @@ from the default branch and provide: marked disabled; - `max_infrastructure_retries`: zero through three, applied only when an attempt explicitly reports a retryable infrastructure failure. +- `grok_authentication`: `api_key` (the compatibility default) or `subscription`. + Subscription requires an explicitly selected Grok roster and the owner-approved + `GROK_AUTH_JSON` secret in the protected `runner-e2e-paid` environment. API mode + uses only `XAI_API_KEY`. A missing selected credential fails; it never changes + authentication mode or falls back to another credential. + +For subscription qualification, pass `-f grok_authentication=subscription` when +dispatching the default-branch workflow. The immutable catalog, retained cell, +campaign roster, and result record carry `authenticationMode`. The cell reads it +from the pinned eval program's actual roster summary, and aggregation rejects +missing or mismatched authentication evidence. Select an eval revision that +supports Grok subscription admission and records this summary field. Keep API +and subscription campaigns separate when interpreting results. Remove temporary +subscription test secrets after the authorized qualification completes. The authorization job resolves the Paperclip branch to a commit and verifies the supplied eval commit before any checkout. A short-lived bot token generated diff --git a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs index 3c0a50faf8c..2ea5c4f7cb2 100644 --- a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs +++ b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs @@ -54,7 +54,14 @@ function inside(root, candidate, label) { return resolve(candidate); } -export function credentialForConfig(config) { +function validateGrokAuthenticationMode(mode) { + if (mode !== "api_key" && mode !== "subscription") { + throw new Error("Grok authentication mode must be api_key or subscription"); + } + return mode; +} + +export function credentialForConfig(config, grokAuthenticationMode = "api_key") { if (config.provider === "opencode") return "OPENROUTER_API_KEY"; if (config.provider === "claude_managed") return "ANTHROPIC_API_KEY"; if (config.provider === "aws_agentcore") return "AWS_AGENTCORE_OIDC"; @@ -64,7 +71,11 @@ export function credentialForConfig(config) { if (config.provider === "acpx") { if (config.acpxAgent === "pi") return "OPENROUTER_API_KEY"; if (config.acpxAgent === "claude") return "ANTHROPIC_API_KEY"; - if (config.acpxAgent === "grok") return "XAI_API_KEY"; + if (config.acpxAgent === "grok") { + return validateGrokAuthenticationMode(grokAuthenticationMode) === "subscription" + ? "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET" + : "XAI_API_KEY"; + } if (config.acpxAgent === "codex") return "OPENAI_API_KEY"; } throw new Error( @@ -117,8 +128,10 @@ export async function buildProtocolEvalCatalog({ campaignId, source = {}, maxParallel = 100, + grokAuthenticationMode = "api_key", }) { safeId(campaignId, "campaign ID"); + validateGrokAuthenticationMode(grokAuthenticationMode); if ( !Number.isSafeInteger(maxParallel) || maxParallel < 2 || @@ -159,7 +172,7 @@ export async function buildProtocolEvalCatalog({ `Config for ${rosterId}`, ); const config = await loadObject(configPath); - const credentialName = credentialForConfig(config); + const credentialName = credentialForConfig(config, grokAuthenticationMode); const cases = roster.cases.map((caseId) => safeId(caseId, "case ID")); if (new Set(cases).size !== cases.length) { throw new Error(`Live roster ${rosterId} repeats a case`); @@ -172,6 +185,9 @@ export async function buildProtocolEvalCatalog({ provider: String(config.provider ?? "codex"), driver: String(config.driver ?? "codex_app_server"), credentialName, + ...(config.provider === "acpx" && config.acpxAgent === "grok" + ? { authenticationMode: grokAuthenticationMode } + : {}), cases, }); } @@ -185,6 +201,9 @@ export async function buildProtocolEvalCatalog({ } } if (rosters.length === 0) throw new Error("No live rosters were selected"); + if (grokAuthenticationMode === "subscription" && !rosters.some((roster) => roster.authenticationMode === "subscription")) { + throw new Error("Subscription selection requires an explicit Grok roster"); + } const cells = rosters.flatMap((roster) => roster.cases.map((caseId) => ({ @@ -196,6 +215,7 @@ export async function buildProtocolEvalCatalog({ provider: roster.provider, driver: roster.driver, credentialName: roster.credentialName, + ...(roster.authenticationMode ? { authenticationMode: roster.authenticationMode } : {}), })), ); const shards = [[], []]; @@ -209,7 +229,7 @@ export async function buildProtocolEvalCatalog({ schema: "paperclip.runner-protocol-eval.catalog/v1", campaignId, source, - selection: { kind: requested === null ? "maintained_full" : "subset", rosters: rosterSelection }, + selection: { kind: requested === null ? "maintained_full" : "subset", rosters: rosterSelection, grokAuthenticationMode }, rosters, cells, matrices: shards.map((include) => ({ include })), @@ -399,6 +419,10 @@ export async function aggregateProtocolEvalCampaign({ ) { throw new Error(`Downloaded cell metadata drifted for ${cellId}`); } + if (expected.authenticationMode !== undefined && + (status.authenticationMode !== expected.authenticationMode || status.authenticationEvidenceFailure !== undefined)) { + throw new Error(`Invalid Grok authentication evidence for ${cellId}; retained cell metadata and attempts require inspection`); + } const attemptRoot = resolve(dirname(statusPath), "runs"); const attemptIds = []; const attemptMetadata = await lstat(attemptRoot).catch(() => null); @@ -473,6 +497,7 @@ export async function aggregateProtocolEvalCampaign({ model: cell.model, provider: cell.provider, driver: cell.driver, + ...(cell.authenticationMode ? { authenticationMode: cell.authenticationMode } : {}), attemptIds, finalAttemptId, disposition: score.disposition, @@ -513,6 +538,7 @@ export async function aggregateProtocolEvalCampaign({ model: roster.model, provider: roster.provider, driver: roster.driver, + ...(roster.authenticationMode ? { authenticationMode: roster.authenticationMode } : {}), selected: roster.cases.length, passed: results.filter( (result) => result.rosterId === roster.rosterId && result.passed, @@ -699,6 +725,7 @@ async function main() { rosterSelection: argument(args, "--rosters", "all"), campaignId: argument(args, "--campaign-id", `local-${Date.now()}`), maxParallel: Number(argument(args, "--max-parallel", "100")), + grokAuthenticationMode: argument(args, "--grok-authentication", "api_key"), source: { paperclipSha: process.env.PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA ?? null, evalsSha: process.env.PAPERCLIP_PROTOCOL_EVALS_SHA ?? null, diff --git a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs index a97049d4c8d..6814929debd 100644 --- a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs +++ b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs @@ -104,6 +104,71 @@ test("maps every qualified driver to one explicit credential boundary", () => { ); }); +async function grokFixture() { + const value = await fixture(); + value.config = { + schema: "paperclip-runner/eval-config/v1", id: "live-grok", + provider: "acpx", driver: "acpx_runtime", acpxAgent: "grok", model: "grok-4.7", + }; + value.roster.model = value.config.model; + await writeFile(join(value.program, "configs/live-opencode-model.json"), JSON.stringify(value.config)); + await writeFile(join(value.program, "rosters/live-opencode-model.json"), JSON.stringify(value.roster)); + return value; +} + +test("selects exactly one Grok credential and preserves authentication provenance", async () => { + const { root } = await grokFixture(); + for (const [mode, credential] of [ + ["api_key", "XAI_API_KEY"], + ["subscription", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"], + ]) { + const catalog = await buildProtocolEvalCatalog({ evalsRoot: root, campaignId: "gha-42-1", grokAuthenticationMode: mode }); + assert.equal(catalog.selection.grokAuthenticationMode, mode); + assert.equal(catalog.cells[0].credentialName, credential); + assert.equal(catalog.cells[0].authenticationMode, mode); + assert.equal(catalog.rosters[0].authenticationMode, mode); + assert.equal(catalog.matrices[0].include[0].credentialName, credential); + } + for (const mode of ["", "auto", "subscription,api_key", null]) { + await assert.rejects(buildProtocolEvalCatalog({ evalsRoot: root, campaignId: "gha-42-1", grokAuthenticationMode: mode }), /authentication mode/); + } + const other = await fixture(); + await assert.rejects(buildProtocolEvalCatalog({ evalsRoot: other.root, campaignId: "gha-42-1", grokAuthenticationMode: "subscription" }), /requires an explicit Grok roster/); +}); + +test("subscription evidence cannot be substituted with an API or missing authentication record", async () => { + const { root, config, evalCase } = await grokFixture(); + const catalog = await buildProtocolEvalCatalog({ evalsRoot: root, campaignId: "gha-42-1", grokAuthenticationMode: "subscription" }); + const catalogPath = join(root, "catalog.json"); + await writeFile(catalogPath, JSON.stringify(catalog)); + const cell = catalog.cells[0]; + const download = join(root, "downloads/cell"); + const attemptId = "grok-attempt-01"; + const attempt = join(download, "runs", attemptId); + await mkdir(attempt, { recursive: true }); + for (const [file, value] of Object.entries({ + "artifact.json": { attemptId, usage: null }, + "score.json": { attemptId, caseId: evalCase.id, passed: true, disposition: "passed" }, + "case.json": evalCase, "config.json": config, + })) await writeFile(join(attempt, file), JSON.stringify(value)); + const aggregate = () => aggregateProtocolEvalCampaign({ + catalogPath, downloadsRoot: join(root, "downloads"), evalsRoot: root, + runsOut: join(root, "merged"), campaignOut: join(root, "campaign.json"), source: {}, + }); + const status = { cellId: cell.cellId, rosterFile: cell.rosterFile, caseId: cell.caseId, exitCode: 0 }; + await writeFile(join(download, "cell.json"), JSON.stringify({ ...status, authenticationMode: "subscription" })); + const passed = await aggregate(); + assert.equal(passed.totals.passed, 1); + assert.equal(passed.results[0].authenticationMode, "subscription"); + assert.equal(passed.rosters[0].authenticationMode, "subscription"); + await writeFile(join(download, "cell.json"), JSON.stringify({ ...status, authenticationMode: "subscription", authenticationEvidenceFailure: "grok_authentication_evidence_unreadable" })); + await assert.rejects(aggregate(), /Invalid Grok authentication evidence/); + for (const authenticationMode of [undefined, "api_key", "auto"]) { + await writeFile(join(download, "cell.json"), JSON.stringify({ ...status, authenticationMode })); + await assert.rejects(aggregate(), /Invalid Grok authentication evidence/); + } +}); + test("catalogs roster plus case cells and emits bounded balanced shards", async () => { const { root } = await fixture(); const catalog = await buildProtocolEvalCatalog({ diff --git a/packages/paperclip-runner/scripts/runner-protocol-eval-workflow-security.test.mjs b/packages/paperclip-runner/scripts/runner-protocol-eval-workflow-security.test.mjs index 2d45293ef8c..210b7954b53 100644 --- a/packages/paperclip-runner/scripts/runner-protocol-eval-workflow-security.test.mjs +++ b/packages/paperclip-runner/scripts/runner-protocol-eval-workflow-security.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; import test from "node:test"; @@ -13,6 +15,21 @@ const trustedPrWorkflowPath = resolve( ".github/workflows/pr-trusted.yml", ); +test("Grok subscription credentials require explicit catalog selection and observed auth evidence", async () => { + const workflow = await readFile(workflowPath, "utf8"); + const paid = workflow.slice(workflow.indexOf(" steps: &direct_eval_steps"), workflow.indexOf(" eval_shard_1:")); + assert.match(workflow, /grok_authentication:\n[\s\S]*?type: choice\n[\s\S]*?default: api_key/u); + assert.match(workflow, /--grok-authentication "\$GROK_AUTHENTICATION"/u); + assert.ok(paid.includes("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}")); + assert.ok(paid.includes("XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}")); + assert.match(paid, /--summary-path cell-output\/roster-summary\.json/u); + assert.match(paid, /JSON\.parse\(readFileSync\("cell-output\/roster-summary\.json", "utf8"\)\)\.authenticationMode/u); + assert.match(paid, /authenticationMode !== expected/u); + for (const job of [workflow.slice(0, workflow.indexOf(" eval_shard_0:")), workflow.slice(workflow.indexOf(" report:"))]) { + assert.doesNotMatch(job, /secrets\.GROK_AUTH_JSON/u); + } +}); + test("direct live eval workflow keeps paid execution behind stable actor authorization", async () => { const workflow = await readFile(workflowPath, "utf8"); assert.match(workflow, /^\s{2}authorize:/mu); @@ -228,3 +245,41 @@ test("trusted catalog, direct eval, and report orchestration stay on workflow re assert.doesNotMatch(section, /ref: \$\{\{ needs\.authorize\.outputs\.target_sha \}\}/u, `${job} must not execute target orchestration code`); } }); + + +test("authentication failures retain cell metadata without leaking malformed summary content", async () => { + const workflow = await readFile(workflowPath, "utf8"); + const script = workflow.match(/CELL_EXIT_CODE="\$status" node --input-type=module <<'NODE'\n([\s\S]*?) NODE/u)?.[1]; + assert.ok(script); + const root = await mkdtemp(resolve(tmpdir(), "grok-cell-evidence-")); + try { + await mkdir(resolve(root, "cell-output")); + const summary = resolve(root, "cell-output/roster-summary.json"); + for (const [content, expectedFailure, expectedMode] of [ + [undefined, "grok_authentication_evidence_unreadable", undefined], + ["{SENSITIVE_SENTINEL", "grok_authentication_evidence_unreadable", undefined], + ["null", "grok_authentication_evidence_unreadable", undefined], + ['{"authenticationMode":"SENSITIVE_SENTINEL"}', "grok_authentication_evidence_mismatch", undefined], + ['{"authenticationMode":"api_key"}', "grok_authentication_evidence_mismatch", "api_key"], + ['{"authenticationMode":"subscription"}', undefined, "subscription"], + ]) { + if (content === undefined) await rm(summary, { force: true }); + else await writeFile(summary, content); + const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], { + cwd: root, encoding: "utf8", + env: { CREDENTIAL_NAME: "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET", CELL_ID: "cell-1", CASE_ID: "context", ROSTER_FILE: "grok.json", CELL_EXIT_CODE: "7" }, + }); + assert.equal(result.status, expectedFailure ? 1 : 0); + const retained = await readFile(resolve(root, "cell-output/cell.json"), "utf8"); + const metadata = JSON.parse(retained); + assert.equal(metadata.cellId, "cell-1"); + assert.equal(metadata.caseId, "context"); + assert.equal(metadata.exitCode, 7); + assert.equal(metadata.authenticationEvidenceFailure, expectedFailure); + assert.equal(metadata.authenticationMode, expectedMode); + assert.doesNotMatch(retained + result.stdout + result.stderr, /SENSITIVE_SENTINEL/u); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 9ca66885e12..2712c8214c1 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -433,12 +433,13 @@ test("Runner eval workflows pin actions and gate paid live execution", () => { }); -test("direct Grok qualification installs the pinned binary and scopes its API key", () => { +test("direct Grok qualification installs the pinned binary and scopes the selected credential", () => { const workflow = readWorkflow("runner-protocol-live-evals.yml"); assert.ok(workflow.includes("XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}")); assert.ok(workflow.includes("if [ -f packages/grok-acp/install.mjs ]; then")); assert.ok(workflow.indexOf("node packages/grok-acp/install.mjs") < workflow.indexOf("pnpm --filter @paperclipai/paperclip-runner deploy --prod")); - assert.ok(!workflow.includes("secrets.GROK_AUTH_JSON")); + assert.ok(workflow.includes("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}")); + assert.equal((workflow.match(/secrets\.GROK_AUTH_JSON/gu) ?? []).length, 1); }); test("direct protocol concurrency override only lowers the configured ceiling", () => { From 8ee8f1fd6e9cb1c43f6dba3d080b750d5e07e8a1 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 19:35:19 -0700 Subject: [PATCH 06/16] ci: retire recurring public cloud image builds (#13827) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Core publishes standard images and source verification for downstream services. > - Managed services can now compose private images from the signed standard image. > - Core still builds a second public cloud image on every master push and release. > - That duplicate producer consumes build capacity and retains an obsolete readiness contract. > - This pull request retires recurring cloud publication while preserving the standard producer and rollback artifacts. ## Linked Issues or Issue Description Refs #13797 and #13789. Related: #12856 changes image dependency packaging; it does not retire this producer. **What existing behavior does this improve?** Core's recurring Docker publication and Cloud readiness workflow. **Current behavior** Master pushes call the legacy cloud publisher from Cloud readiness. Release tags and manual Docker runs call it too. Canary promotion also requires the legacy image. **Proposed behavior** Publish standard Core images and retain `Cloud source verified v1`. Let downstream services build their managed image. Keep explicit commit previews and existing images available. ## What Changed - Remove `docker-cloud.yml`, its master and release callers, and its unused cache selector. - Remove the legacy image/migrator wait and `Cloud deployable v1` job. Keep the full source verification workflow and exact source-proof name. - Make canary promotion inspect and promote the standard image only. - Preserve signed standard-image publication, direct migrator publication, and explicit `release.yml` previews. The preview path still uses the Dockerfile `cloud` target. - Update workflow, preview, build-stamp, and packaging tests. Exercise the promotion shell with mocked registry commands, including missing-image and missing-tag cases. - Document frozen legacy aliases, consumer requirements, preview compatibility, and rollback retention. ## Verification - All 377 workflow tests pass: `node --test .github/scripts/tests/*.test.mjs`. - All 129 release-registry tests pass: `pnpm test:release-registry`. - Focused source-proof, standard-image, preview, and workflow tests pass: 256 tests. - Focused image packaging/build-stamp tests pass: 16 tests. - Actionlint passes on all three changed workflow files. `git diff --check` passes. - Full local `pnpm build` and `pnpm -r typecheck` pass. - The policy follow-up updates an old assertion that required the removed readiness job. All 37 source-proof/release-workflow tests pass locally. - Full local `pnpm test:run` did not complete successfully while the Mac ran out of disk space. No full-suite pass is claimed. Removed 1.2 GiB of generated Cargo output from this isolated worktree with `cargo clean`. GitHub CI passed on the final head: 52 successful checks and 2 optional skips. - Fresh Greptile review for `4f5fe1951f0bd7f7739cf6655d395ff78f1ed944`: **5/5**, successful current-head check, zero review threads. - September 23 refresh: the unchanged PR head merges cleanly with current master `db8f8fe5b73a2697684a30261b0d306a9c631aba`. In an isolated temporary worktree, all 377 workflow tests and 29 release/preview tests pass on the combined tree. `git diff --cached --check` passes. - Refreshed Actionlint workflow validation passes with ShellCheck disabled. Full Actionlint reports the same 10 existing ShellCheck diagnostics as master, with no added diagnostics. No source changes or new PR commits were needed. - The full local build/typecheck and current-head Linux CI results above remain the verification for the unchanged PR head. They were not rerun for this metadata-only refresh. No image publication or tenant deployment was initiated for this refresh. ## Risks **Deployment prerequisite satisfied (September 23):** The combined cleanup release is deployed to staging and production, and production Support is verified. Active managed-fleet automation uses standard-image composition. Explicit immutable previews remain supported by the retained preview publisher. This PR is ready for maintainer review; keep auto-merge disabled and wait for explicit merge authorization. - A consumer still selecting `Cloud deployable v1` will stop advancing at the last legacy-ready commit. Confirm active automatic consumers use the standard-image composition contract before merge. - Legacy cloud release-channel aliases stop advancing. Standard self-hosted aliases continue. - This PR deletes no registry images, cache tags, migrators, credentials, or runner infrastructure. Existing immutable releases remain usable for rollback. - Explicit legacy previews remain for commit-specific operator deployments. Retiring that compatibility path requires a separate consumer migration. - These changes affect CI publication, not database schema or application behavior. ## Model Used OpenAI Codex, GPT-6. The runtime does not expose a more specific model identifier or context-window size. Used repository inspection, reasoning, code editing, shell tools, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../scripts/tests/cloud-readiness.test.mjs | 155 ++-------- .../tests/cloud-runner-routing.test.mjs | 35 --- .../tests/docker-canary-promotion.test.mjs | 56 ++++ .../tests/docker-disk-workflow.test.mjs | 65 ---- .../tests/lockfile-refresh-workflows.test.mjs | 1 - .../tests/post-merge-runner-routing.test.mjs | 4 - .github/workflows/cloud-readiness.yml | 50 --- .github/workflows/docker-cloud.yml | 290 ------------------ .github/workflows/docker.yml | 24 +- .github/workflows/release.yml | 2 +- doc/DOCKER.md | 49 ++- doc/RELEASE-CHECKLIST.md | 4 +- doc/cloud-build-readiness.md | 149 ++++----- doc/preview-release-artifacts.md | 45 ++- package.json | 2 +- .../release-verify-workflow.test.mjs | 4 +- scripts/cloud-readiness.mjs | 102 ------ scripts/preview-artifacts.test.mjs | 134 -------- scripts/select-cloud-cache.mjs | 66 ---- scripts/select-cloud-cache.test.mjs | 65 ---- .../cloud-image-bundled-plugins.test.ts | 56 +--- .../src/__tests__/cloud-image-sentry.test.ts | 13 +- .../src/__tests__/docker-build-stamp.test.ts | 13 +- 23 files changed, 197 insertions(+), 1187 deletions(-) delete mode 100644 .github/scripts/tests/cloud-runner-routing.test.mjs create mode 100644 .github/scripts/tests/docker-canary-promotion.test.mjs delete mode 100644 .github/scripts/tests/docker-disk-workflow.test.mjs delete mode 100644 .github/workflows/docker-cloud.yml delete mode 100644 scripts/cloud-readiness.mjs delete mode 100644 scripts/select-cloud-cache.mjs delete mode 100644 scripts/select-cloud-cache.test.mjs diff --git a/.github/scripts/tests/cloud-readiness.test.mjs b/.github/scripts/tests/cloud-readiness.test.mjs index da4dd06bb6f..c8137f7dd6d 100644 --- a/.github/scripts/tests/cloud-readiness.test.mjs +++ b/.github/scripts/tests/cloud-readiness.test.mjs @@ -1,144 +1,31 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; -import { gzipSync } from "node:zlib"; -import { waitForCloudArtifacts, verifyManifestProvenance, migratorPublished } from "../../../scripts/cloud-readiness.mjs"; -import { artifactBase, descriptor } from "../../../scripts/cloud-migrator-artifacts.mjs"; -import { previewManifest } from "../../../scripts/preview-artifacts.mjs"; -const sha = "a".repeat(40); -const version = `0.0.0-preview.g${sha}`; -const digest = `sha256:${"b".repeat(64)}`; -const json = (body, status = 200) => new Response(JSON.stringify(body), { status }); -const producer = { id: 123, head_sha: sha, head_branch: "master", path: ".github/workflows/cloud-migrator-artifacts.yml", - head_repository: { id: 1170821064, full_name: "paperclipai/paperclip" }, event: "push", status: "completed", conclusion: "success" }; -function bundle() { - const packages = {}; const files = new Map(); - const entries = { "": { dependencies: { "@paperclipai/db": version } } }; - for (const name of ["db", "shared"]) { - const metadata = previewManifest({ name: `@paperclipai/${name}`, dependencies: {} }, sha); - const bytes = Buffer.from(JSON.stringify(metadata)); - const header = Buffer.alloc(512); header.write("package/package.json"); header.write(bytes.length.toString(8).padStart(11, "0"), 124, 11); header[156] = 48; - const padded = Buffer.alloc(Math.ceil(bytes.length / 512) * 512); bytes.copy(padded); - const archive = gzipSync(Buffer.concat([header, padded, Buffer.alloc(1024)])); - const pin = descriptor(archive, "tgz"); packages[name] = pin; files.set(pin.url, archive); - entries[`node_modules/@paperclipai/${name}`] = { version, resolved: pin.url, integrity: pin.integrity, dependencies: metadata.dependencies }; - } - const lock = Buffer.from(JSON.stringify({ lockfileVersion: 3, packages: entries })); - const manifest = { version: 1, sourceSha: sha, packageVersion: version, packages, lockfile: descriptor(lock, "json") }; - files.set(manifest.lockfile.url, lock); - const bytes = Buffer.from(JSON.stringify(manifest) + "\n"); - files.set(`${artifactBase}/${sha}/manifest.json`, bytes); - return { manifest, bytes, files }; -} -function registry({ missing = new Set(), failure, wrongImage = false, run = producer, objects = bundle() } = {}) { - return async (url, options) => { - assert.ok(!url.startsWith("https://registry.npmjs.org/"), "readiness must never wait for npm"); - if (failure) return json({}, failure); - if (url.startsWith("https://api.github.com/")) { - assert.match(url, new RegExp(`head_sha=${sha}&per_page=100&page=1$`)); - return json({ total_count: missing.has("migrator") ? 0 : 1, workflow_runs: missing.has("migrator") ? [] : [run] }); - } - if (url.startsWith(artifactBase)) { - assert.equal(options.headers?.Authorization, undefined, "GitHub credentials stay off the artifact origin"); - return objects.files.has(url) ? new Response(objects.files.get(url)) : json({}, 403); - } - if (url.includes("/token?")) return json({ token: "fixture" }); - if (url.includes("/manifests/")) return missing.has("image") ? json({}, 404) : json({ config: { digest } }); - if (url.includes("/blobs/")) return json({ config: { Labels: { "org.opencontainers.image.revision": wrongImage ? "c".repeat(40) : sha } } }); - throw new Error(`Unexpected request: ${url}`); - }; -} -const noSignature = async () => {}; // Signature enforcement is exercised separately below. +const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8"); -test("readiness rechecks image and publisher, then verifies the exact signed bundle with no npm requests", async () => { - const missing = new Set(["image", "migrator"]); const objects = bundle(); let clock = 0; let signatures = 0; - const result = await waitForCloudArtifacts(sha, { - fetchImpl: registry({ missing, objects }), token: "fixture", now: () => clock, intervalMs: 10, timeoutMs: 100, log: () => {}, - verifyProvenance: async (bytes, source) => { assert.deepEqual(bytes, objects.bytes); assert.equal(source, sha); signatures++; }, - sleep: async (ms) => { - clock += ms; - if (clock === 10) missing.delete("image"); - if (clock === 20) { missing.delete("migrator"); missing.add("image"); } - if (clock === 30) missing.delete("image"); - }, - }); - assert.equal(clock, 30); assert.equal(signatures, 1); - assert.deepEqual(result, { version: 1, sha, packageVersion: version }); -}); - -test("missing or in-progress publishers time out with a precise inventory and bounded sleep", async () => { - for (const fixture of [{ missing: new Set(["migrator"]) }, { run: { ...producer, status: "in_progress", conclusion: null } }]) { - let clock = 0; const sleeps = []; - await assert.rejects(waitForCloudArtifacts(sha, { - fetchImpl: registry(fixture), now: () => clock, timeoutMs: 25, intervalMs: 20, log: () => {}, verifyProvenance: noSignature, - sleep: async (ms) => { sleeps.push(ms); clock += ms; }, - }), /timed out.*missing: migrator/); - assert.deepEqual(sleeps, [20, 5]); - } -}); - -for (const fixture of [{ failure: 403 }, { failure: 503 }, { wrongImage: true }, - ...["failure", "cancelled", "skipped"].map((conclusion) => ({ run: { ...producer, conclusion } })), - ...[{ head_sha: "b".repeat(40) }, { head_branch: "feature" }, { path: ".github/workflows/evil.yml" }, - { head_repository: { id: 123, full_name: "someone/paperclip" } }, { event: "pull_request" }].map((wrong) => ({ run: { ...producer, ...wrong } }))]) { - test(`upstream errors, failed publication and identity mismatches fail immediately: ${JSON.stringify(fixture)}`, async () => { - await assert.rejects(waitForCloudArtifacts(sha, { fetchImpl: registry(fixture), verifyProvenance: noSignature, - sleep: async () => assert.fail("must not retry an invalid artifact or upstream error"), log: () => {} })); - }); -} - -test("successful publication cannot hide inaccessible or corrupt archives or an invalid signature", async () => { - for (const corrupt of [false, true]) { - const objects = bundle(); - if (corrupt) objects.files.set(objects.manifest.packages.db.url, Buffer.from("corrupt")); - else objects.files.delete(objects.manifest.packages.db.url); - await assert.rejects(waitForCloudArtifacts(sha, { fetchImpl: registry({ objects }), verifyProvenance: noSignature, log: () => {} }), /download failed|immutable pin/); - } - await assert.rejects(waitForCloudArtifacts(sha, { fetchImpl: registry(), verifyProvenance: async () => { throw new Error("invalid signature"); }, log: () => {} }), /invalid signature/); -}); - -test("CLI verifies the exact bytes, source, master workflow and hosted runner and cleans up on failure", () => { - let temporary; - assert.throws(() => verifyManifestProvenance(Buffer.from("exact manifest\n"), sha, { exec: (cmd, args) => { - assert.equal(cmd, "gh"); assert.deepEqual(args.slice(0, 2), ["attestation", "verify"]); temporary = args[2]; - assert.equal(readFileSync(temporary, "utf8"), "exact manifest\n"); - for (const [flag, value] of [["--repo", "paperclipai/paperclip"], ["--source-digest", sha], ["--source-ref", "refs/heads/master"], - ["--cert-identity", "https://github.com/paperclipai/paperclip/.github/workflows/cloud-migrator-artifacts.yml@refs/heads/master"]]) assert.equal(args[args.indexOf(flag) + 1], value); - assert.ok(args.includes("--deny-self-hosted-runners")); throw new Error("verification rejected"); - } }), /verification rejected/); - assert.equal(existsSync(temporary), false); -}); - -test("invalid source and timing configuration are rejected before registry access", async () => { - const fetchImpl = async () => assert.fail("invalid inputs must not reach a registry"); - await assert.rejects(waitForCloudArtifacts("master", { fetchImpl }), /full immutable commit SHA/); - for (const options of [{ timeoutMs: 0 }, { intervalMs: -1 }, { timeoutMs: Infinity }]) await assert.rejects(waitForCloudArtifacts(sha, { ...options, fetchImpl }), /positive finite/); -}); - -test("versioned readiness retains every source gate and removes duplicate automatic npm publication", () => { - const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8"); +test("retirement preserves exact-source verification without issuing legacy deployment readiness", () => { assert.match(workflow, /push:\s*\n\s*branches: \[master\]/); assert.match(workflow, /uses: \.\/\.github\/workflows\/release-verify.yml\s+with:\s+ref: \$\{\{ github.sha \}\}/); - assert.match(workflow, /uses: \.\/\.github\/workflows\/docker-cloud.yml/); - assert.match(workflow, /attestations: read/); assert.match(workflow, /GH_TOKEN: \$\{\{ github.token \}\}/); - const ready = workflow.split(" ready:")[1]; - assert.match(ready, /name: Cloud deployable v1/); assert.match(ready, /needs: \[verify, image, artifacts\]/); - assert.match(ready, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/); - assert.doesNotMatch(ready, /^\s*(?:if:.*always\(|continue-on-error:)/m); - assert.doesNotMatch(workflow, /secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/); - assert.equal(existsSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url)), false); + const proof = workflow.split(" source_verified:")[1]; + assert.ok(proof); + assert.match(proof, /name: Cloud source verified v1/); + assert.match(proof, /needs: \[verify\]/); + assert.match(proof, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/); + assert.doesNotMatch(proof, /^\s*(?:if:.*always\(|continue-on-error:)/m); + assert.doesNotMatch(workflow, /Cloud deployable v1|docker-cloud.yml|cloud-readiness.mjs|^ (image|artifacts|ready):/m); + assert.doesNotMatch(workflow, /packages: write|secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/); + assert.equal(existsSync(new URL("../../workflows/docker-cloud.yml", import.meta.url)), false); }); - -test("later manual failures or pending retries cannot hide an earlier successful immutable publication", async () => { - for (const latest of [{ status: "completed", conclusion: "failure" }, { status: "in_progress", conclusion: null }]) { - let calls = 0; - assert.equal(await migratorPublished(sha, async (url) => { - calls++; - if (url.endsWith("page=1")) return json({ total_count: 101, workflow_runs: Array.from({ length: 100 }, (_, i) => ({ ...producer, ...latest, id: 200 + i, event: "workflow_dispatch" })) }); - assert.ok(url.endsWith("page=2")); return json({ total_count: 101, workflow_runs: [producer] }); - }), true); - assert.equal(calls, 2); - } +test("standard image provenance and independent exact-source migrators remain available", () => { + const docker = readFileSync(new URL("../../workflows/docker.yml", import.meta.url), "utf8"); + assert.match(docker, /target: production/); + assert.match(docker, /type=raw,value=sha-\$\{\{ github.sha \}\}/); + assert.match(docker, /run: node scripts\/standard-image-contract.mjs --resolve "\$GITHUB_SHA"/); + assert.match(docker, /subject-digest: \$\{\{ steps.standard.outputs.digest \}\}/); + const migrator = readFileSync(new URL("../../workflows/cloud-migrator-artifacts.yml", import.meta.url), "utf8"); + assert.match(migrator, /push:\s*\n\s*branches: \[master\]/); + assert.match(migrator, /uses: actions\/attest@/); + assert.doesNotMatch(docker, /build-and-push-cloud|docker-cloud.yml|canary-cloud/); }); diff --git a/.github/scripts/tests/cloud-runner-routing.test.mjs b/.github/scripts/tests/cloud-runner-routing.test.mjs deleted file mode 100644 index 3fd8cd3bf0a..00000000000 --- a/.github/scripts/tests/cloud-runner-routing.test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { runInNewContext } from "node:vm"; - -const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8"); -// Exercise the workflow's actual boolean expression. Its string comparisons and -// boolean operators have the same results in JS for these canonical contexts. -const expression = workflow.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1]; -assert.ok(expression, "cloud routing must remain an explicit job expression"); -const timeoutExpression = workflow.match(/^ timeout-minutes: \$\{\{ (.+) \}\}$/m)?.[1]; -assert.ok(timeoutExpression, "AWS jobs must finish before the Fleet instance lifetime"); -const fleet = "runs-on/fleet=paperclip-cloud-build-x64/env=public-ci"; -const base = { repository: "paperclipai/paperclip", repository_id: "1170821064", ref: "refs/heads/master", event_name: "push" }; -for (const { name, github = {}, enabled = "true", expected = "ubuntu-latest" } of [ - { name: "canonical master push", expected: fleet }, - { name: "manual master build", github: { event_name: "workflow_dispatch" }, expected: fleet }, - { name: "disabled switch", enabled: "false" }, - { name: "missing switch", enabled: "" }, - { name: "invalid switch", enabled: "yes" }, - { name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } }, - { name: "wrong repository identity", github: { repository_id: "123" } }, - { name: "pull request", github: { event_name: "pull_request", ref: "refs/pull/123/merge" } }, - { name: "privileged PR event", github: { event_name: "pull_request_target" } }, - { name: "release tag", github: { ref: "refs/tags/v2026.911.0" } }, - { name: "branch push", github: { ref: "refs/heads/feature" } }, - { name: "manual branch build", github: { event_name: "workflow_dispatch", ref: "refs/heads/feature" } }, - { name: "workflow completion event", github: { event_name: "workflow_run" } }, -]) { - test(`cloud runner routing: ${name}`, () => { - const context = { github: { ...base, ...github }, vars: { AWS_CLOUD_BUILDS_ENABLED: enabled } }; - assert.equal(runInNewContext(expression, context), expected); - assert.equal(runInNewContext(timeoutExpression, context), expected === fleet ? 40 : 60); - }); -} diff --git a/.github/scripts/tests/docker-canary-promotion.test.mjs b/.github/scripts/tests/docker-canary-promotion.test.mjs new file mode 100644 index 00000000000..a9b0727465f --- /dev/null +++ b/.github/scripts/tests/docker-canary-promotion.test.mjs @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const workflow = readFileSync(new URL("../../workflows/docker.yml", import.meta.url), "utf8"); +const job = workflow.split(" promote_canary_channel:\n")[1]; +const script = job.split(" run: |\n")[1].split("\n").map(line => line.replace(/^ {10}/, "")).join("\n"); +const sha = "a".repeat(40); + +test("canary promotion waits for the standard manifest and keeps its serialized channel", () => { + assert.match(job, /needs: \[merge-and-push\]/); + assert.match(job, /group: docker-canary-channel-promotion/); + assert.match(job, /cancel-in-progress: false/); +}); + +for (const [name, commitPresent, imagePresent] of [ + ["promotes the current npm canary without a cloud image", true, true], + ["waits when the standard image is missing", true, false], + ["waits when the npm canary tag has not resolved", false, false], +]) { + test(name, () => { + const dir = mkdtempSync(path.join(tmpdir(), "standard-canary-promotion-")); + const log = path.join(dir, "calls.jsonl"); + const fixture = `#!${process.execPath} +const fs = require("node:fs"); +const command = require("node:path").basename(process.argv[1]); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.TEST_CALLS, JSON.stringify({ command, args }) + "\\n"); +if (command === "curl") process.stdout.write(JSON.stringify({ canary: "2026.922.0-canary.1" })); +else if (command === "gh") { if (process.env.COMMIT_PRESENT !== "true") process.exit(1); process.stdout.write(process.env.TEST_SHA); } +else if (args.slice(0, 3).join(" ") === "buildx imagetools inspect") process.exit(process.env.IMAGE_PRESENT === "true" ? 0 : 1); +else if (args.slice(0, 3).join(" ") !== "buildx imagetools create") process.exit(99); +`; + try { + for (const command of ["curl", "gh", "docker"]) writeFileSync(path.join(dir, command), fixture, { mode: 0o755 }); + const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], { + encoding: "utf8", env: { + ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, + IMAGE: "ghcr.io/paperclipai/paperclip", GITHUB_REPOSITORY: "paperclipai/paperclip", + TEST_CALLS: log, TEST_SHA: sha, COMMIT_PRESENT: String(commitPresent), IMAGE_PRESENT: String(imagePresent), + }, + }); + assert.equal(result.status, 0, result.stderr); + const calls = readFileSync(log, "utf8").trim().split("\n").map(line => JSON.parse(line)); + assert.ok(calls.find(call => call.command === "gh").args.some(arg => arg.includes("canary%2Fv2026.922.0-canary.1"))); + const docker = calls.filter(call => call.command === "docker").map(call => call.args); + assert.deepEqual(docker, [ + ...(commitPresent ? [["buildx", "imagetools", "inspect", "ghcr.io/paperclipai/paperclip:sha-aaaaaaa"]] : []), + ...(commitPresent && imagePresent ? [["buildx", "imagetools", "create", "-t", "ghcr.io/paperclipai/paperclip:canary", "ghcr.io/paperclipai/paperclip:sha-aaaaaaa"]] : []), + ]); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +} diff --git a/.github/scripts/tests/docker-disk-workflow.test.mjs b/.github/scripts/tests/docker-disk-workflow.test.mjs deleted file mode 100644 index 414af38cfdb..00000000000 --- a/.github/scripts/tests/docker-disk-workflow.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; - -const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8"); -const step = workflow.split(" - name: Free runner disk")[1].split(" - name: Login to GitHub Container Registry")[0]; -const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n"); -const threshold = 64 * 1024 * 1024; - -for (const { name, dockerFree, workspaceFree, dfStatus = "0", infoStatus = "0", cleanup } of [ - { name: "ample free space", dockerFree: threshold + 1, workspaceFree: threshold + 1, cleanup: false }, - { name: "exactly the headroom threshold", dockerFree: threshold, workspaceFree: threshold, cleanup: false }, - { name: "Docker filesystem below threshold", dockerFree: threshold - 1, workspaceFree: threshold + 1, cleanup: true }, - { name: "workspace filesystem below threshold", dockerFree: threshold + 1, workspaceFree: threshold - 1, cleanup: true }, - { name: "invalid Docker measurement", dockerFree: "unknown", workspaceFree: threshold + 1, cleanup: true }, - { name: "invalid workspace measurement", dockerFree: threshold + 1, workspaceFree: "unknown", cleanup: true }, - { name: "failed df command", dockerFree: threshold + 1, workspaceFree: threshold + 1, dfStatus: "1", cleanup: true }, - { name: "failed Docker inspection", dockerFree: threshold + 1, workspaceFree: threshold + 1, infoStatus: "1", cleanup: true }, -]) { - test(`cloud disk cleanup: ${name}`, () => { - const dir = mkdtempSync(path.join(tmpdir(), "cloud-disk-test-")); - const log = path.join(dir, "commands.log"); - // Every mutating command is a recording fixture; no real SDKs, caches, - // images, or directories are deleted when the workflow shell executes. - const fixture = `#!/bin/bash -printf '%s %s\\n' "\${0##*/}" "$*" >> "$COMMAND_LOG" -case "\${0##*/}" in - df) - printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n' - if [ "$1" = '-Pk' ]; then - printf '/dev/docker 200000000 1 %s 1%% /docker\\n' "$DOCKER_FREE" - printf '/dev/workspace 200000000 1 %s 1%% /workspace\\n' "$WORKSPACE_FREE" - exit "$DF_STATUS" - fi - ;; - docker) - if [ "$1" = 'info' ]; then - printf '/docker-data\\n' - exit "$INFO_STATUS" - fi - ;; -esac -`; - try { - for (const command of ["df", "docker", "pnpm", "sudo"]) { - writeFileSync(path.join(dir, command), fixture, { mode: 0o755 }); - } - const result = spawnSync("bash", ["-c", script], { - encoding: "utf8", - env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_WORKSPACE: "/workspace", COMMAND_LOG: log, - DOCKER_FREE: String(dockerFree), WORKSPACE_FREE: String(workspaceFree), DF_STATUS: dfStatus, INFO_STATUS: infoStatus }, - }); - assert.equal(result.status, 0, result.stderr); - const commands = readFileSync(log, "utf8"); - if (infoStatus === "0") assert.match(commands, /df -Pk \/docker-data \/workspace/); - assert.equal(commands.includes("pnpm store prune"), cleanup); - assert.equal(commands.includes("sudo rm -rf /usr/share/dotnet"), cleanup); - assert.equal(commands.includes("docker system prune -af"), cleanup); - assert.equal(result.stdout.includes("skipping cleanup"), !cleanup); - } finally { rmSync(dir, { recursive: true, force: true }); } - }); -} diff --git a/.github/scripts/tests/lockfile-refresh-workflows.test.mjs b/.github/scripts/tests/lockfile-refresh-workflows.test.mjs index e0e9775c04b..7c19cf2cb9d 100644 --- a/.github/scripts/tests/lockfile-refresh-workflows.test.mjs +++ b/.github/scripts/tests/lockfile-refresh-workflows.test.mjs @@ -6,7 +6,6 @@ const workflows = [ '.github/workflows/refresh-lockfile.yml', '.github/workflows/pr-trusted.yml', '.github/workflows/docker.yml', - '.github/workflows/docker-cloud.yml', ]; test('lockfile repair workflows resolve dependencies instead of updating metadata only', async () => { diff --git a/.github/scripts/tests/post-merge-runner-routing.test.mjs b/.github/scripts/tests/post-merge-runner-routing.test.mjs index b0adab147b0..d31d315c042 100644 --- a/.github/scripts/tests/post-merge-runner-routing.test.mjs +++ b/.github/scripts/tests/post-merge-runner-routing.test.mjs @@ -87,9 +87,7 @@ test("Cloud readiness bookkeeping never waits for the AWS verification fleet", ( const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8"); const bodies = new Map(); for (const [name, needs] of [ - ["artifacts", null], ["source_verified", "[verify]"], - ["ready", "[verify, image, artifacts]"], ]) { const body = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z_]+:|(?![\\s\\S]))`, "m"))?.[1]; assert.ok(body, `missing ${name} job`); @@ -100,8 +98,6 @@ test("Cloud readiness bookkeeping never waits for the AWS verification fleet", ( assert.match(body, /^ +SOURCE_SHA: \$\{\{ github.sha \}\}$/m); assert.equal(body.match(/^ needs: (.+)$/m)?.[1] ?? null, needs, `${name} prerequisites`); } - assert.match(bodies.get("artifacts"), /^ run: node scripts\/cloud-readiness.mjs "\$SOURCE_SHA"$/m); assert.match(bodies.get("source_verified"), /^ run: node --test scripts\/cloud-source-verification.test.mjs$/m); assert.match(bodies.get("source_verified"), /echo "Cloud source verified v1: \$SOURCE_SHA"/); - assert.match(bodies.get("ready"), /echo "Cloud deployable v1: \$SOURCE_SHA"/); }); diff --git a/.github/workflows/cloud-readiness.yml b/.github/workflows/cloud-readiness.yml index 0da006cb45a..6c709ade0ed 100644 --- a/.github/workflows/cloud-readiness.yml +++ b/.github/workflows/cloud-readiness.yml @@ -14,13 +14,6 @@ concurrency: cancel-in-progress: false jobs: - image: - if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master' - permissions: - contents: read - packages: write - uses: ./.github/workflows/docker-cloud.yml - verify: if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master' permissions: @@ -29,29 +22,6 @@ jobs: with: ref: ${{ github.sha }} - artifacts: - if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master' - name: Wait for exact-source cloud artifacts - # Bookkeeping must not wait for the AWS builders it observes. - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: read - actions: read - attestations: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 24 - - name: Wait for verified image and exact-source migrator - env: - GH_TOKEN: ${{ github.token }} - SOURCE_SHA: ${{ github.sha }} - run: node scripts/cloud-readiness.mjs "$SOURCE_SHA" - source_verified: # npm canary publication reuses this exact-source verification proof. # Keep it independent of image/migrator availability, and fail closed when @@ -79,23 +49,3 @@ jobs: SOURCE_SHA: ${{ github.sha }} run: | echo "Cloud source verified v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY" - - ready: - # Versioned consumer contract. Never add always() or continue-on-error: - # failed, cancelled, or skipped prerequisites must not report readiness. - name: Cloud deployable v1 - needs: [verify, image, artifacts] - if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master' - # Bookkeeping must not wait for the AWS builders it observes. - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Record cloud readiness - env: - SOURCE_SHA: ${{ github.sha }} - run: | - { - echo "Cloud deployable v1: $SOURCE_SHA" - echo "Source verification passed; the full-SHA image and exact-source migrator are available." - echo "Deployment tooling must still resolve and pin the image and migrator and validate migration compatibility." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml deleted file mode 100644 index 81e608c9277..00000000000 --- a/.github/workflows/docker-cloud.yml +++ /dev/null @@ -1,290 +0,0 @@ -name: Docker cloud - -on: - workflow_dispatch: - workflow_call: - -permissions: {} - -# Independent SHAs can build immediately on separate runners. -# Repeated requests for the same source serialize without cancelling a build. -# No mutable canary channel is promoted here; docker.yml owns that operation. -concurrency: - group: docker-cloud-${{ github.sha }} - cancel-in-progress: false - -jobs: - build-and-push-cloud: - # Only canonical master builds can consume the release Fleet. The runner - # group must also allow this workflow only at refs/heads/master. - # Keep an operator switch for a full-run retry on GitHub-hosted runners. - runs-on: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-cloud-build-x64/env=public-ci' || 'ubuntu-latest' }} - # Fleet instances expire after 45 minutes, including bootstrap and cleanup. - timeout-minutes: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 40 || 60 }} - permissions: - contents: read - packages: write - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # Full history and tags so `git describe` below can compute the - # release version to stamp into the image. - fetch-depth: 0 - - # `.git` is dockerignored, so a running image cannot derive its own - # version and otherwise reports the source package.json placeholder in - # analytics and the debug panel. Compute it here from the pristine - # checkout (real CalVer drift from the nearest release tag) and pass it - # into the build. Empty when no release tag is reachable — the server - # then keeps its existing fallbacks. - - name: Compute build version - id: build-version - run: | - set -euo pipefail - case "${GITHUB_REF}" in - refs/tags/nightly/v*) - # Lane tags carry the exact published version; stamp it verbatim - # instead of describing drift from the nearest stable tag. - version="${GITHUB_REF#refs/tags/nightly/v}" - ;; - refs/tags/beta/v*) - version="${GITHUB_REF#refs/tags/beta/v}" - ;; - *) - version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" - ;; - esac - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "Stamping build version: ${version:-}" - - # ISO week stamp for the Dockerfile's tool layer: the layer caches - # across commits and re-pulls the @latest CLI tools when the week rolls - # over, instead of on every build. - - name: Compute tool cache epoch - id: tools-epoch - run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" - - - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - with: - version: 9.15.4 - run_install: false - - # No dependency cache here: this workflow publishes release images, and - # restoring a shared Actions cache into the build inputs would let a - # poisoned cache entry reach the published artifact. - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 24 - - - name: Refresh lockfile for Docker build context - run: | - set -euo pipefail - pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile - - changed="$(git status --porcelain)" - if [ -z "$changed" ]; then - echo "Lockfile already matches package metadata." - exit 0 - fi - - if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then - echo "Unexpected files changed during lockfile refresh:" - echo "$changed" - exit 1 - fi - - echo "Using refreshed pnpm-lock.yaml in the Docker build context." - - - name: Free runner disk - run: | - set -euo pipefail - echo "Disk before cleanup:" - df -h - - # A measured hosted cloud build started with 86 GB available. - # Keep ample headroom for BuildKit and image verification, but - # avoid minutes deleting SDKs when neither filesystem needs space. - minimum_free_kib=$((64 * 1024 * 1024)) - if docker_root="$(docker info --format '{{.DockerRootDir}}')" \ - && available_kib="$(df -Pk "$docker_root" "$GITHUB_WORKSPACE" | awk 'NR > 1 { rows++; if ($4 !~ /^[0-9]+$/) invalid = 1; if (min == "" || $4 < min) min = $4 } END { if (invalid || rows != 2) exit 1; print min }')" \ - && [[ "$available_kib" =~ ^[0-9]+$ ]] \ - && (( available_kib >= minimum_free_kib )); then - echo "At least 64 GiB is available for Docker and the workspace; skipping cleanup." - exit 0 - fi - - pnpm store prune || true - sudo apt-get clean || true - sudo rm -rf \ - /usr/share/dotnet \ - /usr/share/swift \ - /usr/local/lib/android \ - /usr/local/share/boost \ - /usr/local/share/powershell \ - /opt/ghc \ - /opt/hostedtoolcache/CodeQL \ - /opt/hostedtoolcache/PyPy \ - /opt/hostedtoolcache/Ruby || true - docker system prune -af || true - - echo "Disk after cleanup:" - df -h - - - name: Login to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - - # Mixing several historical manifests missed otherwise reusable native - # layers on fresh builders. Import the nearest available complete cache. - - name: Select cloud cache ancestry - id: cloud-cache - env: - CACHE_IMAGE: ghcr.io/${{ github.repository }} - run: node scripts/select-cloud-cache.mjs - - # Deployment tooling reads these labels from the registry to verify an - # image's schema expectations against a migrator before deploying it, - # without pulling the image. The server refuses to start when the - # database is missing bundled migrations, so orchestrators need a cheap - # way to check image/migrator compatibility up front. - - name: Compute schema migration labels - id: schema - run: | - set -euo pipefail - last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1) - count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ') - echo "last=${last}" >> "$GITHUB_OUTPUT" - echo "count=${count}" >> "$GITHUB_OUTPUT" - - # Published under the same lane tag set as the self-hosted image, with a - # `-cloud` suffix (nightly-cloud, latest-cloud, -cloud, - # sha--cloud). `:canary-cloud` follows the same retag-step - # ownership rule as `:canary` above. - - name: Docker meta (cloud) - id: meta-cloud - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 - with: - images: ghcr.io/${{ github.repository }} - flavor: | - suffix=-cloud,onlatest=true - tags: | - type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }} - type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=sha - labels: | - io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }} - io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }} - - - name: Build and push (cloud) - id: build-cloud - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 - with: - context: . - target: cloud - # Space-separated sandbox-provider directory names to build into - # the variant; add here when managed deployments need another. - # CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the - # variant installs from server/package.json's declared version; - # add another name there when a managed tenant needs it. - build-args: | - USER_UID=1001 - USER_GID=1001 - CLOUD_BUNDLED_PLUGINS=daytona - CLOUD_BUNDLED_SERVER_DEPS=@sentry/node - PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }} - PAPERCLIP_BUILD_COMMIT=${{ github.sha }} - CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }} - # amd64 only, unlike the self-hosted image above: the cloud variant - # is consumed exclusively by managed-deployment hosts, which run - # amd64. The QEMU-emulated arm64 half dominated this job's wall - # clock, and dropping it roughly halves time-to-deployable-image. - platforms: linux/amd64 - push: true - # Same-SHA builds serialize above; different SHAs never share a - # writable cache ref. Registry layers are content-addressed and - # shared even when cache manifests have separate tags. - cache-from: ${{ steps.cloud-cache.outputs.source }} - cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max - tags: ${{ steps.meta-cloud.outputs.tags }} - labels: ${{ steps.meta-cloud.outputs.labels }} - - # The cloud target installs @sentry/node at the version - # server/package.json declares, into a directory the server's own - # module resolution walks. Verify the image this job just pushed, not - # a local build, so a build-cache or layer-ordering regression is - # caught before any tenant runs the image. - - - name: Verify the pushed image resolves the declared Sentry version - env: - IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} - run: | - set -euo pipefail - - expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")" - test -n "$expected" - - installed="$(docker run --rm --pull always \ - -v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \ - --entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)" - - echo "Declared optional peer version: $expected" - echo "Installed in the pushed image: $installed" - if [ "$installed" != "$expected" ]; then - echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2 - exit 1 - fi - echo "The pushed image resolves the declared @sentry/node version." - - # Managed hosts run node as 1001:1001. Bake that identity into the image - # so usermod does not walk the mounted home on every container start. - # Check before the entrypoint can repair a wrongly built identity. - - name: Verify cloud runtime user - env: - IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} - run: | - set -euo pipefail - docker run --rm --entrypoint sh "$IMAGE" -ec ' - test "$(id -u node)" = 1001 - test "$(id -g node)" = 1001 - test "$USER_UID" = 1001 - test "$USER_GID" = 1001 - ' - docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec ' - test "$(id -u)" = 1001 - test "$(id -g)" = 1001 - test -w "$PAPERCLIP_HOME" - ' - - # Verify the independently published cloud image without waiting for - # the self-hosted manifest job. The Sentry check already pulled it. - - name: Verify cloud PID 1 reaps orphaned processes - env: - IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} - run: docker run --rm -i "$IMAGE" sh -s < scripts/assert-orphan-reaping.sh - - # Cloud's commit resolver and preview-artifact planner use the full SHA. - # Publish that address only after checking this build's exact digest. - # Retagging reuses the registry manifest and does not rebuild the image. - - name: Publish verified full-SHA cloud tag - env: - IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} - FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud - run: | - set -euo pipefail - revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" - platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')" - test "$revision" = "$GITHUB_SHA" - test "$platform" = linux/amd64 - docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6153471c69d..b8a16cac697 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -53,9 +53,8 @@ jobs: # ten master commits sampled that day never produced an image at all. # # Each platform now builds on a runner of its own architecture and pushes by - # digest; `merge` assembles the manifest list. arm64 is kept rather than - # dropped (the cloud variant below dropped it and is amd64-only) because - # this is the self-hosted image, and ARM hosts consume it. + # digest; `merge` assembles the manifest list. Both architectures remain + # available to self-hosted installations and downstream image composers. build-and-push: strategy: # Independent legs: one architecture failing should still publish @@ -353,7 +352,7 @@ jobs: # until the cgroup pid limit is exhausted and every fork() in the # container fails. Run against the pushed manifest rather than a local # build: the legs push by digest, so nothing is loaded into this - # runner's daemon. The independent cloud workflow checks its own image. + # runner's daemon. - name: Verify PID 1 reaps orphaned processes env: # Through the environment, not interpolated into the script body, so @@ -387,17 +386,7 @@ jobs: subject-digest: ${{ steps.standard.outputs.digest }} push-to-registry: true - # Master cloud builds start independently in docker-cloud.yml. Tag builds - # and manual Docker dispatches call the same implementation, preserving the - # release tags and the canary promotion dependency below. - build-and-push-cloud: - if: github.event_name != 'push' || github.ref != 'refs/heads/master' - uses: ./.github/workflows/docker-cloud.yml - permissions: - contents: read - packages: write - - # Moves the mutable `:canary` / `:canary-cloud` channel tags. Kept OUT + # Moves the mutable `:canary` channel tag. Kept OUT # of the build jobs and serialized in its own lane, and — the load- # bearing property — CONVERGENT rather than self-interested: a # promotion does not promote "its own" canary, it retags the channel @@ -418,7 +407,7 @@ jobs: # merge-and-push, not build-and-push: the per-arch legs push untagged # digests, and the production `sha-*` tags this promotion retags only # exist once the manifest merge has named them. - needs: [merge-and-push, build-and-push-cloud] + needs: [merge-and-push] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -447,10 +436,9 @@ jobs: exit 0 fi short="$(printf '%s' "$sha" | cut -c1-7)" - if ! docker buildx imagetools inspect "$IMAGE:sha-${short}-cloud" >/dev/null 2>&1; then + if ! docker buildx imagetools inspect "$IMAGE:sha-${short}" >/dev/null 2>&1; then echo "images for ${current} (sha-${short}) not published yet; its own promotion converges the channel" exit 0 fi docker buildx imagetools create -t "$IMAGE:canary" "$IMAGE:sha-${short}" - docker buildx imagetools create -t "$IMAGE:canary-cloud" "$IMAGE:sha-${short}-cloud" echo "channel tags moved to canary ${current} (sha-${short})" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 878899cb248..f8fdbe42109 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -417,7 +417,7 @@ jobs: # explicitly, exactly like the nightly and beta lanes: the run keys # its concurrency off the tag ref, so no master push can supersede # it, and docker.yml's `type=sha` mapping publishes the - # sha- and sha--cloud images either way. + # standard sha- images either way. - name: Build Docker images for the canary tag env: GH_TOKEN: ${{ github.token }} diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 04f23424b4d..897a8f3cd19 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -32,40 +32,29 @@ docker build -t paperclip-local \ --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) . ``` -## Cloud image addresses +## Standard images and downstream composition -The Docker workflow publishes the managed deployment image for Linux AMD64. -`Cloud readiness` starts `Docker cloud` on each master push independently of the -multi-platform self-hosted build. Different commits use separate concurrency groups and existing -GitHub-hosted runners, so an older production or cloud build does not hold the -new commit in a workflow queue. Available GitHub runner capacity still applies. -Release tags and manual `Docker` dispatches call the same cloud build workflow. +The Docker workflow publishes the standard `production` target for Linux AMD64 +and ARM64. Canonical master pushes also publish +`ghcr.io/paperclipai/paperclip:sha-` and a GitHub/Sigstore attestation +for its immutable multi-platform digest. Downstream services can compose their +own images from this public base without rebuilding Core. -Each commit exports to its own `buildcache-cloud-` registry tag. -Builds import the current commit and nine first-parent ancestors, plus the -legacy `buildcache-cloud` fallback. This preserves reusable layers without -letting concurrent builds overwrite one shared cache manifest. Retain recent -cache tags if registry cleanup is configured; deleting them makes builds colder. +The legacy recurring public `-cloud` publisher is retired. Master pushes, +release tags, and manual `Docker` dispatches no longer build that variant. +Existing `-cloud` tags and digests remain in the registry for rollback; their +release-channel aliases no longer advance. This change deletes no images, +cache tags, or migrator artifacts. -Cloud CI skips SDK and cache cleanup when both the Docker data filesystem and -the checkout filesystem have at least 64 GiB available. Below that conservative -headroom threshold, or when the measurement fails, it retains the existing -cleanup. The threshold selects the fast path; it is not a new minimum disk -requirement for local builds or smaller runners. +The `cloud` Dockerfile target remains available for explicit +[preview builds](preview-release-artifacts.md). Those requests still publish a +full-SHA `-cloud` tag when needed. They do not advance a release channel or +replace downstream private composition. -After the pushed image passes its Sentry and orphan-reaping checks, the workflow verifies its -commit label and platform and adds `ghcr.io/paperclipai/paperclip:sha--cloud`. -This address lets commit-based deployment tooling reuse the normal build. -Existing short-SHA and release tags remain available. - -The full-SHA tag identifies the source commit. It does not certify that source -tests passed or that a compatible database migrator is available. Deployment -tooling must still check those prerequisites and pin the resolved image digest; -a rebuild of the same source can update the tag's digest. - -The separate [cloud readiness check](cloud-build-readiness.md) combines source -verification, successful cloud image checks, and exact-source migrator -availability. It runs outside the full npm release's concurrency queue. +A published image alone does not prove source tests or migration compatibility. +Downstream deployment tooling must verify [source proof](cloud-build-readiness.md), +the standard image attestation, the exact-source migrator, and its own composed +image before rollout. Resolve immutable digests instead of deploying mutable tags. ## One-liner (build + run) diff --git a/doc/RELEASE-CHECKLIST.md b/doc/RELEASE-CHECKLIST.md index e8d3c03245e..a49048e46a6 100644 --- a/doc/RELEASE-CHECKLIST.md +++ b/doc/RELEASE-CHECKLIST.md @@ -19,7 +19,7 @@ The release captain's checklist for every lane. The mechanics live in - [ ] the release smoke suite passed against the exact candidate canary before anything published - [ ] `npm view paperclipai@nightly version` shows the new `-nightly.N` -- [ ] `nightly/v*` tag pushed; `:nightly` and `:nightly-cloud` images built +- [ ] `nightly/v*` tag pushed; `:nightly` image built - [ ] on a tag-push rejection (workflows-permission error), follow the recovery commands in the job summary @@ -34,7 +34,7 @@ Happy path: - [ ] dispatch `release.yml` with `channel: beta` - [ ] approve the `npm-beta` environment gate - [ ] `npm view paperclipai@beta version` shows the new `-beta.N` -- [ ] `beta/v*` tag pushed; `:beta` and `:beta-cloud` images built +- [ ] `beta/v*` tag pushed; `:beta` image built - [ ] post-publish smoke (`smoke_beta`) is green - [ ] `draft_stable_notes` pushed `release-notes/v`; open the notes PR from the job-summary link diff --git a/doc/cloud-build-readiness.md b/doc/cloud-build-readiness.md index 724ec2d33ee..2a08902fd32 100644 --- a/doc/cloud-build-readiness.md +++ b/doc/cloud-build-readiness.md @@ -1,38 +1,25 @@ # Cloud build readiness -The `Cloud readiness` workflow starts for every master push. Its versioned -`Cloud deployable v1` job succeeds only after all three prerequisites succeed: +The `Cloud readiness` workflow starts for every master push and retains the +versioned `Cloud source verified v1` job. It calls the full `Release Verify` +workflow for that exact commit, including typecheck, builds, general and +serialized tests, and Runner verification. The source proof depends on every +source check and fails closed if verification fails, is cancelled, or is skipped. -- The existing `Release Verify` workflow checks that exact commit, including - typecheck, builds, general and serialized tests, and Runner verification. -- The reusable `Docker cloud` workflow builds and verifies its Linux AMD64 - image, including Sentry resolution and orphan reaping, then publishes the - full-SHA cloud tag. Cloud readiness owns the master trigger so there is one - cloud build per push. Release tags and manual Docker runs retain their callers. -- The full-SHA image is visible and the exact-source `Cloud migrator artifacts` - workflow has succeeded. Readiness verifies the manifest's GitHub attestation - against the full SHA, canonical master workflow, and GitHub-hosted runner, - then downloads and validates both package archives and the prepared dependency - lockfile. The database package pins the matching shared package. New-version - npm metadata and tarball propagation are outside this path. +The recurring public `-cloud` publisher and its `Cloud deployable v1` gate are +retired. The workflow no longer builds a legacy image or waits for one. +Keep its filename and source-proof job name stable: npm canary publication and +downstream image composers consume that exact contract. -The Cloud workflow builds the image with `USER_UID=1001` and `USER_GID=1001`, -matching the managed runtime. This avoids a startup user remap, which can walk -the mounted home and delay health checks. Before publishing the full-SHA tag, -the workflow checks the baked identity without running the entrypoint, then -checks the normal entrypoint's effective user and writable home. Volume ownership -repair still runs when needed. The Dockerfile defaults remain `1000:1000` for -self-hosted builds, and runtime identity overrides remain supported. The first -build with the new identity must rebuild layers that depend on the base image; -later builds can reuse those layers. +Standard images still publish independently through `docker.yml`. The +`cloud-migrator-artifacts.yml` workflow still publishes signed exact-source +migrators independently. A downstream composer must verify those artifacts, +build and test its own image, and record separate deployment readiness. -Verification and image building run concurrently, outside the full npm release's -concurrency group. Different commits have independent groups. The npm canary -release reuses `Cloud source verified v1` for the exact master push instead of -starting a second copy of `Release Verify`. This source-only job depends on every -source check but does not wait for Docker or migrator publication. npm canary -publication remains possible when source verification passes and an image build -fails. Stable releases and candidate-branch betas still run full verification. +Verification runs outside the full npm release's concurrency group. Different +commits have independent groups. The npm canary release reuses the source proof +for its exact master push instead of starting another `Release Verify` run. +Stable releases and candidate-branch betas still run full verification. The canary consumer requires the expected workflow ID and path, upstream source repository, master push event, full SHA, and a successful job in the latest run @@ -57,41 +44,36 @@ before that bot's PR merges. Verification must install and test that commit without waiting for another merge. The generated lockfile stays in the job's workspace; these checks do not commit it back to the repository. -The artifact wait runs for up to 30 minutes and reports what is missing. A -missing image or an exact-source publisher with no successful run yet means publication -is pending. An earlier successful push or manual run remains valid after a failed -retry because publication is immutable. If all matching runs failed, readiness -fails. An invalid signature, inaccessible or corrupt -bundle, authorization error, or identity mismatch fails the job. A failed, cancelled, or skipped prerequisite -cannot produce a successful readiness job. Retry the failed publication or build, -then rerun the failed readiness workflow jobs to check the same commit again. +## Consumer contract and retirement boundary -## Consumer contract +Accept `Cloud source verified v1` only from the latest attempt of the canonical +`cloud-readiness.yml` master push for the expected repository identity and full +source SHA. Check the job itself and reject failed, skipped, cancelled, or +ambiguous proof. This signal verifies source only. It creates no release record, +certifies no composed image, and deploys no instance. -`Cloud deployable v1` is a source-and-artifact readiness signal. A deployment -consumer must still resolve and pin the image digest and migrator integrity/lockfile, -validate migration contents and compatibility, and apply its target health gates. -The check creates no release record and deploys no instance. A full-SHA tag by -itself, or a successful migrator dispatch, is not this readiness signal. +Downstream deployment consumers must separately verify the standard image's +immutable digest and attestation, the exact-source migrator's signature and +integrity, migration compatibility, their own image composition, and target +health. Order automatic candidates by master ancestry, not completion time. -For automatic selection, accept only a successful job named exactly -`Cloud deployable v1` in the latest attempt of a successful -`.github/workflows/cloud-readiness.yml` run in `paperclipai/paperclip`, with -event `push`, head branch `master`, and the expected full head SHA and repository. -Do not trust a similarly named check from another workflow or a manual branch run. -Order candidates by master ancestry, not job completion time: an older commit -finishing late must not roll a fleet backward. Fail closed on API errors. +Merge this retirement only after all active automatic deployment consumers use +the standard-image composition contract. A consumer still selecting +`Cloud deployable v1` will stop advancing at the last legacy-ready commit. +Do not rename the source proof to the old readiness name or weaken a consumer +check to hide that dependency. -Cloud consumers must enable `CLOUD_HARNESS_DIRECT_MIGRATOR_ARTIFACTS` before -this gate is adopted: readiness no longer promises preview npm availability. -The automatic npm-only migrator dispatcher has been removed. Manual -`release.yml` runs with `channel=cloud-migrator`, branch previews, and stable -releases retain their npm publisher for legacy consumers and rollback. +Existing release records, immutable image digests, migrator artifacts, and +registry tags are retained for rollback. No registry deletion or live deployment +is part of this change. Explicit `release.yml` preview requests still use the +legacy `cloud` Dockerfile target for a specified source commit. They do not +restart recurring legacy publication. Keep that compatibility path until its +operator consumers migrate separately. -For rollback, restore the npm dispatcher and gate together before disabling the -cloud direct-artifact switch. Already-created releases retain their immutable -archive URLs and lockfiles; keep those objects available. The master producer -can be retried independently without republishing or overwriting a valid bundle. +The old `nightly-cloud`, `beta-cloud`, `latest-cloud`, and `canary-cloud` aliases +stop advancing. Self-hosted standard release aliases continue unchanged. A +rollback to an already published image needs no rebuild; restoring recurring +legacy publication would require reverting the publisher retirement. ## Timing and rollout @@ -107,7 +89,8 @@ exact commit. Keep readiness and deployment as separate milestones: | --- | --- | --- | | Merge | Merged PR timestamp and full merge commit SHA | Merge | | Image available | Successful full-SHA image publication and verification | Merge | -| Cloud deployable | Successful `Cloud deployable v1` job in the accepted push run and attempt | Merge | +| Source verified | Successful `Cloud source verified v1` job in the accepted push run and attempt | Merge | +| Composed image ready | Downstream composition verification and publication succeed | Merge | | Canary healthy | Deployment consumer's canary health gate confirms the target commit | Merge | | Fleet complete | Campaign succeeds for all eligible targets at that commit | Merge | @@ -118,7 +101,7 @@ does not measure automatic merge-to-deploy latency. A preparation-only run resolves artifacts without deploying a target and must not be counted as a successful deployment. -Record queue time and the image, source-verification, and artifact-wait durations +Record queue time and the image, source-verification, migrator, and composition durations separately. The slowest prerequisite determines readiness; shortening an already faster prerequisite may have no effect on the total. After readiness, measure consumer discovery delay, artifact resolution, canary health, and fleet rollout. @@ -132,23 +115,14 @@ excluded or sleeping targets, retries, and failures with the fleet result. Recor runner queue conditions and cache state; one warm or cold run is a sample, not a latency guarantee. -Land full-SHA image publication, independent cloud builds, and migrator-only -publication before enabling this workflow. Until those producers are present, -the artifact wait cannot succeed. A manual dispatch on master can verify the -wiring, but automatic consumers should use push runs. Source verification and -registry checks can be rerun without deploying or changing mutable npm channels. - -When reverting this workflow, restore the master push trigger in -`docker-cloud.yml` in the same change so master images continue to build. - ## Reserved AWS verification capacity -`AWS_POST_MERGE_CI_ENABLED=true` routes cloud source verification, artifact -waiting, readiness signals, and exact-master migrator preparation to the +`AWS_POST_MERGE_CI_ENABLED=true` routes cloud source verification and +exact-master migrator preparation to the `paperclip-post-merge` runner group. The separate Fleet label is `runs-on/fleet=paperclip-post-merge-x64/env=public-ci`. Its 36 reserved slots use the same four-vCPU, 16-GiB machines as approved PR jobs. PR capacity is reduced -to 64; image capacity stays at eight. The total ceiling remains 108 runners. +to 64; the separately provisioned image capacity is unchanged by this retirement. This keeps PR bursts from consuming every post-merge verification slot. Every selector checks the canonical repository name and ID, master ref, and a @@ -172,29 +146,12 @@ Disable the switch and rerun the whole workflow to restore GitHub-hosted placement. Assigned jobs keep their original runners. Readiness requirements, source checks, and npm integrity checks are unchanged. -## AWS cloud build routing - -`AWS_CLOUD_BUILDS_ENABLED=true` routes the Docker cloud job to the -`paperclip-cloud-build-x64` RunsOn Fleet for canonical `paperclipai/paperclip` -master pushes and manual master runs. Forks, pull requests, and release tags -retain GitHub-hosted runners. The separate `AWS_CI_ENABLED` and -`AWS_CI_TRUSTED_USER_IDS` variables control PR routing. - -The cloud Fleet uses a separate runner group, `paperclip-cloud-build`, restricted -to this repository and `.github/workflows/docker-cloud.yml@refs/heads/master`. -Provision that group and Fleet before enabling the variable. The cloud runners -need at least 64 GiB free for Docker and the workspace; the initial configuration -uses 120 GiB disks with the existing 4-vCPU, 16-GiB machine size. AWS jobs have -a 40-minute workflow timeout so they finish before the 45-minute instance -lifetime; GitHub-hosted jobs retain their 60-minute timeout. Keep the registry -cache and all pushed-image verification steps enabled. - -To roll back routing, set `AWS_CLOUD_BUILDS_ENABLED=false`, then rerun the cloud -workflow. Changing the variable does not migrate an already assigned job. -Check the Actions job's runner name and runner group to verify placement. Record -queue time, image verification completion, and `Cloud deployable v1` separately; -source verification and the migrator still run on GitHub-hosted runners. +## Retired AWS cloud build routing +`AWS_CLOUD_BUILDS_ENABLED` and the `paperclip-cloud-build` runner group no longer +route a public image job after this retirement. This source change does not +delete runner groups, Fleets, credentials, registry images, or cache tags. Review +shared infrastructure ownership separately before removing those resources. ### Typecheck Rust dependency cache diff --git a/doc/preview-release-artifacts.md b/doc/preview-release-artifacts.md index af8b0373139..7ac129bcdfd 100644 --- a/doc/preview-release-artifacts.md +++ b/doc/preview-release-artifacts.md @@ -21,6 +21,9 @@ definitions that do not run from `master`. ## Outputs and reuse The image uses `ghcr.io/paperclipai/paperclip:sha--cloud`. +This explicit operator path is retained after retirement of the recurring public +`-cloud` publisher. Existing images remain reusable; missing images still build +the `cloud` Dockerfile target. It is separate from private image composition. Full-SHA tags keep separate commits with the same short prefix isolated. Normal release images retain their existing short-tag convention. Build arguments carry the full commit SHA. Preview builds do not import or overwrite the shared release cache or release @@ -51,26 +54,18 @@ version 1, request ID, SHA, stage `build`, and status `ready`. It expires after ### Migrator publication on merge -The `Cloud artifacts` workflow starts a `cloud-migrator` dispatch of `release.yml` -for every push to `master`. This dispatch builds and publishes only the exact-source -`@paperclipai/shared` and `@paperclipai/db` preview packages. It starts independently -of the full npm release and does not wait for the Docker image. The normal Docker -workflow supplies the image separately. +`cloud-migrator-artifacts.yml` publishes a signed exact-source DB/shared bundle +on each canonical master push, independently of image publication and npm. +See [Direct cloud migrator artifacts](#direct-cloud-migrator-artifacts) below. +Downstream deployment tooling must verify source, image, and migrator separately. -The run title is `Cloud migrator `. A successful `Cloud artifacts` -dispatch job only confirms that GitHub accepted the request. Inspect the matching -`release.yml` run to confirm publication completed. This path does not produce a -`stack-deploy-result` or certify source-test success or deployment readiness. -Cloud must still verify all deployment prerequisites. - -To retry one commit, dispatch `release.yml` on `master` with `channel=cloud-migrator`, -the full SHA as `source_ref`, a new UUID v4 as `request_id`, and `dry_run=false`. -`preview_migrator` is not required for this channel. Existing packages are verified -and reused. Preview and migrator-only runs use separate workflow concurrency -groups. Only their package publication jobs share a group for the same SHA, so -they cannot publish the same version concurrently and the migrator does not wait -for a preview's image build. Different SHAs publish in separate groups; the full -release keeps its existing group. +The manual npm compatibility path remains available: dispatch `release.yml` on +`master` with `channel=cloud-migrator`, the full SHA as `source_ref`, a new UUID v4 +as `request_id`, and `dry_run=false`. `preview_migrator` is not required for this +channel. Existing packages are verified and reused. Preview and migrator-only +runs use separate workflow concurrency groups. Only their npm publication jobs +share a group for the same SHA. This prevents duplicate publication without +making the migrator wait for a preview image. Different SHAs remain independent. ### Publisher identity @@ -170,12 +165,12 @@ The deploy policies are checked in under `.github/cloud-migrator-deploy/`: There is no lifecycle expiry on this prefix. Keep referenced artifacts for rollback; deleting them can prevent a fresh migrator install for an old release. -Cloud readiness consumes the signed direct bundle after its exact-source -publisher succeeds. It retains source verification, image identity, and the -cloud runner's integrity and migration compatibility checks. The cloud direct -artifact switch must be enabled before adopting this gate. Automatic npm-only -migrator dispatch is removed; explicit npm previews and manual migrator runs -remain available. See `doc/cloud-build-readiness.md` for coordinated rollback. +Downstream deployment consumers verify the signed direct bundle after its +exact-source publisher succeeds. Source verification, image identity, archive +integrity and migration compatibility remain required. The retired public +`Cloud deployable v1` gate no longer waits for this bundle. Explicit npm previews +and manual migrator runs remain available for compatible consumers and rollback. +See `doc/cloud-build-readiness.md` for the source proof and retirement boundary. Local verification: diff --git a/package.json b/package.json index fb4396efecd..23dbea24a23 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "smoke:posthog-live": "node scripts/smoke/posthog-live.mjs", "smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", - "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs scripts/select-cloud-cache.test.mjs", + "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs", "storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs", "test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts", "test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack", diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 2712c8214c1..0ddf46d9cc0 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -63,13 +63,13 @@ test("canary reuses exact-source proof while stable keeps full verification", () test("source proof requires every source check and does not wait on image publication", () => { const readiness = readWorkflow("cloud-readiness.yml"); - const proof = readiness.split(" source_verified:\n")[1].split("\n ready:")[0]; + const proof = readiness.split(" source_verified:\n")[1]; assert.match(proof, /name: Cloud source verified v1/); assert.match(proof, /needs: \[verify\]/); assert.match(proof, /node --test scripts\/cloud-source-verification.test.mjs/); assert.match(proof, /SOURCE_SHA: \$\{\{ github\.sha \}\}/); assert.doesNotMatch(proof, /always\(\)|continue-on-error|needs:.*(?:image|artifacts)/); - assert.match(readiness.split(" ready:\n")[1], /needs: \[verify, image, artifacts\]/); + assert.doesNotMatch(readiness, /^ (?:image|artifacts|ready):/m); }); test("onboard smoke container binds beyond loopback so the mapped port is reachable", () => { diff --git a/scripts/cloud-readiness.mjs b/scripts/cloud-readiness.mjs deleted file mode 100644 index aefffe63fda..00000000000 --- a/scripts/cloud-readiness.mjs +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env node -import { pathToFileURL } from "node:url"; -import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { imageExists, versionFor } from "./preview-artifacts.mjs"; -import { verifyPublished } from "./cloud-migrator-artifacts.mjs"; - -const repository = "paperclipai/paperclip"; -const workflow = ".github/workflows/cloud-migrator-artifacts.yml"; - -export async function migratorPublished(sha, fetchImpl, token) { - let pending = false; - const failures = []; - for (let page = 1; page <= 10; page++) { - const response = await fetchImpl(`https://api.github.com/repos/${repository}/actions/workflows/cloud-migrator-artifacts.yml/runs?branch=master&head_sha=${sha}&per_page=100&page=${page}`, { - headers: { Accept: "application/vnd.github+json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, - redirect: "error", signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) throw new Error(`Migrator producer lookup failed: HTTP ${response.status}`); - const body = await response.json(); - if (!Array.isArray(body.workflow_runs) || !Number.isSafeInteger(body.total_count) || body.total_count < 0 || - (page === 1 && (body.total_count === 0) !== (body.workflow_runs.length === 0))) throw new Error("Invalid migrator producer response."); - if (body.total_count === 0) return false; - for (const run of body.workflow_runs) { - if (run.head_sha !== sha || run.head_branch !== "master" || run.path !== workflow || - run.head_repository?.id !== 1170821064 || run.head_repository.full_name !== repository || - !["push", "workflow_dispatch"].includes(run.event)) throw new Error("Migrator producer identity mismatch."); - // Publication is immutable. A later failed manual run must not hide a - // successful exact-source publisher; the signed bundle is checked next. - if (run.status === "completed" && run.conclusion === "success") return true; - if (run.status !== "completed") pending = true; - else failures.push(`${run.id}: ${run.conclusion}`); - } - if (page * 100 >= body.total_count) { - if (pending) return false; - throw new Error(`Migrator producers failed: ${failures.join(", ")}.`); - } - } - throw new Error("Too many migrator producer runs to establish publication."); -} - -export function verifyManifestProvenance(bytes, sha, { exec = execFileSync } = {}) { - versionFor(sha); - const scratch = mkdtempSync(path.join(os.tmpdir(), "cloud-readiness-attestation-")); - try { - const file = path.join(scratch, "manifest.json"); - writeFileSync(file, bytes); - exec("gh", ["attestation", "verify", file, "--repo", repository, - "--source-digest", sha, "--source-ref", "refs/heads/master", - "--cert-identity", `https://github.com/${repository}/${workflow}@refs/heads/master`, - "--deny-self-hosted-runners"], { stdio: "inherit", timeout: 60_000 }); - } finally { rmSync(scratch, { recursive: true, force: true }); } -} - -/** Read-only availability gate. Deployment still resolves and pins artifacts. */ -export async function waitForCloudArtifacts(sha, { - fetchImpl = fetch, - token = process.env.GH_TOKEN, - verifyProvenance = verifyManifestProvenance, - now = () => performance.now(), - sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - timeoutMs = 30 * 60_000, - intervalMs = 20_000, - log = console.log, -} = {}) { - const version = versionFor(sha); - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(intervalMs) || intervalMs <= 0) { - throw new Error("Cloud readiness requires positive finite timeout and poll interval."); - } - const deadline = now() + timeoutMs; - let previous; - let missing = ["image", "migrator"]; - while (now() < deadline) { - // Recheck the image and exact-source publisher on the successful poll. - // Only a missing/pending producer waits; failed publication fails closed. - const results = await Promise.all([ - imageExists(sha, fetchImpl), - migratorPublished(sha, fetchImpl, token), - ]); - missing = ["image", "migrator"].filter((_, index) => !results[index]); - if (missing.length === 0) { - // Verify the exact signed bytes and all pinned downloads after the - // publisher succeeds. An inaccessible or corrupt artifact cannot pass. - await verifyPublished(sha, fetchImpl, { verifyProvenance }); - log(`Cloud artifacts available for ${sha}: verified image and exact-source migrator ${version}.`); - return { version: 1, sha, packageVersion: version }; - } - const state = missing.join(", "); - if (state !== previous) log(`Waiting for cloud artifacts for ${sha}: ${state}.`); - previous = state; - const remaining = deadline - now(); - if (remaining > 0) await sleep(Math.min(intervalMs, remaining)); - } - throw new Error(`Cloud artifacts timed out for ${sha}; missing: ${missing.join(", ")}.`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { await waitForCloudArtifacts(process.argv[2]); } - catch (error) { console.error(error.message); process.exitCode = 1; } -} diff --git a/scripts/preview-artifacts.test.mjs b/scripts/preview-artifacts.test.mjs index 7d360787d3b..4a35378edf9 100644 --- a/scripts/preview-artifacts.test.mjs +++ b/scripts/preview-artifacts.test.mjs @@ -4,9 +4,7 @@ import { planArtifacts } from "./preview-artifacts.mjs"; import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; -import { execFileSync, spawnSync } from "node:child_process"; import { previewManifest, assertMetadata, validateRequest, versionFor, tarManifest, packageExists, imageExists, publishPreview, publishImage } from "./preview-artifacts.mjs"; const sha = "a".repeat(40); @@ -222,135 +220,3 @@ test("commits sharing a short prefix use separate full-SHA image addresses", asy await imageExists(other, fetchImpl); assert.deepEqual(urls.filter((url) => url.includes("/manifests/")), [sha, other].map((commit) => `https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${commit}-cloud`)); }); - -test("cloud builds start per commit and preserve tag promotion dependencies", () => { - const docker = readFileSync(new URL("../.github/workflows/docker.yml", import.meta.url), "utf8"); - const cloud = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8"); - const readiness = readFileSync(new URL("../.github/workflows/cloud-readiness.yml", import.meta.url), "utf8"); - assert.match(readiness, /branches: \[master\]/); - assert.match(readiness, /uses: \.\/\.github\/workflows\/docker-cloud.yml/); - assert.doesNotMatch(cloud, /^ push:/m); - assert.match(cloud, /workflow_call:/); - assert.match(cloud, /group: docker-cloud-\$\{\{ github.sha \}\}/); - assert.match(cloud, /cancel-in-progress: false/); - assert.doesNotMatch(cloud, /uses: .*@v\d\b/); - assert.match(cloud, /cache-to: type=registry,ref=ghcr.io\/\$\{\{ github.repository \}\}:buildcache-cloud-\$\{\{ github.sha \}\},mode=max/); - const caller = docker.split(" build-and-push-cloud:")[1].split(" promote_canary_channel:")[0]; - assert.match(caller, /if: github.event_name != 'push' \|\| github.ref != 'refs\/heads\/master'/); - assert.match(caller, /uses: .\/.github\/workflows\/docker-cloud.yml/); - assert.match(docker.split(" promote_canary_channel:")[1], /needs: \[merge-and-push, build-and-push-cloud\]/); - const reaping = cloud.indexOf(" - name: Verify cloud PID 1 reaps orphaned processes"); - assert.ok(reaping > cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version")); - assert.ok(reaping < cloud.indexOf(" - name: Publish verified full-SHA cloud tag")); -}); - -test("cloud builds bake the managed runtime identity and verify it before publication", () => { - const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8"); - const build = workflow.split(" - name: Build and push (cloud)")[1].split(" - name:")[0]; - assert.match(build, /build-args: \|\n\s+USER_UID=1001\n\s+USER_GID=1001\n/); - const verify = workflow.indexOf(" - name: Verify cloud runtime user"); - assert.ok(verify > workflow.indexOf(" - name: Verify the pushed image resolves the declared Sentry version")); - assert.ok(verify < workflow.indexOf(" - name: Publish verified full-SHA cloud tag")); - const step = workflow.slice(verify).split("\n - name:")[0]; - assert.match(step, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/); - assert.doesNotMatch(step, /continue-on-error:|if:/); - assert.ok(step.indexOf('--entrypoint sh "$IMAGE"') < step.indexOf('-e USER_UID=1001 -e USER_GID=1001')); - for (const flag of ["u", "g"]) { - assert.ok(step.includes(`test "$(id -${flag} node)" = 1001`)); - assert.ok(step.includes(`test "$(id -${flag})" = 1001`)); - } - assert.ok(step.includes('test -w "$PAPERCLIP_HOME"')); -}); - -test("cloud cache imports are bounded, follow master ancestry, and retain the legacy fallback", () => { - const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8"); - const selector = workflow.indexOf(" - name: Select cloud cache ancestry"); - assert.ok(selector > workflow.indexOf(" - name: Login to GitHub Container Registry")); - assert.ok(selector > workflow.indexOf(" - name: Set up Docker Buildx")); - assert.ok(selector < workflow.indexOf(" - name: Build and push (cloud)")); - assert.match(workflow, /run: node scripts\/select-cloud-cache.mjs/); - assert.match(workflow, /cache-from: \$\{\{ steps.cloud-cache.outputs.source \}\}/); - const script = fileURLToPath(new URL("./select-cloud-cache.mjs", import.meta.url)); - const dir = mkdtempSync(path.join(tmpdir(), "cloud-cache-test-")); - const output = path.join(dir, "output"); - const env = { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.test" }; - const git = (...args) => execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); - try { - git("init", "--initial-branch=master"); - const commits = []; - for (let i = 0; i < 12; i++) { - git("commit", "--allow-empty", "-m", `main ${i}`); - commits.unshift(git("rev-parse", "HEAD")); - } - git("checkout", "-b", "topic", "HEAD~1"); - git("commit", "--allow-empty", "-m", "topic"); - git("checkout", "master"); - git("merge", "--no-ff", "topic", "-m", "merge topic"); - commits.unshift(git("rev-parse", "HEAD")); - const available = `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commits[2]}`; - const inspections = path.join(dir, "inspections"); - writeFileSync(path.join(dir, "docker"), `#!/usr/bin/env node -const fs = require("node:fs"); -fs.appendFileSync(process.env.CACHE_INSPECTIONS, process.argv.at(-1) + "\\n"); -if (process.argv.at(-1) !== process.env.AVAILABLE_CACHE) { - process.stderr.write("manifest unknown"); - process.exit(1); -} -`, { mode: 0o755 }); - const result = spawnSync(process.execPath, [script], { - cwd: dir, encoding: "utf8", env: { - ...env, PATH: `${dir}${path.delimiter}${env.PATH}`, CACHE_IMAGE: "ghcr.io/paperclipai/paperclip", - GITHUB_OUTPUT: output, AVAILABLE_CACHE: available, CACHE_INSPECTIONS: inspections, - }, - }); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(output, "utf8"), `source=type=registry,ref=${available}\n`); - assert.deepEqual(readFileSync(inspections, "utf8").trim().split("\n"), commits.slice(0, 3).map((commit) => `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commit}`)); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test("normal cloud builds publish the checked digest only when source and platform match", () => { - const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8"); - const cloud = workflow.split(" build-and-push-cloud:")[1]; - const verify = cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"); - const publish = cloud.indexOf(" - name: Publish verified full-SHA cloud tag"); - assert.ok(verify >= 0 && publish > verify); - const verification = cloud.slice(verify, publish); - assert.match(verification, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/); - assert.doesNotMatch(verification, /continue-on-error:|if: always\(/); - const step = cloud.slice(publish).split(/\n(?: #| - name:)/)[0]; - assert.doesNotMatch(step, /continue-on-error:|if:/); - assert.match(step, /FULL_SHA_TAG: ghcr.io\/\$\{\{ github.repository \}\}:sha-\$\{\{ github.sha \}\}-cloud/); - const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n"); - const dir = mkdtempSync(path.join(tmpdir(), "cloud-tag-test-")); - const image = `ghcr.io/paperclipai/paperclip@sha256:${"b".repeat(64)}`; - const tag = `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`; - try { - writeFileSync(path.join(dir, "docker"), `#!/bin/sh -case "$1 $2" in - 'image inspect') - case "$5" in - *revision*) printf '%s\\n' "$TEST_REVISION" ;; - *) printf '%s\\n' "$TEST_PLATFORM" ;; - esac ;; - 'buildx imagetools') printf '%s\\n' "$@" > "$TEST_CALLS" ;; - *) exit 99 ;; -esac -`, { mode: 0o755 }); - for (const [revision, platform, succeeds] of [[sha, "linux/amd64", true], ["c".repeat(40), "linux/amd64", false], [sha, "linux/arm64", false]]) { - const calls = path.join(dir, "calls"); - rmSync(calls, { force: true }); - const result = spawnSync("bash", ["-c", script], { encoding: "utf8", env: { - ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, GITHUB_SHA: sha, - IMAGE: image, FULL_SHA_TAG: tag, TEST_REVISION: revision, TEST_PLATFORM: platform, TEST_CALLS: calls, - } }); - if (succeeds) { - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(readFileSync(calls, "utf8").trim().split("\n"), ["buildx", "imagetools", "create", "--prefer-index=false", "--tag", tag, image]); - } else { - assert.notEqual(result.status, 0); - assert.throws(() => readFileSync(calls), { code: "ENOENT" }); - } - } - } finally { rmSync(dir, { recursive: true, force: true }); } -}); diff --git a/scripts/select-cloud-cache.mjs b/scripts/select-cloud-cache.mjs deleted file mode 100644 index 74b05a533d3..00000000000 --- a/scripts/select-cloud-cache.mjs +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -import { execFileSync } from "node:child_process"; -import { appendFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; - -export function cloudCacheCandidates(image, commits) { - if (!/^ghcr\.io\/[a-z0-9._-]+\/[a-z0-9._-]+$/.test(image ?? "")) { - throw new Error("Expected a GHCR owner/repository cache image."); - } - if (!Array.isArray(commits) || commits.length === 0 || commits.some((sha) => !/^[a-f0-9]{40}$/.test(sha))) { - throw new Error("Cloud cache ancestry requires full commit SHAs."); - } - return [ - ...[...new Set(commits)].slice(0, 10).map((sha) => `${image}:buildcache-cloud-${sha}`), - `${image}:buildcache-cloud`, - ]; -} - -export async function selectCloudCache(image, commits, { - exists = registryCacheExists, - log = console.log, -} = {}) { - for (const ref of cloudCacheCandidates(image, commits)) { - try { - if (!await exists(ref)) continue; - log(`Using cloud cache: ${ref}`); - return `type=registry,ref=${ref}`; - } catch { - // Cache availability must not turn an otherwise valid build into a - // failure. A later ancestor may still be available during a rollout. - log(`Could not inspect cloud cache ${ref}; trying the next ancestor.`); - } - } - log("No cloud cache is available; this build will populate one."); - return ""; -} - -function registryCacheExists(ref) { - try { - // Use the preceding Docker login, including for private registry caches. - // Inspect metadata only: no layer download and no image execution. - execFileSync("docker", ["buildx", "imagetools", "inspect", "--raw", ref], { - timeout: 10_000, - maxBuffer: 1024 * 1024, - stdio: ["ignore", "pipe", "pipe"], - }); - return true; - } catch (error) { - if (/manifest unknown|not found|NAME_UNKNOWN/i.test(String(error.stderr ?? ""))) return false; - throw error; - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required."); - const commits = execFileSync("git", ["rev-list", "--first-parent", "--max-count=10", "HEAD"], { - encoding: "utf8", - }).trim().split("\n"); - const source = await selectCloudCache(process.env.CACHE_IMAGE, commits, { exists: registryCacheExists }); - appendFileSync(process.env.GITHUB_OUTPUT, `source=${source}\n`); - } catch (error) { - console.error(error.message); - process.exitCode = 1; - } -} diff --git a/scripts/select-cloud-cache.test.mjs b/scripts/select-cloud-cache.test.mjs deleted file mode 100644 index d160fa4f048..00000000000 --- a/scripts/select-cloud-cache.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { cloudCacheCandidates, selectCloudCache } from "./select-cloud-cache.mjs"; - -const image = "ghcr.io/paperclipai/paperclip"; -const commits = ["a".repeat(40), "b".repeat(40), "c".repeat(40)]; -const candidates = cloudCacheCandidates(image, commits); - -test("a same-SHA rerun imports only its existing cache", async () => { - const inspected = []; - const source = await selectCloudCache(image, commits, { - exists: async (ref) => { inspected.push(ref); return true; }, log() {}, - }); - assert.equal(source, `type=registry,ref=${candidates[0]}`); - assert.deepEqual(inspected, candidates.slice(0, 1)); -}); - -test("a new merge imports only its nearest available ancestor", async () => { - const inspected = []; - const source = await selectCloudCache(image, commits, { - exists: async (ref) => { inspected.push(ref); return ref === candidates[1]; }, log() {}, - }); - assert.equal(source, `type=registry,ref=${candidates[1]}`); - assert.deepEqual(inspected, candidates.slice(0, 2)); - assert.equal(source.includes("\n"), false); -}); - -test("a still-building parent falls back to an older completed cache", async () => { - assert.equal(await selectCloudCache(image, commits, { - exists: async (ref) => ref === candidates[2], log() {}, - }), `type=registry,ref=${candidates[2]}`); -}); - -test("the legacy cache is used only if no SHA cache exists", async () => { - const inspected = []; - assert.equal(await selectCloudCache(image, commits, { - exists: async (ref) => { inspected.push(ref); return ref === candidates.at(-1); }, log() {}, - }), `type=registry,ref=${candidates.at(-1)}`); - assert.deepEqual(inspected, candidates); -}); - -test("missing caches permit a cold build", async () => { - assert.equal(await selectCloudCache(image, commits, { exists: async () => false, log() {} }), ""); -}); - -test("a failed lookup can fall back without failing image publication", async () => { - const messages = []; - assert.equal(await selectCloudCache(image, commits, { - exists: async (ref) => { - if (ref === candidates[0]) throw new Error("registry temporarily unavailable"); - return true; - }, - log: (message) => messages.push(message), - }), `type=registry,ref=${candidates[1]}`); - assert.match(messages[0], /Could not inspect cloud cache/); -}); - -test("ancestry is bounded, deduplicated, and rejects output injection", () => { - const many = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(40, "0")); - assert.equal(cloudCacheCandidates(image, many).length, 11); - assert.deepEqual(cloudCacheCandidates(image, [commits[0], commits[0]]), [candidates[0], candidates.at(-1)]); - assert.throws(() => cloudCacheCandidates(`${image}\nsource=untrusted`, commits)); - assert.throws(() => cloudCacheCandidates(image, ["master"])); - assert.throws(() => cloudCacheCandidates(image, [])); -}); diff --git a/server/src/__tests__/cloud-image-bundled-plugins.test.ts b/server/src/__tests__/cloud-image-bundled-plugins.test.ts index 0358db8f8b6..475fd522259 100644 --- a/server/src/__tests__/cloud-image-bundled-plugins.test.ts +++ b/server/src/__tests__/cloud-image-bundled-plugins.test.ts @@ -5,22 +5,18 @@ import { describe, expect, it } from "vitest"; import { BUNDLED_PLUGIN_CATALOG } from "../services/bundled-plugins.js"; /** - * Drift guard for the cloud image variant (Dockerfile `cloud` target). + * Drift guard for the explicit preview image (Dockerfile `cloud` target). * - * The cloud image builds the sandbox-provider plugins named in the + * The preview image builds the sandbox-provider plugins named in the * CLOUD_BUNDLED_PLUGINS build arg so managed instances can auto-install - * them from the bundled catalog at boot. That contract spans three places - * that nothing else ties together: the Dockerfile ARG default, the docker - * workflow's build-arg, and BUNDLED_PLUGIN_CATALOG. A rename or removal in - * any one of them would otherwise surface only when the image build fails - * on master — or worse, as a silent "bundle not present" skip at instance - * boot. + * them from the bundled catalog at boot. The Dockerfile default and + * BUNDLED_PLUGIN_CATALOG must agree even after the recurring public cloud + * publisher is retired. Explicit previews still use this build target. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8"); -const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8"); function parseList(source: string, pattern: RegExp, label: string): string[] { const match = source.match(pattern); @@ -35,18 +31,9 @@ const dockerfileDefault = parseList( /^ARG CLOUD_BUNDLED_PLUGINS="([^"]*)"/m, "Dockerfile", ); -const workflowArg = parseList( - cloudWorkflow, - /^\s*CLOUD_BUNDLED_PLUGINS=(.*)$/m, - "docker workflow", -); describe("cloud image bundled plugins", () => { - it("keeps the Dockerfile default and the workflow build-arg in sync", () => { - expect(workflowArg).toEqual(dockerfileDefault); - }); - - it.each([...new Set([...dockerfileDefault, ...workflowArg])])( + it.each(dockerfileDefault)( "plugin %s is buildable and resolvable by the auto-installer", (name) => { const dir = path.join(repoRoot, "packages", "plugins", "sandbox-providers", name); @@ -77,37 +64,6 @@ describe("cloud image bundled plugins", () => { expect(workflow).toMatch(/^\s*target: production$/m); }); - it("publishes the cloud image in its own job with no needs coupling", () => { - const caller = workflow.split(" build-and-push-cloud:")[1]?.split(" promote_canary_channel:")[0]; - expect(caller, "tag and manual builds must call the cloud workflow").toContain("uses: ./.github/workflows/docker-cloud.yml"); - expect(caller, "the reusable caller must also remain independent of production").not.toMatch(/^\s*needs:/m); - // The reusable cloud workflow owns its job and SHA concurrency group. - // Production publication must not gate, delay, or skip the cloud build. - const jobsSection = cloudWorkflow.slice(cloudWorkflow.indexOf("\njobs:\n")); - const headers = [...jobsSection.matchAll(/^ {2}([\w-]+):[^\n]*$/gm)]; - expect( - headers.length, - "docker-cloud.yml must declare a cloud build job under jobs:", - ).toBeGreaterThanOrEqual(1); - - // Locate the job block that carries the cloud build (target: cloud) and - // assert it declares no `needs:` — coupling it to another job would - // reintroduce the shared failure the split job exists to remove. - const cloudHeaderIdx = headers.findIndex((header, i) => { - const start = header.index ?? 0; - const end = headers[i + 1]?.index ?? jobsSection.length; - return jobsSection.slice(start, end).includes("target: cloud"); - }); - expect(cloudHeaderIdx, "one job must build the cloud target").toBeGreaterThanOrEqual(0); - const start = headers[cloudHeaderIdx].index ?? 0; - const end = headers[cloudHeaderIdx + 1]?.index ?? jobsSection.length; - const cloudJobBlock = jobsSection.slice(start, end); - expect( - cloudJobBlock, - "the cloud job must not couple to another job via needs:", - ).not.toMatch(/^\s*needs:/m); - }); - it("throttles the docker workflow with cancel-in-progress: false", () => { // Concurrency is declared at the workflow (top) level so a single group // spans the whole run, and cancel-in-progress is false so an in-flight diff --git a/server/src/__tests__/cloud-image-sentry.test.ts b/server/src/__tests__/cloud-image-sentry.test.ts index 25a55e4bafb..1a01dbd2887 100644 --- a/server/src/__tests__/cloud-image-sentry.test.ts +++ b/server/src/__tests__/cloud-image-sentry.test.ts @@ -14,12 +14,12 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; /** - * Drift guard for the cloud image variant's bundled Sentry server package + * Drift guard for the explicit preview image's bundled Sentry server package * (Dockerfile `cloud` target). * * The self-hosted image, built from the `production` target, keeps * `@sentry/node` as a true optional peer dependency: the operator installs - * it themselves. The hosted (cloud) image installs the packages the + * it themselves. The explicit preview image installs the packages the * `CLOUD_BUNDLED_SERVER_DEPS` build argument names, so a managed tenant * gets server error reports with no separate install step. The stage * reads each package's version from the `peerDependencies` block of @@ -30,14 +30,13 @@ import { describe, expect, it } from "vitest"; * workflow carry no literal version pin (they read the version from * `server/package.json` at build time instead); the `cloud-server-deps` * stage declares the `CLOUD_BUNDLED_SERVER_DEPS` build argument with a - * default that names `@sentry/node`; the docker workflow passes that same - * argument to the cloud build; and no committed manifest re-declares the + * default that names `@sentry/node`; and no committed manifest re-declares the * version. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); -const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8"); +const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8"); const serverPackageJson = JSON.parse( readFileSync(path.join(repoRoot, "server", "package.json"), "utf8"), ) as { peerDependencies?: Record }; @@ -164,10 +163,6 @@ describe("cloud image Sentry install", () => { ).toContain("@sentry/node"); }); - it("passes CLOUD_BUNDLED_SERVER_DEPS to the cloud build in the docker workflow", () => { - expect(workflow).toMatch(/^\s*CLOUD_BUNDLED_SERVER_DEPS=@sentry\/node\s*$/m); - }); - it("declares no committed manifest that re-states the version", () => { expect( existsSync(path.join(repoRoot, "docker", "cloud-server-deps")), diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 76862952c88..285fb34fd29 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -21,7 +21,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8"); -const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8"); +const previewWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8"); /** * Return the text of the Dockerfile stage that starts at the named target. @@ -68,12 +68,11 @@ describe("docker build-stamp wiring", () => { ).toBeLessThan(serverBuildIdx); }); - it("passes PAPERCLIP_BUILD_COMMIT as a build-arg for both image targets", () => { - const argLines = [...`${workflow}\n${cloudWorkflow}`.matchAll(/^\s*PAPERCLIP_BUILD_COMMIT=.*$/gm)]; - expect( - argLines.length, - "the docker workflow must pass PAPERCLIP_BUILD_COMMIT for the production and cloud builds", - ).toBeGreaterThanOrEqual(2); + it("passes PAPERCLIP_BUILD_COMMIT as a build-arg for standard and explicit preview builds", () => { + for (const [name, source] of [["standard", workflow], ["preview", previewWorkflow]]) { + expect(source, `${name} must pass the source commit into the image build`) + .toMatch(/^\s*PAPERCLIP_BUILD_COMMIT=\$\{\{ (?:github.sha|inputs.source_ref) \}\}$/m); + } }); }); From 94a0aa77261682ce49ea3c57e2ee080e99e5e3d0 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 19:38:07 -0700 Subject: [PATCH 07/16] fix(ui): hide workspace isolation controls for managed hosts (#13907) ## Thinking Path > - Paperclip manages AI agents and their work. > - Workspace isolation keeps task checkouts separate. > - Managed hosts can enable isolation and hide its experimental toggles. > - Project, task, and routine forms still expose choices that override that policy. > - This pull request adds an operator visibility key for those controls. > - Workspace access stays available, and execution keeps its existing policy. ## Linked Issues or Issue Description **What existing behavior does this improve?** Operator control over workspace isolation settings in the UI. This follows the settings list cleanup in #13905. **Current behavior** Hiding the experimental isolation toggles leaves project policy editors, task selectors, routine and pipeline overrides, recovery actions, and workspace configuration visible. **Proposed behavior** Set `PAPERCLIP_HIDDEN_SETTINGS=workspaces.isolation` to hide these controls. Keep workspace navigation, status, files, and runtime access. Hide experimental toggles separately. Instances that do not set this key keep their controls. **Reason and benefit** Users on managed hosts should use the host's isolation default. A hidden form must not submit a stale draft that overrides it. ## What Changed - Add the UI-only `workspaces.isolation` key to the shared visibility registry. - Hide project workspace policy, task and subtask selectors, routine and pipeline overrides, and isolated re-issue actions. - Hide the workspace Configuration tab and redirect direct links to workspace issues. - Omit hidden new-task and routine overrides. Keep explicit task/subtask workspace launch context, saved policies, and automatic branch values for workspace routine runs. - Wait for the health visibility policy before showing controls. Keep workspace access and all execution APIs available. - Document the key and test visibility, form payloads, deep links, and unchanged workspace access. ## Verification - All 52 CI checks pass on `50cc770d33` (two expected skips). The branch is mergeable. Greptile is 5/5 with no unresolved comments. - `pnpm -r typecheck` passed. - `pnpm build` passed. - `pnpm check:token-gates` passed. - Targeted UI checks passed: 346 tests across 14 suites, including hidden project/task controls, stale task drafts, routine branch defaults, recovery actions, configuration deep links, and workspace access. - Shared settings-visibility tests passed: 11 tests. - `pnpm test:run` was run and stopped after reproducing five failures in unchanged server tests: two `chat-channels.integration` cases (linked-request provenance and direct external-chat finals) and three `company-skills-service` cases (runtime refresh, concurrent download, and explicit update). The earlier local run for #13905 showed the same failures. The full local suite is not claimed green. Targeted UI/shared checks pass. All PR CI shards, including the affected chat and skills suites, pass. - Reviewed the diff for secrets, private links, and run artifacts. ## Risks This key changes UI visibility only. It does not reject API calls or change feature values. Operators must enable isolation and its default through their existing policy mechanism. Older app versions ignore the new key until upgraded. Removing the key restores the controls. No schema changes. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally; targeted checks pass (full-suite limitation documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- docs/deploy/environment-variables.md | 10 +++ packages/shared/src/index.ts | 2 + .../shared/src/settings-visibility.test.ts | 5 ++ packages/shared/src/settings-visibility.ts | 6 ++ ui/src/components/IssueProperties.test.tsx | 18 ++++- .../IssueRecoveryActionCard.test.tsx | 13 ++++ ui/src/components/IssueRecoveryActionCard.tsx | 5 +- ui/src/components/IssueWorkspaceCard.test.tsx | 29 ++++++++ ui/src/components/IssueWorkspaceCard.tsx | 67 ++++++++++--------- ui/src/components/NewIssueDialog.test.tsx | 50 +++++++++++++- ui/src/components/NewIssueDialog.tsx | 18 +++-- .../ProjectProperties.concurrency.test.tsx | 16 ++++- ui/src/components/ProjectProperties.tsx | 4 +- .../RoutineRunVariablesDialog.test.tsx | 64 +++++++++--------- .../components/RoutineRunVariablesDialog.tsx | 10 ++- .../issue-properties/IssueProperties.tsx | 8 ++- .../useWorkspaceIsolationControls.test.tsx | 47 +++++++++++++ ui/src/hooks/useWorkspaceIsolationControls.ts | 7 ++ .../pages/ExecutionWorkspaceDetail.test.tsx | 17 ++++- ui/src/pages/ExecutionWorkspaceDetail.tsx | 16 ++++- ui/src/pages/PipelineSettings.tsx | 8 ++- 21 files changed, 336 insertions(+), 84 deletions(-) create mode 100644 ui/src/hooks/useWorkspaceIsolationControls.test.tsx create mode 100644 ui/src/hooks/useWorkspaceIsolationControls.ts diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index b2985bd7a95..286f42f8f6d 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -114,6 +114,16 @@ Daytona snapshot for future leases. while the rest of the page stays up. UI-visibility only; the secret provider-config and proposal APIs stay live for agents and integrations. +- `workspaces.isolation` hides project execution-workspace policy, task and + routine workspace selectors, pipeline workspace overrides, isolated re-issue + actions, and the execution-workspace Configuration tab (including direct + links). Workspace navigation, files, status, and runtime access stay available. + This key only controls UI visibility: it does not disable isolation, change + saved policies, or block APIs used by agents. New tasks and routine runs omit + hidden draft overrides so the server applies the existing defaults. Tasks + launched from a workspace or parent task keep that explicit context. Hide the two + experimental isolation toggles separately when the operator manages them. + Unknown keys are logged and ignored, so one list can be rolled across a fleet of mixed app versions, and retired keys (like `instance.heartbeats`, whose page was removed) can stay in an operator list without breaking older or diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 085fb0411aa..8260662b3d8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2680,6 +2680,7 @@ export { HIDEABLE_GENERAL_SECTIONS, HIDEABLE_INSTANCE_PAGES, HIDEABLE_SETTING_KEYS, + HIDEABLE_WORKSPACE_SECTIONS, SETTINGS_OPERATOR_MANAGED_ERROR_CODE, UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, @@ -2695,6 +2696,7 @@ export { type HideableGeneralSection, type HideableInstancePage, type HideableSettingKey, + type HideableWorkspaceSection, type ParsedHiddenSettings, } from "./settings-visibility.js"; export { diff --git a/packages/shared/src/settings-visibility.test.ts b/packages/shared/src/settings-visibility.test.ts index 0184922a9d2..0a657065faf 100644 --- a/packages/shared/src/settings-visibility.test.ts +++ b/packages/shared/src/settings-visibility.test.ts @@ -49,6 +49,11 @@ describe("hideable setting keys", () => { }); describe("parseHiddenSettingsList", () => { + it("accepts workspace controls independently of experimental flags", () => { + expect(parseHiddenSettingsList("workspaces.isolation")).toEqual({ hidden: ["workspaces.isolation"], unknown: [] }); + expect(hidesExperimentalSetting(new Set(["workspaces.isolation"]), "enableIsolatedWorkspaces")).toBe(false); + }); + it("returns nothing hidden for undefined or empty input", () => { expect(parseHiddenSettingsList(undefined)).toEqual({ hidden: [], unknown: [] }); expect(parseHiddenSettingsList(" , ,")).toEqual({ hidden: [], unknown: [] }); diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 6729d4d062b..3b1e1047394 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -99,7 +99,12 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim return `instance.experimental.${key}`; } +/** Workspace policy editors and selectors. UI-only; execution and APIs stay active. */ +export const HIDEABLE_WORKSPACE_SECTIONS = ["workspaces.isolation"] as const; +export type HideableWorkspaceSection = (typeof HIDEABLE_WORKSPACE_SECTIONS)[number]; + export type HideableSettingKey = + | HideableWorkspaceSection | HideableInstancePage | HideableCompanyPage | HideableCompanySection @@ -108,6 +113,7 @@ export type HideableSettingKey = /** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */ export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [ + ...HIDEABLE_WORKSPACE_SECTIONS, ...HIDEABLE_INSTANCE_PAGES, ...HIDEABLE_COMPANY_PAGES, ...HIDEABLE_COMPANY_SECTIONS, diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 6b3492230eb..6d4ef3258fc 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -438,12 +438,13 @@ function createExecutionState(overrides: Partial = {}): Iss }; } -function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps) { +function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps, hiddenSettings: string[] = []) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, }, }); + queryClient.setQueryData(queryKeys.health, { hiddenSettings }); const root = createRoot(container); act(() => { root.render( @@ -3451,6 +3452,21 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("hides workspace selection but keeps the bound workspace accessible", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockProjectsApi.list.mockResolvedValue([createProject({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } })]); + const onUpdate = vi.fn(); + const { root } = renderPropertiesWithQueryClient(container, { + issue: createIssue({ projectId: "project-1", executionWorkspaceId: "workspace-1", currentExecutionWorkspace: createExecutionWorkspace() }), + childIssues: [], onUpdate, inline: true, + }, ["workspaces.isolation"]); + await flush(); + expect(container.querySelector('[data-property-label="Execution"]')).toBeNull(); + expect(container.querySelector('a[href="/execution-workspaces/workspace-1"]')).not.toBeNull(); + expect(onUpdate).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + it("shows the workspace picker with no bound workspace", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); mockProjectsApi.list.mockResolvedValue([createProject({ diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index b8ba6701bb8..d8480fb51c5 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -13,6 +13,10 @@ vi.mock("@/lib/router", () => ({ ), })); +const visibility = vi.hoisted(() => ({ visible: true, loaded: true })); +vi.mock("@/hooks/useWorkspaceIsolationControls", () => ({ useWorkspaceIsolationControls: () => visibility })); +beforeEach(() => { visibility.visible = true; visibility.loaded = true; }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -458,6 +462,15 @@ describe("IssueRecoveryActionCard workspace_validation divergence", () => { expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).toBeNull(); }); + it("hides the isolated re-issue action under operator visibility policy", () => { + visibility.visible = false; + const onReissueIsolated = vi.fn(); + const node = render(); + expect(node.querySelector("[data-testid='recovery-action-reissue-trigger']")).toBeNull(); + expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull(); + expect(onReissueIsolated).not.toHaveBeenCalled(); + }); + it("offers the re-issue action and passes the live branch as the base ref", () => { const onReissueIsolated = vi.fn(); const node = render( diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 4f4736f2635..8e7b7047be9 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -1,3 +1,4 @@ +import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { requiresExecutionReconciliation } from "@paperclipai/shared"; import { useMemo, useState } from "react"; import type { @@ -993,6 +994,7 @@ export function IssueRecoveryActionCard({ variant = "full", className, }: IssueRecoveryActionCardProps) { + const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const liveness = useMemo(() => ({ scheduledRetry }), [scheduledRetry]); const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action, liveness); const tone = STATE_TONE[cardState]; @@ -1065,6 +1067,7 @@ export function IssueRecoveryActionCard({ }); const reissueBaseRef = divergence?.reissueBaseRef ?? null; const showReissueAction = + workspaceIsolationControlsVisible && onReissueIsolated !== undefined && cardState !== "resolved" && divergence !== null && @@ -1096,7 +1099,7 @@ export function IssueRecoveryActionCard({ divergence !== null && divergence.cleanliness === "dirty"; const repairDisabledReason = repairContention - ? `Held by ${contentionLabel(repairContention)} — re-issue on an isolated workspace instead.` + ? `Held by ${contentionLabel(repairContention)}${showReissueAction ? " — re-issue on an isolated workspace instead." : "."}` : null; // When contended, the re-issue is the recommended path, so it takes the primary emphasis and a // "Recommended" hint while the repair button is disabled. diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 3e744ebef6a..7d88e8ddfa9 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -35,6 +35,10 @@ vi.mock("@/lib/router", () => ({ ), })); +const visibility = vi.hoisted(() => ({ visible: true, loaded: true })); +vi.mock("@/hooks/useWorkspaceIsolationControls", () => ({ useWorkspaceIsolationControls: () => visibility })); +beforeEach(() => { visibility.visible = true; visibility.loaded = true; }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -146,6 +150,31 @@ describe("IssueWorkspaceCard", () => { container.remove(); }); + it("keeps workspace details and files while suppressing editing and draft writes", () => { + visibility.visible = false; + useQueryMock.mockImplementation((options: { queryKey: unknown[] }) => ({ + data: options.queryKey[0] === "instance" ? { enableIsolatedWorkspaces: true } : [], + })); + const root = createRoot(container); + const onUpdate = vi.fn(); + const onDraftChange = vi.fn(); + const onBrowseFiles = vi.fn(); + act(() => root.render()); + expect(container.querySelector("select")).toBeNull(); + expect(container.textContent).not.toContain("Save"); + expect(container.textContent).toContain("View workspace details"); + const browse = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Browse files")); + act(() => browse!.click()); + expect(onBrowseFiles).toHaveBeenCalledOnce(); + expect(onUpdate).not.toHaveBeenCalled(); + expect(onDraftChange).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + it("clears the legacy issue environment override when reusing a workspace", () => { const root = createRoot(container); const onUpdate = vi.fn(); diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 8f82c5360f3..372fec857cf 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -1,3 +1,4 @@ +import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "@/lib/router"; import type { Issue, ExecutionWorkspace } from "@paperclipai/shared"; @@ -193,6 +194,7 @@ export function IssueWorkspaceCard({ onBrowseFiles, onOpenFileByPath, }: IssueWorkspaceCardProps) { + const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const { selectedCompanyId } = useCompany(); const companyId = issue.companyId ?? selectedCompanyId; const [editing, setEditing] = useState(initialEditing); @@ -236,7 +238,7 @@ export function IssueWorkspaceCard({ projectWorkspaceId: issue.projectWorkspaceId ?? undefined, reuseEligible: true, }), - enabled: Boolean(companyId) && Boolean(issue.projectId) && editing, + enabled: Boolean(companyId) && Boolean(issue.projectId) && editing && workspaceIsolationControlsVisible, }); const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? []; @@ -306,15 +308,15 @@ export function IssueWorkspaceCard({ ]); useEffect(() => { - if (!onDraftChange) return; + if (!onDraftChange || !workspaceIsolationControlsVisible) return; onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig, workspaceBranchName: draftWorkspaceBranchName, }); - }, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange]); + }, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange, workspaceIsolationControlsVisible]); const handleSave = useCallback(() => { - if (!canSaveWorkspaceConfig) return; + if (!canSaveWorkspaceConfig || !workspaceIsolationControlsVisible) return; const update = buildWorkspaceDraftUpdate(); if (!update) return; onUpdate(update); @@ -322,6 +324,7 @@ export function IssueWorkspaceCard({ }, [ buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, + workspaceIsolationControlsVisible, onUpdate, ]); @@ -333,7 +336,7 @@ export function IssueWorkspaceCard({ if (!policyEnabled || !project) return null; - const showEditingControls = livePreview || editing; + const showEditingControls = workspaceIsolationControlsVisible && (livePreview || editing); return (
@@ -346,37 +349,39 @@ export function IssueWorkspaceCard({ : configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)} {workspace ? statusBadge(workspace.status) : statusBadge("idle")}
-
- {showEditingControls ? ( - <> + {workspaceIsolationControlsVisible && ( +
+ {showEditingControls ? ( + <> + + + + ) : ( - - - ) : ( - - )} -
+ )} +
+ )} {/* Read-only info */} @@ -448,7 +453,7 @@ export function IssueWorkspaceCard({ )} {/* Editing controls */} - {editing && ( + {editing && workspaceIsolationControlsVisible && (
) : ("idle"); - const prompt = buildGitHubSetupPrompt(instanceUrl); - return ( -
- - {status === "copied" ? "Setup prompt copied. Paste it into Codex or Claude with browser tools." : ""} - {status === "failed" && ( -
-

Could not copy automatically. Select and copy the setup prompt below.

-